feat: add hwpod harness node-ops loop
This commit is contained in:
@@ -79,6 +79,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥
|
|||||||
- v0.2 Code Agent 由 `hwlab-cloud-api` 接入 AgentRun v0.1 共享执行基础设施,不再保留 HWLAB 自有 agent manager/worker 控制面:[docs/reference/agentrun-code-agent-dispatch.md](docs/reference/agentrun-code-agent-dispatch.md)。
|
- v0.2 Code Agent 由 `hwlab-cloud-api` 接入 AgentRun v0.1 共享执行基础设施,不再保留 HWLAB 自有 agent manager/worker 控制面:[docs/reference/agentrun-code-agent-dispatch.md](docs/reference/agentrun-code-agent-dispatch.md)。
|
||||||
- v0.2 `hwlab-agent-skills` 技能包服务规格:[docs/reference/spec-v02-hwlab-agent-skills.md](docs/reference/spec-v02-hwlab-agent-skills.md)。
|
- v0.2 `hwlab-agent-skills` 技能包服务规格:[docs/reference/spec-v02-hwlab-agent-skills.md](docs/reference/spec-v02-hwlab-agent-skills.md)。
|
||||||
- v0.2 `hwlab-cli` 固定 repo 短连接 client 规格:[docs/reference/spec-v02-hwlab-cli.md](docs/reference/spec-v02-hwlab-cli.md)。
|
- v0.2 `hwlab-cli` 固定 repo 短连接 client 规格:[docs/reference/spec-v02-hwlab-cli.md](docs/reference/spec-v02-hwlab-cli.md)。
|
||||||
|
- HWPOD Harness 快速迭代规格,定义 workspace-local `hwpod-spec`、`hwpod-cli`、`hwpod-ctl`、`hwpod-compiler-cli`、`hwpod-node-ops` 和 v0.2 Device Pod 迁移映射:[docs/reference/spec-hwpod-harness.md](docs/reference/spec-hwpod-harness.md)。
|
||||||
- v0.2 `hwlab-device-pod` 部署服务规格:[docs/reference/spec-v02-hwlab-device-pod-service.md](docs/reference/spec-v02-hwlab-device-pod-service.md)。
|
- v0.2 `hwlab-device-pod` 部署服务规格:[docs/reference/spec-v02-hwlab-device-pod-service.md](docs/reference/spec-v02-hwlab-device-pod-service.md)。
|
||||||
- v0.2 `hwlab-gateway` 硬件 transport 边界规格:[docs/reference/spec-v02-hwlab-gateway.md](docs/reference/spec-v02-hwlab-gateway.md)。
|
- v0.2 `hwlab-gateway` 硬件 transport 边界规格:[docs/reference/spec-v02-hwlab-gateway.md](docs/reference/spec-v02-hwlab-gateway.md)。
|
||||||
- v0.2 `hwlab-edge-proxy` API edge proxy 规格:[docs/reference/spec-v02-hwlab-edge-proxy.md](docs/reference/spec-v02-hwlab-edge-proxy.md)。
|
- v0.2 `hwlab-edge-proxy` API edge proxy 规格:[docs/reference/spec-v02-hwlab-edge-proxy.md](docs/reference/spec-v02-hwlab-edge-proxy.md)。
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# Device Pod 正式接入规格
|
# Device Pod 迁移对照规格
|
||||||
|
|
||||||
本文是 HWLAB `v0.2` 正式接入 `device-pod` 的规格说明。`device-pod` 是一个逻辑设备能力单元,不是 Kubernetes Pod 名称,也不是 code agent 本地 profile 文件。正式接入后,profile 定义 `device-pod`,因此 profile 必须由管理员和服务端权威存储管理,不能由 code agent 本地文件决定路由或资源边界。
|
本文保留 HWLAB `v0.2` 既有 `device-pod` 正式接入口径,作为 HWPOD Harness 迁移对照。新的 HWPOD 业务方向以 [spec-hwpod-harness.md](spec-hwpod-harness.md) 和 [pikasTech/HWLAB#897](https://github.com/pikasTech/HWLAB/issues/897) 为准:快速迭代阶段先把 `hwpod-spec`、`hwpod-cli`、`hwpod-ctl` 和 `hwpod-compiler-cli` 放在 Code Agent workspace 内,`hwlab-api` 只做 `hwpod-node-ops` 转发,`hwpod-node` 只维护少量稳定 ops。
|
||||||
|
|
||||||
|
旧 `device-pod` 是一个逻辑设备能力单元,不是 Kubernetes Pod 名称,也不是 HWPOD 目标状态下的产品主概念。旧 profile/server authority 路径仍可用于现有 v0.2 兼容和迁移对照,但新增业务翻译应优先进入 `hwpod-compiler-cli`,不要继续堆在 `hwlab-device-pod` executor 或 `device-host-cli.mjs` 中。
|
||||||
|
|
||||||
实施跟踪见 [pikasTech/HWLAB#533](https://github.com/pikasTech/HWLAB/issues/533),原 `docs/plan/v02-device-pod-spec-migration.md` 和旧 device-pod MVP 计划全文已迁入该 issue 评论。
|
实施跟踪见 [pikasTech/HWLAB#533](https://github.com/pikasTech/HWLAB/issues/533),原 `docs/plan/v02-device-pod-spec-migration.md` 和旧 device-pod MVP 计划全文已迁入该 issue 评论。
|
||||||
|
|
||||||
旧的 `device-pod-cli` 本地 profile 闭环只用于 CLI MVP 和真实硬件最小验证。进入正式多用户系统后,所有用户态设备访问必须收敛到:
|
旧的 `device-pod-cli` 本地 profile 闭环只用于 CLI MVP 和真实硬件最小验证。既有正式多用户 device-pod 路径曾收敛到:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
browser Cloud Web UI or hwpod/device-pod-cli
|
browser Cloud Web UI or hwpod/device-pod-cli
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# HWPOD Harness 规格
|
||||||
|
|
||||||
|
本文是 HWLAB `v0.2` 向 HWPOD Harness 迁移的长期规格。概念体系和实施 issue 见 [pikasTech/HWLAB#897](https://github.com/pikasTech/HWLAB/issues/897)。旧 Device Pod 规格和实现仍作为迁移对照保留在 [spec-device-pod.md](spec-device-pod.md),但新的业务方向以本文为准。
|
||||||
|
|
||||||
|
当前阶段只设计核心业务闭环,不把鉴权、安全、并发、计量、复杂调度或完整 Evidence 体系作为前置条件。目标是让 Code Agent 在自己的 workspace 内先获得可观察、可修改、可快速改进的 HWPOD harness 闭环,再逐步把稳定部分平台化。
|
||||||
|
|
||||||
|
## 分阶段口径
|
||||||
|
|
||||||
|
### 快速迭代阶段
|
||||||
|
|
||||||
|
快速迭代阶段先把 harness 的业务翻译权放在 Code Agent workspace 内:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Code Agent workspace
|
||||||
|
.hwlab/hwpod-spec.yaml
|
||||||
|
hwpod-cli
|
||||||
|
hwpod-ctl
|
||||||
|
hwpod-compiler-cli
|
||||||
|
|
||||||
|
hwpod-cli / hwpod-ctl
|
||||||
|
-> hwpod-compiler-cli
|
||||||
|
-> hwpod-node-ops plan
|
||||||
|
-> hwlab-api
|
||||||
|
-> hwpod-node HTTP / injected handler
|
||||||
|
```
|
||||||
|
|
||||||
|
这一阶段的核心边界:
|
||||||
|
|
||||||
|
- `hwpod-spec` 存放在 Code Agent workspace 内,是当前 hwpod 的本地声明式定义。
|
||||||
|
- `hwpod-cli` 是用户和 Code Agent 执行研发动作的入口。
|
||||||
|
- `hwpod-ctl` 是和 `hwpod-cli` 平级的管理入口,用于初始化、修改、检查 `hwpod-spec`,以及 smoke/node 状态类操作。
|
||||||
|
- `hwpod-compiler-cli` 是本地编译器,把高层 intent 和 `hwpod-spec` 编译为 `hwpod-node-ops`。
|
||||||
|
- `hwlab-api` 只接收 `hwpod-node-ops` plan,负责转发和返回结果,不承担业务翻译。初版通过 `HWLAB_HWPOD_NODE_OPS_URL` 转发到 node,或在测试/嵌入场景注入 node-ops handler。
|
||||||
|
- `hwpod-node` 只维护少量稳定 op handler,不保存完整 `hwpod-spec`,也不解释高层 hwpod intent。初版 `hwpod-node` 可以作为本地 HTTP executor 运行,只保证基础 workspace/cmd ops,debug/io 需要后续绑定真实工具。
|
||||||
|
|
||||||
|
### 云端平台阶段
|
||||||
|
|
||||||
|
快速闭环跑通后,再逐步迁移为云端平台能力:
|
||||||
|
|
||||||
|
- 将 workspace-local `hwpod-spec` 注册或同步到云端,形成云端 hwpod source of truth。
|
||||||
|
- 将 `hwpod-ctl spec` 从本地文件管理迁移为云端 hwpod/spec 管理,同时保留本地导入、导出和调试能力。
|
||||||
|
- 将 `hwpod-compiler-cli` 服务化为云端 `hwpod-compiler`,供 Web、Code Agent、batch harness 和 CI/HIL gate 共用。
|
||||||
|
- 将 `hwlab-api` 从薄转发面扩展为 hwpod run 管理面,承载 run 状态、计划版本、结果归档和后续 Evidence。
|
||||||
|
- 只在多个任务反复需要同一类基础动作时扩展 `hwpod-node-ops`,避免把业务变化下沉到 node。
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
| 概念 | 定义 | 当前阶段含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `hwpod` | 硬件研发执行逻辑实体 | 由 target device、workspace、debug probe、io probe 四要素组成 |
|
||||||
|
| `hwpod-spec` | hwpod 的声明式定义 | 先存放在 Code Agent workspace 内,后续迁移到云端维护 |
|
||||||
|
| `hwpod-cli` | 用户和 Code Agent 的任务入口 | 发起 inspect、workspace、build、download、reset、UART、JSON-RPC 和 closeout |
|
||||||
|
| `hwpod-ctl` | workspace 内的管理入口 | 初始化/检查/修改 spec、绑定 node、执行 smoke 和临时维护动作 |
|
||||||
|
| `hwpod-compiler-cli` | workspace-local HWPOD 编译器 | 把高层 intent + spec 编译为 `hwpod-node-ops` plan |
|
||||||
|
| `hwpod-compiler` | 云端化后的编译服务 | 由 `hwpod-compiler-cli` 成熟后服务化而来 |
|
||||||
|
| `hwpod-node-ops` | node 最小操作协议 | `hwlab-api` 和 `hwpod-node` 之间的稳定执行契约 |
|
||||||
|
| `hwpod-node` | 靠近硬件的薄执行节点 | PC host、AI 网关、调试器边缘设备或实验室控制盒上的 executor |
|
||||||
|
| `hwlab-api` | HWPOD node-ops 转发面 | 快速阶段只转发 node-ops plan 并返回 result |
|
||||||
|
|
||||||
|
`hwpod` 的四个本体要素固定为:
|
||||||
|
|
||||||
|
- target device:被研发或调试的目标设备。
|
||||||
|
- workspace:项目源码、工程、构建工具链和 artifact 边界。
|
||||||
|
- debug probe:下载、复位、chip-id、调试连接等能力。
|
||||||
|
- io probe:UART、JSON-RPC、GPIO/relay 或其他 I/O 观测与交互能力。
|
||||||
|
|
||||||
|
`nodeBinding` 是 `hwpod-spec` 的部署字段,不属于 hwpod 四个本体要素。
|
||||||
|
|
||||||
|
## hwpod-spec
|
||||||
|
|
||||||
|
快速迭代阶段的默认 spec 路径是 `.hwlab/hwpod-spec.yaml`。CLI 也可以通过 `--spec <path>` 显式指定。第一版 spec 保持轻量:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: hwlab.dev/v0alpha1
|
||||||
|
kind: Hwpod
|
||||||
|
metadata:
|
||||||
|
uid: hwpod-local-demo
|
||||||
|
name: demo
|
||||||
|
spec:
|
||||||
|
targetDevice:
|
||||||
|
board: D601-F103-V2
|
||||||
|
mcu: STM32F103
|
||||||
|
workspace:
|
||||||
|
path: /workspace/project
|
||||||
|
toolchain: keil-mdk
|
||||||
|
debugProbe:
|
||||||
|
type: stlink
|
||||||
|
adapter: keil
|
||||||
|
ioProbe:
|
||||||
|
uart:
|
||||||
|
id: uart/1
|
||||||
|
port: COM9
|
||||||
|
baudrate: 115200
|
||||||
|
nodeBinding:
|
||||||
|
nodeId: node-d601-pc-host
|
||||||
|
nodeType: pc-host
|
||||||
|
```
|
||||||
|
|
||||||
|
最低校验要求:
|
||||||
|
|
||||||
|
- `kind` 必须为 `Hwpod`。
|
||||||
|
- `spec.targetDevice`、`spec.workspace`、`spec.debugProbe`、`spec.ioProbe` 必须存在。
|
||||||
|
- `spec.nodeBinding.nodeId` 必须存在。
|
||||||
|
- `spec.workspace.path` 必须存在。
|
||||||
|
|
||||||
|
## hwpod-node-ops
|
||||||
|
|
||||||
|
`hwpod-node-ops` 不表达用户意图,只表达 node 可执行的基础动作。第一版最小集合:
|
||||||
|
|
||||||
|
| op | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `node.health` | 查询 node 存活和基础状态 |
|
||||||
|
| `node.version` | 查询 node runtime 版本 |
|
||||||
|
| `node.inventory` | 查询 workspace/probe/io 能力 |
|
||||||
|
| `workspace.ls` | 列目录 |
|
||||||
|
| `workspace.cat` | 读文件 |
|
||||||
|
| `workspace.rg` | 搜索文件内容 |
|
||||||
|
| `workspace.apply-patch` | 应用源码补丁 |
|
||||||
|
| `debug.build` | 编译工程 |
|
||||||
|
| `debug.download` | 下载/烧录目标设备 |
|
||||||
|
| `debug.reset` | 复位目标设备 |
|
||||||
|
| `io.uart.read` | 读取 UART |
|
||||||
|
| `io.uart.write` | 写入 UART |
|
||||||
|
| `io.uart.jsonrpc` | 通过 UART 做 JSON-RPC |
|
||||||
|
| `cmd.run` | 临时维护命令透传,仅用于 `hwpod-ctl` 或早期薄 node 调试 |
|
||||||
|
|
||||||
|
标准 plan 形态:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contractVersion": "hwpod-node-ops-v1",
|
||||||
|
"planId": "plan_xxx",
|
||||||
|
"hwpodId": "hwpod-local-demo",
|
||||||
|
"nodeId": "node-d601-pc-host",
|
||||||
|
"ops": [
|
||||||
|
{
|
||||||
|
"opId": "op_001",
|
||||||
|
"op": "workspace.ls",
|
||||||
|
"args": {
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每个 op 的结果第一版保持简单:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type HwpodNodeOpResult = {
|
||||||
|
opId: string;
|
||||||
|
op: string;
|
||||||
|
ok: boolean;
|
||||||
|
status: "completed" | "blocked" | "failed";
|
||||||
|
exitCode?: number;
|
||||||
|
stdout?: string;
|
||||||
|
stderr?: string;
|
||||||
|
summary?: string;
|
||||||
|
blocker?: { code: string; summary: string } | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI 入口
|
||||||
|
|
||||||
|
快速阶段的源码入口使用 Bun 直接运行 TypeScript 文件;runner 或 skill 可以把这些入口暴露为短命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun tools/hwpod-ctl.ts spec init --spec .hwlab/hwpod-spec.yaml
|
||||||
|
bun tools/hwpod-ctl.ts spec validate --spec .hwlab/hwpod-spec.yaml
|
||||||
|
bun tools/hwpod-compiler-cli.ts compile --spec .hwlab/hwpod-spec.yaml --intent workspace.ls --args '{"path":"."}'
|
||||||
|
bun tools/hwpod-cli.ts inspect --spec .hwlab/hwpod-spec.yaml --dry-run
|
||||||
|
bun tools/hwpod-node.ts serve --host 127.0.0.1 --port 19678
|
||||||
|
```
|
||||||
|
|
||||||
|
`hwpod-cli` 的正常任务路径是:读取 spec、调用 compiler、把 node-ops plan 交给 `hwlab-api`,最后输出 closeout/result。`hwpod-ctl` 的管理路径可以在本地直接修改 spec,也可以生成 smoke plan 交给 `hwlab-api` 验证 node 链路。
|
||||||
|
|
||||||
|
## v0.2 迁移映射
|
||||||
|
|
||||||
|
| 现有 v0.2 对象 | HWPOD 迁移归属 | 迁移说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `devicePodId` / server profile | workspace-local `hwpod-spec` | 先把 profile 内容迁移成 `.hwlab/hwpod-spec.yaml`,后续再上云 |
|
||||||
|
| `device-pod-cli` / 旧 `hwpod` alias | `hwpod-cli` | 旧 selector/REST 入口作为迁移参考,新任务入口使用 HWPOD intent -> compiler -> node-ops |
|
||||||
|
| profile 管理脚本 | `hwpod-ctl` | 变成 workspace-local spec 管理和 smoke 工具 |
|
||||||
|
| `hwlab-device-pod` executor 中的 intent -> host argv | `hwpod-compiler-cli` | 高层业务翻译先下放到 workspace-local compiler-cli 快速迭代 |
|
||||||
|
| `device-host-cli.mjs` 中的高层业务拼接 | `hwpod-compiler-cli` | 能上收的命令编排和 profile 解释上收到 compiler-cli |
|
||||||
|
| `device-host-cli.mjs` 中的基础执行能力 | `hwpod-node` | 稳定 op handler 留在 node 侧 |
|
||||||
|
| `hwlab-gateway` / `devicepod-gateway` | `hwpod-node` 内部 transport/gateway | 不再作为产品主概念 |
|
||||||
|
| cloud-api device-pod job route | `hwlab-api` node-ops 转发面 | 快速阶段只做 plan submit/result,不做业务翻译 |
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
快速阶段最小验收:
|
||||||
|
|
||||||
|
1. Code Agent workspace 内存在 `.hwlab/hwpod-spec.yaml`,并能通过 `hwpod-ctl spec validate`。
|
||||||
|
2. `hwpod-compiler-cli compile` 能把同一个 spec 和高层 intent 编译为稳定 `hwpod-node-ops-v1` plan。
|
||||||
|
3. `hwpod-cli --dry-run` 输出的 plan 不需要云端 spec,也不读取旧 `.device-pod/*.json` profile authority。
|
||||||
|
4. `hwlab-api` 的 node-ops 入口能接收 plan,返回 JSON result;无可用 node 时必须返回结构化 blocker,而不是静默伪造 DEV-LIVE。
|
||||||
|
5. 旧 Device Pod 路径仍可作为迁移对照,但新增业务翻译应优先进入 `hwpod-compiler-cli`,不要继续堆在 `hwlab-device-pod` executor 或 `device-host-cli.mjs` 中。
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "bun:test";
|
||||||
|
|
||||||
|
import { createCloudApiServer } from "./server.ts";
|
||||||
|
import { createHwpodNodeServer } from "../../tools/src/hwpod-node-lib.ts";
|
||||||
|
|
||||||
|
test("cloud-api exposes hwpod-node-ops contract on /v1", async () => {
|
||||||
|
const server = createCloudApiServer();
|
||||||
|
await listen(server);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${serverUrl(server)}/v1`);
|
||||||
|
const payload = await response.json();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(payload.hwpod.contractVersion, "hwpod-node-ops-v1");
|
||||||
|
assert.equal(payload.hwpod.apiRole, "node-ops-forwarder");
|
||||||
|
assert.ok(payload.hwpod.supportedOps.includes("workspace.ls"));
|
||||||
|
} finally {
|
||||||
|
await close(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cloud-api forwards valid hwpod-node-ops plans to injected node handler", async () => {
|
||||||
|
const seen: any[] = [];
|
||||||
|
const server = createCloudApiServer({
|
||||||
|
hwpodNodeOpsHandler: async (plan: any, context: any) => {
|
||||||
|
seen.push({ plan, context });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: "completed",
|
||||||
|
results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed", output: { sample: true } }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await listen(server);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", "x-trace-id": "trc_hwpod_test" },
|
||||||
|
body: JSON.stringify(samplePlan())
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(payload.ok, true);
|
||||||
|
assert.equal(payload.status, "completed");
|
||||||
|
assert.equal(payload.contractVersion, "hwpod-node-ops-v1");
|
||||||
|
assert.equal(payload.results[0].op, "workspace.ls");
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
assert.equal(seen[0].plan.nodeId, "pc-host-1");
|
||||||
|
assert.equal(seen[0].context.requestMeta.traceId, "trc_hwpod_test");
|
||||||
|
} finally {
|
||||||
|
await close(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cloud-api returns blocked payload when hwpod-node is not wired yet", async () => {
|
||||||
|
const server = createCloudApiServer();
|
||||||
|
await listen(server);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(samplePlan())
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(payload.ok, false);
|
||||||
|
assert.equal(payload.status, "blocked");
|
||||||
|
assert.equal(payload.blocker.code, "hwpod_node_unavailable");
|
||||||
|
assert.equal(payload.results[0].blocker.layer, "hwpod-node");
|
||||||
|
} finally {
|
||||||
|
await close(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cloud-api forwards hwpod-node-ops to configured thin hwpod-node URL", async () => {
|
||||||
|
const node = createHwpodNodeServer();
|
||||||
|
await listen(node);
|
||||||
|
const server = createCloudApiServer({
|
||||||
|
env: {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
HWLAB_HWPOD_NODE_OPS_URL: `${serverUrl(node)}/v1/hwpod-node-ops`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await listen(server);
|
||||||
|
try {
|
||||||
|
const plan = samplePlan();
|
||||||
|
plan.ops[0].args.workspacePath = process.cwd();
|
||||||
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(plan)
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(payload.ok, true);
|
||||||
|
assert.equal(payload.status, "completed");
|
||||||
|
assert.equal(payload.results[0].op, "workspace.ls");
|
||||||
|
assert.equal(payload.results[0].output.entries.some((entry: any) => entry.name === "package.json"), true);
|
||||||
|
} finally {
|
||||||
|
await close(server);
|
||||||
|
await close(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cloud-api rejects unsupported hwpod-node ops before forwarding", async () => {
|
||||||
|
const server = createCloudApiServer({
|
||||||
|
hwpodNodeOpsHandler: async () => {
|
||||||
|
throw new Error("must not be called");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await listen(server);
|
||||||
|
try {
|
||||||
|
const plan = samplePlan();
|
||||||
|
plan.ops[0].op = "debug.flash";
|
||||||
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(plan)
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
assert.equal(response.status, 400);
|
||||||
|
assert.equal(payload.ok, false);
|
||||||
|
assert.equal(payload.error.code, "unsupported_hwpod_node_op");
|
||||||
|
} finally {
|
||||||
|
await close(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function samplePlan() {
|
||||||
|
return {
|
||||||
|
contractVersion: "hwpod-node-ops-v1",
|
||||||
|
planId: "hwpod_plan_test",
|
||||||
|
hwpodId: "hwpod-local",
|
||||||
|
nodeId: "pc-host-1",
|
||||||
|
intent: "workspace.ls",
|
||||||
|
ops: [
|
||||||
|
{ opId: "op_01_workspace_ls", op: "workspace.ls", args: { workspacePath: "/workspace/fw", path: "." } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listen(server: any) {
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close(server: any) {
|
||||||
|
await new Promise((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve(undefined))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverUrl(server: any) {
|
||||||
|
return `http://127.0.0.1:${server.address().port}`;
|
||||||
|
}
|
||||||
@@ -68,6 +68,23 @@ import {
|
|||||||
} from "./skills-store.ts";
|
} from "./skills-store.ts";
|
||||||
|
|
||||||
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
||||||
|
const HWPOD_NODE_OPS_CONTRACT_VERSION = "hwpod-node-ops-v1";
|
||||||
|
const HWPOD_NODE_OPS = new Set([
|
||||||
|
"node.health",
|
||||||
|
"node.version",
|
||||||
|
"node.inventory",
|
||||||
|
"workspace.ls",
|
||||||
|
"workspace.cat",
|
||||||
|
"workspace.rg",
|
||||||
|
"workspace.apply-patch",
|
||||||
|
"debug.build",
|
||||||
|
"debug.download",
|
||||||
|
"debug.reset",
|
||||||
|
"io.uart.read",
|
||||||
|
"io.uart.write",
|
||||||
|
"io.uart.jsonrpc",
|
||||||
|
"cmd.run"
|
||||||
|
]);
|
||||||
|
|
||||||
function runtimeEnvironment(env = process.env) {
|
function runtimeEnvironment(env = process.env) {
|
||||||
const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV;
|
const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV;
|
||||||
@@ -317,6 +334,15 @@ async function handleRestAdapter(request, response, url, options) {
|
|||||||
jobsRoute: "/v1/device-pods/{devicePodId}/jobs",
|
jobsRoute: "/v1/device-pods/{devicePodId}/jobs",
|
||||||
legacyHardwareUi: "disabled"
|
legacyHardwareUi: "disabled"
|
||||||
},
|
},
|
||||||
|
hwpod: {
|
||||||
|
route: "/v1/hwpod-node-ops",
|
||||||
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||||
|
specAuthority: "code-agent-workspace",
|
||||||
|
compiler: "hwpod-compiler-cli",
|
||||||
|
apiRole: "node-ops-forwarder",
|
||||||
|
nodeRole: "thin-hwpod-node-executor",
|
||||||
|
supportedOps: Array.from(HWPOD_NODE_OPS)
|
||||||
|
},
|
||||||
m3IoControl: describeM3IoControl(options)
|
m3IoControl: describeM3IoControl(options)
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -411,6 +437,11 @@ async function handleRestAdapter(request, response, url, options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/v1/hwpod-node-ops") {
|
||||||
|
await handleHwpodNodeOpsHttp(request, response, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (request.method === "GET" && url.pathname === M3_IO_CONTROL_ROUTE) {
|
if (request.method === "GET" && url.pathname === M3_IO_CONTROL_ROUTE) {
|
||||||
sendJson(response, 200, await describeM3IoControlLive(options));
|
sendJson(response, 200, await describeM3IoControlLive(options));
|
||||||
return;
|
return;
|
||||||
@@ -573,6 +604,195 @@ async function handleRestAdapter(request, response, url, options) {
|
|||||||
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
|
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleHwpodNodeOpsHttp(request, response, options) {
|
||||||
|
if (request.method === "GET") {
|
||||||
|
sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
status: "ready",
|
||||||
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||||
|
route: "/v1/hwpod-node-ops",
|
||||||
|
specAuthority: "code-agent-workspace",
|
||||||
|
compiler: "hwpod-compiler-cli",
|
||||||
|
apiRole: "node-ops-forwarder",
|
||||||
|
nodeRole: "thin-hwpod-node-executor",
|
||||||
|
supportedOps: Array.from(HWPOD_NODE_OPS)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "hwpod-node-ops only supports GET and POST" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonObject(request, options.bodyLimitBytes);
|
||||||
|
if (!body.ok) {
|
||||||
|
sendJson(response, 400, body.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validation = validateHwpodNodeOpsPlan(body.value);
|
||||||
|
if (!validation.ok) {
|
||||||
|
sendJson(response, 400, {
|
||||||
|
ok: false,
|
||||||
|
status: "rejected",
|
||||||
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||||
|
error: validation.error
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = validation.plan;
|
||||||
|
const requestMeta = {
|
||||||
|
requestId: getHeader(request, "x-request-id") || `req_hwpod_${randomUUID()}`,
|
||||||
|
traceId: getHeader(request, "x-trace-id") || `trc_hwpod_${randomUUID()}`,
|
||||||
|
serviceId: getHeader(request, "x-source-service-id") || CLOUD_API_SERVICE_ID,
|
||||||
|
environment: runtimeEnvironment(options.env ?? process.env)
|
||||||
|
};
|
||||||
|
const hwpodNodeOpsUrl = normalizedHwpodNodeOpsUrl(options.env ?? process.env);
|
||||||
|
if (typeof options.hwpodNodeOpsHandler !== "function" && !hwpodNodeOpsUrl) {
|
||||||
|
sendJson(response, 200, hwpodNodeOpsBlockedPayload(plan, requestMeta, "HWLAB_HWPOD_NODE_OPS_URL is not configured; cloud-api is only validating the hwpod-node-ops contract"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handled = typeof options.hwpodNodeOpsHandler === "function"
|
||||||
|
? await options.hwpodNodeOpsHandler(plan, { request, requestMeta, env: options.env ?? process.env })
|
||||||
|
: await forwardHwpodNodeOpsPlan(hwpodNodeOpsUrl, plan, requestMeta, options);
|
||||||
|
const results = Array.isArray(handled?.results) ? handled.results : [];
|
||||||
|
const failed = results.some((item) => item?.ok === false);
|
||||||
|
sendJson(response, handled?.httpStatus ?? (failed ? 409 : 200), {
|
||||||
|
ok: handled?.ok ?? !failed,
|
||||||
|
status: handled?.status ?? (failed ? "failed" : "completed"),
|
||||||
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||||
|
planId: plan.planId,
|
||||||
|
hwpodId: plan.hwpodId,
|
||||||
|
nodeId: plan.nodeId,
|
||||||
|
acceptedOps: plan.ops.length,
|
||||||
|
results,
|
||||||
|
blocker: handled?.blocker ?? null,
|
||||||
|
requestMeta
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(response, 200, hwpodNodeOpsBlockedPayload(plan, requestMeta, error?.message ?? "hwpod-node-ops handler failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forwardHwpodNodeOpsPlan(targetUrl, plan, requestMeta, options) {
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-request-id": requestMeta.requestId,
|
||||||
|
"x-trace-id": requestMeta.traceId,
|
||||||
|
"x-source-service-id": CLOUD_API_SERVICE_ID
|
||||||
|
},
|
||||||
|
body: JSON.stringify(plan),
|
||||||
|
signal: AbortSignal.timeout(parsePositiveInteger(options.env?.HWLAB_HWPOD_NODE_OPS_TIMEOUT_MS, 30000))
|
||||||
|
});
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: "blocked",
|
||||||
|
httpStatus: 200,
|
||||||
|
results: [],
|
||||||
|
blocker: {
|
||||||
|
code: "hwpod_node_response_invalid",
|
||||||
|
layer: "hwpod-node",
|
||||||
|
retryable: true,
|
||||||
|
summary: `hwpod-node returned HTTP ${response.status} without a JSON object payload`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: body.ok,
|
||||||
|
status: body.status,
|
||||||
|
httpStatus: response.status,
|
||||||
|
results: body.results,
|
||||||
|
blocker: body.blocker ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedHwpodNodeOpsUrl(env = process.env) {
|
||||||
|
const direct = String(env.HWLAB_HWPOD_NODE_OPS_URL ?? "").trim();
|
||||||
|
if (direct) return direct;
|
||||||
|
const base = String(env.HWLAB_HWPOD_NODE_URL ?? "").trim().replace(/\/+$/u, "");
|
||||||
|
return base ? `${base}/v1/hwpod-node-ops` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHwpodNodeOpsPlan(value) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return { ok: false, error: { code: "invalid_hwpod_node_ops_plan", message: "hwpod-node-ops body must be a JSON object" } };
|
||||||
|
}
|
||||||
|
if (value.contractVersion !== HWPOD_NODE_OPS_CONTRACT_VERSION) {
|
||||||
|
return { ok: false, error: { code: "invalid_hwpod_node_ops_contract", message: `contractVersion must be ${HWPOD_NODE_OPS_CONTRACT_VERSION}`, actual: value.contractVersion ?? null } };
|
||||||
|
}
|
||||||
|
const planId = safeOpaqueId(value.planId) || `hwpod_plan_${randomUUID()}`;
|
||||||
|
const nodeId = safeOpaqueId(value.nodeId);
|
||||||
|
const hwpodId = safeOpaqueId(value.hwpodId);
|
||||||
|
if (!nodeId) return { ok: false, error: { code: "invalid_hwpod_node_id", message: "nodeId is required" } };
|
||||||
|
if (!hwpodId) return { ok: false, error: { code: "invalid_hwpod_id", message: "hwpodId is required" } };
|
||||||
|
if (!Array.isArray(value.ops) || value.ops.length === 0) {
|
||||||
|
return { ok: false, error: { code: "invalid_hwpod_node_ops", message: "ops must be a non-empty array" } };
|
||||||
|
}
|
||||||
|
const ops = [];
|
||||||
|
for (const [index, item] of value.ops.entries()) {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
||||||
|
return { ok: false, error: { code: "invalid_hwpod_node_op", message: `ops[${index}] must be a JSON object` } };
|
||||||
|
}
|
||||||
|
const op = typeof item.op === "string" ? item.op.trim() : "";
|
||||||
|
if (!HWPOD_NODE_OPS.has(op)) {
|
||||||
|
return { ok: false, error: { code: "unsupported_hwpod_node_op", message: `unsupported hwpod-node op: ${op || "<empty>"}`, supportedOps: Array.from(HWPOD_NODE_OPS) } };
|
||||||
|
}
|
||||||
|
ops.push({
|
||||||
|
opId: safeOpaqueId(item.opId) || `op_${index + 1}`,
|
||||||
|
op,
|
||||||
|
args: item.args && typeof item.args === "object" && !Array.isArray(item.args) ? item.args : {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
plan: {
|
||||||
|
...value,
|
||||||
|
planId,
|
||||||
|
hwpodId,
|
||||||
|
nodeId,
|
||||||
|
ops
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hwpodNodeOpsBlockedPayload(plan, requestMeta, summary) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: "blocked",
|
||||||
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||||
|
planId: plan.planId,
|
||||||
|
hwpodId: plan.hwpodId,
|
||||||
|
nodeId: plan.nodeId,
|
||||||
|
acceptedOps: plan.ops.length,
|
||||||
|
results: plan.ops.map((op) => ({
|
||||||
|
opId: op.opId,
|
||||||
|
op: op.op,
|
||||||
|
ok: false,
|
||||||
|
status: "blocked",
|
||||||
|
blocker: {
|
||||||
|
code: "hwpod_node_unavailable",
|
||||||
|
layer: "hwpod-node",
|
||||||
|
retryable: true,
|
||||||
|
summary,
|
||||||
|
userMessage: "hwpod-node 尚未接入执行面;cloud-api 已完成 hwpod-node-ops 合同校验。"
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
blocker: {
|
||||||
|
code: "hwpod_node_unavailable",
|
||||||
|
layer: "hwpod-node",
|
||||||
|
retryable: true,
|
||||||
|
summary
|
||||||
|
},
|
||||||
|
requestMeta
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function handleInternalDevicePodGatewayDispatch(request, response, options) {
|
async function handleInternalDevicePodGatewayDispatch(request, response, options) {
|
||||||
if (request.method !== "POST") {
|
if (request.method !== "POST") {
|
||||||
sendJson(response, 405, { error: { code: "method_not_allowed", message: "Internal device-pod gateway dispatch only supports POST" } });
|
sendJson(response, 405, { error: { code: "method_not_allowed", message: "Internal device-pod gateway dispatch only supports POST" } });
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { mainHwpodCli } from "./src/hwpod-harness-lib.ts";
|
||||||
|
|
||||||
|
await mainHwpodCli();
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { mainHwpodCompilerCli } from "./src/hwpod-harness-lib.ts";
|
||||||
|
|
||||||
|
await mainHwpodCompilerCli();
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { mainHwpodCtl } from "./src/hwpod-harness-lib.ts";
|
||||||
|
|
||||||
|
await mainHwpodCtl();
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test } from "bun:test";
|
||||||
|
|
||||||
|
import { runHwpodCli, runHwpodCompilerCli, runHwpodCtl } from "./src/hwpod-harness-lib.ts";
|
||||||
|
|
||||||
|
const NOW = "2026-06-05T00:00:00.000Z";
|
||||||
|
|
||||||
|
test("hwpod-ctl initializes, validates, edits, and binds workspace-local hwpod-spec", async () => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-ctl-"));
|
||||||
|
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
|
||||||
|
try {
|
||||||
|
const init = await runHwpodCtl([
|
||||||
|
"spec",
|
||||||
|
"init",
|
||||||
|
"--spec",
|
||||||
|
specPath,
|
||||||
|
"--name",
|
||||||
|
"stm32-local",
|
||||||
|
"--node",
|
||||||
|
"pc-host-1",
|
||||||
|
"--workspace",
|
||||||
|
"/workspace/fw"
|
||||||
|
], { now: () => NOW });
|
||||||
|
assert.equal(init.exitCode, 0);
|
||||||
|
assert.equal(init.payload.document.kind, "Hwpod");
|
||||||
|
assert.equal(init.payload.document.spec.nodeBinding.nodeId, "pc-host-1");
|
||||||
|
|
||||||
|
const validate = await runHwpodCtl(["spec", "validate", "--spec", specPath], { now: () => NOW });
|
||||||
|
assert.equal(validate.exitCode, 0);
|
||||||
|
assert.deepEqual(validate.payload.fourElements, ["targetDevice", "workspace", "debugProbe", "ioProbe"]);
|
||||||
|
|
||||||
|
const set = await runHwpodCtl(["spec", "set", "spec.targetDevice.id", "stm32f103", "--spec", specPath], { now: () => NOW });
|
||||||
|
assert.equal(set.exitCode, 0);
|
||||||
|
assert.equal(set.payload.document.spec.targetDevice.id, "stm32f103");
|
||||||
|
|
||||||
|
const bind = await runHwpodCtl(["bind", "--spec", specPath, "--node", "edge-gateway-1"], { now: () => NOW });
|
||||||
|
assert.equal(bind.exitCode, 0);
|
||||||
|
assert.equal(bind.payload.nodeId, "edge-gateway-1");
|
||||||
|
assert.match(await readFile(specPath, "utf8"), /nodeId: edge-gateway-1/u);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("hwpod-compiler-cli compiles workspace-local spec into node ops", async () => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-compiler-"));
|
||||||
|
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
|
||||||
|
try {
|
||||||
|
await runHwpodCtl(["spec", "init", "--spec", specPath, "--node", "pc-host-1", "--workspace", "/workspace/fw"], { now: () => NOW });
|
||||||
|
const result = await runHwpodCompilerCli([
|
||||||
|
"compile",
|
||||||
|
"--spec",
|
||||||
|
specPath,
|
||||||
|
"--intent",
|
||||||
|
"workspace.ls",
|
||||||
|
"--args",
|
||||||
|
"{\"path\":\"src\"}"
|
||||||
|
], { now: () => NOW });
|
||||||
|
assert.equal(result.exitCode, 0);
|
||||||
|
assert.equal(result.payload.contractVersion, "hwpod-node-ops-v1");
|
||||||
|
assert.equal(result.payload.plan.nodeId, "pc-host-1");
|
||||||
|
assert.equal(result.payload.plan.ops.length, 1);
|
||||||
|
assert.equal(result.payload.plan.ops[0].op, "workspace.ls");
|
||||||
|
assert.equal(result.payload.plan.ops[0].args.path, "src");
|
||||||
|
assert.equal(result.payload.plan.ops[0].args.workspacePath, "/workspace/fw");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("hwpod-cli dry-run keeps high-level intent out of hwlab-api and exposes hwpod-node-ops plan", async () => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-cli-"));
|
||||||
|
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
|
||||||
|
try {
|
||||||
|
await runHwpodCtl(["spec", "init", "--spec", specPath, "--node", "pc-host-1"], { now: () => NOW });
|
||||||
|
const inspect = await runHwpodCli(["inspect", "--spec", specPath, "--dry-run"], { now: () => NOW });
|
||||||
|
assert.equal(inspect.exitCode, 0);
|
||||||
|
assert.equal(inspect.payload.dryRun, true);
|
||||||
|
assert.deepEqual(inspect.payload.plan.ops.map((op: any) => op.op), ["node.health", "node.inventory"]);
|
||||||
|
|
||||||
|
const build = await runHwpodCli(["build", "--spec", specPath, "--dry-run", "--target", "Debug"], { now: () => NOW });
|
||||||
|
assert.equal(build.exitCode, 0);
|
||||||
|
assert.equal(build.payload.plan.ops[0].op, "debug.build");
|
||||||
|
assert.equal(build.payload.plan.ops[0].args.target, "Debug");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hwpod-cli submits compiled node ops to hwlab-api when not dry-run", async () => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-cli-submit-"));
|
||||||
|
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
|
||||||
|
const seen: any[] = [];
|
||||||
|
try {
|
||||||
|
await runHwpodCtl(["spec", "init", "--spec", specPath, "--node", "pc-host-1"], { now: () => NOW });
|
||||||
|
const result = await runHwpodCli(["workspace", "ls", "src", "--spec", specPath], {
|
||||||
|
env: {
|
||||||
|
HWLAB_RUNTIME_API_URL: "http://cloud.test",
|
||||||
|
HWLAB_RUNTIME_NAMESPACE: "hwlab-v02",
|
||||||
|
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1",
|
||||||
|
HWLAB_API_KEY: "hwl_live_test"
|
||||||
|
},
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
|
||||||
|
return new Response(JSON.stringify({ ok: true, status: "completed", contractVersion: "hwpod-node-ops-v1", results: [{ op: "workspace.ls", ok: true }] }), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
},
|
||||||
|
now: () => NOW
|
||||||
|
});
|
||||||
|
assert.equal(result.exitCode, 0);
|
||||||
|
assert.equal(seen[0].url, "http://cloud.test/v1/hwpod-node-ops");
|
||||||
|
assert.equal(seen[0].init.headers["x-hwlab-device-pod-api-key"], "hwl_live_test");
|
||||||
|
assert.equal(seen[0].body.contractVersion, "hwpod-node-ops-v1");
|
||||||
|
assert.equal(seen[0].body.ops[0].op, "workspace.ls");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test } from "bun:test";
|
||||||
|
|
||||||
|
import { createHwpodNodeServer, executeHwpodNodeOpsPlan } from "./src/hwpod-node-lib.ts";
|
||||||
|
|
||||||
|
test("hwpod-node executes minimal workspace node ops", async () => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-"));
|
||||||
|
try {
|
||||||
|
await writeFile(path.join(root, "main.c"), "int main(void) { return 0; }\n", "utf8");
|
||||||
|
const result = await executeHwpodNodeOpsPlan({
|
||||||
|
contractVersion: "hwpod-node-ops-v1",
|
||||||
|
planId: "hwpod_plan_test",
|
||||||
|
hwpodId: "hwpod-local",
|
||||||
|
nodeId: "pc-host-1",
|
||||||
|
ops: [
|
||||||
|
{ opId: "op_01", op: "node.health", args: { workspacePath: root } },
|
||||||
|
{ opId: "op_02", op: "workspace.ls", args: { workspacePath: root, path: "." } },
|
||||||
|
{ opId: "op_03", op: "workspace.cat", args: { workspacePath: root, path: "main.c" } }
|
||||||
|
]
|
||||||
|
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.status, "completed");
|
||||||
|
assert.equal(result.results[1].output.entries[0].name, "main.c");
|
||||||
|
assert.match(result.results[2].output.content, /int main/u);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("hwpod-node HTTP endpoint accepts hwpod-node-ops plans", async () => {
|
||||||
|
const server = createHwpodNodeServer({ now: () => "2026-06-05T00:00:00.000Z" });
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${server.address().port}/v1/hwpod-node-ops`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contractVersion: "hwpod-node-ops-v1",
|
||||||
|
planId: "hwpod_plan_http",
|
||||||
|
hwpodId: "hwpod-local",
|
||||||
|
nodeId: "pc-host-1",
|
||||||
|
ops: [{ opId: "op_01", op: "node.version", args: {} }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(payload.ok, true);
|
||||||
|
assert.equal(payload.results[0].op, "node.version");
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve(undefined))));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hwpod-node applies workspace patches through the stable node op", async () => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-"));
|
||||||
|
try {
|
||||||
|
await writeFile(path.join(root, "main.c"), "int value = 1;\n", "utf8");
|
||||||
|
const result = await executeHwpodNodeOpsPlan({
|
||||||
|
contractVersion: "hwpod-node-ops-v1",
|
||||||
|
planId: "hwpod_plan_patch",
|
||||||
|
hwpodId: "hwpod-local",
|
||||||
|
nodeId: "pc-host-1",
|
||||||
|
ops: [{
|
||||||
|
opId: "op_patch",
|
||||||
|
op: "workspace.apply-patch",
|
||||||
|
args: {
|
||||||
|
workspacePath: root,
|
||||||
|
patch: [
|
||||||
|
"*** Begin Patch",
|
||||||
|
"*** Update File: main.c",
|
||||||
|
"@@",
|
||||||
|
"-int value = 1;",
|
||||||
|
"+int value = 2;",
|
||||||
|
"*** End Patch",
|
||||||
|
""
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}, { now: () => "2026-06-05T00:00:00.000Z" });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.results[0].ok, true);
|
||||||
|
assert.match(await readFile(path.join(root, "main.c"), "utf8"), /value = 2/u);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { mainHwpodNode } from "./src/hwpod-node-lib.ts";
|
||||||
|
|
||||||
|
await mainHwpodNode();
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { resolveRuntimeEndpoint, runtimeEndpointVisibility } from "./runtime-endpoint-resolver.ts";
|
||||||
|
|
||||||
|
export const HWPOD_NODE_OPS_CONTRACT_VERSION = "hwpod-node-ops-v1";
|
||||||
|
export const DEFAULT_HWPOD_SPEC_PATH = ".hwlab/hwpod-spec.yaml";
|
||||||
|
|
||||||
|
const COMPILER_NAME = "hwpod-compiler-cli";
|
||||||
|
const CTL_NAME = "hwpod-ctl";
|
||||||
|
const CLI_NAME = "hwpod-cli";
|
||||||
|
const DEFAULT_TIMEOUT_MS = 30000;
|
||||||
|
const BOOLEAN_OPTIONS = new Set(["dryRun", "force", "full", "help", "h", "json", "noAuth"]);
|
||||||
|
const HWPOD_NODE_OPS = new Set([
|
||||||
|
"node.health",
|
||||||
|
"node.version",
|
||||||
|
"node.inventory",
|
||||||
|
"workspace.ls",
|
||||||
|
"workspace.cat",
|
||||||
|
"workspace.rg",
|
||||||
|
"workspace.apply-patch",
|
||||||
|
"debug.build",
|
||||||
|
"debug.download",
|
||||||
|
"debug.reset",
|
||||||
|
"io.uart.read",
|
||||||
|
"io.uart.write",
|
||||||
|
"io.uart.jsonrpc",
|
||||||
|
"cmd.run"
|
||||||
|
]);
|
||||||
|
|
||||||
|
type EnvLike = Record<string, string | undefined>;
|
||||||
|
type ParsedArgs = Record<string, unknown> & { _: string[] };
|
||||||
|
type FetchLike = typeof fetch;
|
||||||
|
|
||||||
|
export async function mainHwpodCompilerCli(argv = process.argv.slice(2), options: any = {}) {
|
||||||
|
const result = await runHwpodCompilerCli(argv, options);
|
||||||
|
console.log(JSON.stringify(result.payload, null, 2));
|
||||||
|
process.exitCode = result.exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mainHwpodCtl(argv = process.argv.slice(2), options: any = {}) {
|
||||||
|
const result = await runHwpodCtl(argv, options);
|
||||||
|
console.log(JSON.stringify(result.payload, null, 2));
|
||||||
|
process.exitCode = result.exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mainHwpodCli(argv = process.argv.slice(2), options: any = {}) {
|
||||||
|
const result = await runHwpodCli(argv, options);
|
||||||
|
console.log(JSON.stringify(result.payload, null, 2));
|
||||||
|
process.exitCode = result.exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runHwpodCompilerCli(argv: string[], options: { env?: EnvLike; now?: () => string } = {}) {
|
||||||
|
const now = options.now ?? (() => new Date().toISOString());
|
||||||
|
try {
|
||||||
|
const parsed = parseOptions(argv);
|
||||||
|
const command = parsed._[0] || "help";
|
||||||
|
if (["help", "--help", "-h"].includes(command)) return result(0, compilerHelp(), now);
|
||||||
|
if (command !== "compile") throw cliError("unsupported_compiler_command", `unsupported hwpod-compiler-cli command: ${command}`);
|
||||||
|
const specPath = specPathFrom(parsed);
|
||||||
|
const intent = requiredText(parsed.intent ?? parsed._[1], "intent");
|
||||||
|
const args = parseJsonObject(parsed.argsJson ?? parsed.args ?? "{}", "args");
|
||||||
|
const document = await readHwpodSpec(specPath);
|
||||||
|
const plan = compileHwpodNodeOpsPlan({ document, specPath, intent, args, now });
|
||||||
|
return result(0, ok("hwpod-compiler.compile", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, plan }), now);
|
||||||
|
} catch (error) {
|
||||||
|
return result(1, failure(COMPILER_NAME, error), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runHwpodCtl(argv: string[], options: { env?: EnvLike; now?: () => string } = {}) {
|
||||||
|
const now = options.now ?? (() => new Date().toISOString());
|
||||||
|
try {
|
||||||
|
const parsed = parseOptions(argv);
|
||||||
|
const group = parsed._[0] || "help";
|
||||||
|
if (["help", "--help", "-h"].includes(group)) return result(0, ctlHelp(), now);
|
||||||
|
if (group === "bind") return result(0, await bindSpec(parsed, now), now);
|
||||||
|
if (group !== "spec") throw cliError("unsupported_ctl_command", `unsupported hwpod-ctl command: ${group}`);
|
||||||
|
const subcommand = parsed._[1] || "validate";
|
||||||
|
if (subcommand === "init") return result(0, await initSpec(parsed, now), now);
|
||||||
|
if (subcommand === "validate") return result(0, await validateSpec(parsed), now);
|
||||||
|
if (subcommand === "show") return result(0, await showSpec(parsed), now);
|
||||||
|
if (subcommand === "set") return result(0, await setSpec(parsed, now), now);
|
||||||
|
if (subcommand === "bind") return result(0, await bindSpec(parsed, now), now);
|
||||||
|
throw cliError("unsupported_spec_command", `unsupported hwpod-ctl spec command: ${subcommand}`);
|
||||||
|
} catch (error) {
|
||||||
|
return result(1, failure(CTL_NAME, error), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runHwpodCli(argv: string[], options: { env?: EnvLike; fetchImpl?: FetchLike; stdinText?: string; now?: () => string } = {}) {
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
const now = options.now ?? (() => new Date().toISOString());
|
||||||
|
try {
|
||||||
|
const parsed = parseOptions(argv);
|
||||||
|
const command = parsed._[0] || "help";
|
||||||
|
if (["help", "--help", "-h"].includes(command)) return result(0, cliHelp(), now);
|
||||||
|
if (command === "closeout") return result(0, closeout(parsed), now);
|
||||||
|
|
||||||
|
const specPath = specPathFrom(parsed);
|
||||||
|
const document = await readHwpodSpec(specPath);
|
||||||
|
const { intent, args } = commandToIntent(parsed, options.stdinText);
|
||||||
|
const plan = compileHwpodNodeOpsPlan({ document, specPath, intent, args, now });
|
||||||
|
if (parsed.dryRun === true) {
|
||||||
|
return result(0, ok("hwpod-cli.plan", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, plan, dryRun: true }), now);
|
||||||
|
}
|
||||||
|
const response = await submitHwpodNodeOpsPlan({ parsed, env, fetchImpl: options.fetchImpl, plan });
|
||||||
|
const exitCode = response.body?.ok === false || response.status >= 400 ? 1 : 0;
|
||||||
|
return result(exitCode, ok("hwpod-cli.invoke", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, route: response.route, runtimeEndpoint: response.runtimeEndpoint, body: response.body, httpStatus: response.status }, response.body?.status ?? (exitCode === 0 ? "succeeded" : "failed")), now);
|
||||||
|
} catch (error) {
|
||||||
|
return result(1, failure(CLI_NAME, error), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readHwpodSpec(specPath = DEFAULT_HWPOD_SPEC_PATH) {
|
||||||
|
const text = await readFile(specPath, "utf8");
|
||||||
|
return normalizeHwpodSpec(parseSimpleYaml(text, specPath), specPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compileHwpodNodeOpsPlan({ document, specPath = DEFAULT_HWPOD_SPEC_PATH, intent, args = {}, now = () => new Date().toISOString() }: any) {
|
||||||
|
const normalizedIntent = normalizeIntent(intent);
|
||||||
|
const nodeId = document.spec.nodeBinding.nodeId;
|
||||||
|
const hwpodId = document.metadata.uid || document.metadata.name;
|
||||||
|
const ops = opsForIntent(normalizedIntent, args, document).map((operation: any, index: number) => ({
|
||||||
|
opId: operation.opId ?? `op_${String(index + 1).padStart(2, "0")}_${operation.op.replace(/[^a-z0-9]+/giu, "_")}`,
|
||||||
|
...operation
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||||
|
planId: `hwpod_plan_${randomUUID()}`,
|
||||||
|
hwpodId,
|
||||||
|
nodeId,
|
||||||
|
intent: normalizedIntent,
|
||||||
|
source: {
|
||||||
|
compiler: COMPILER_NAME,
|
||||||
|
specPath,
|
||||||
|
specAuthority: "code-agent-workspace"
|
||||||
|
},
|
||||||
|
resourceHints: {
|
||||||
|
workspacePath: document.spec.workspace.path,
|
||||||
|
targetDevice: document.spec.targetDevice,
|
||||||
|
debugProbe: document.spec.debugProbe,
|
||||||
|
ioProbe: document.spec.ioProbe
|
||||||
|
},
|
||||||
|
ops,
|
||||||
|
createdAt: now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compilerHelp() {
|
||||||
|
return ok("hwpod-compiler.help", {
|
||||||
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||||
|
usage: [
|
||||||
|
"bun tools/hwpod-compiler-cli.ts compile --spec .hwlab/hwpod-spec.yaml --intent workspace.ls --args '{\"path\":\".\"}'",
|
||||||
|
"bun tools/hwpod-compiler-cli.ts compile --intent inspect"
|
||||||
|
],
|
||||||
|
role: "Translate workspace-local hwpod-spec plus high-level intent into hwpod-node-ops."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctlHelp() {
|
||||||
|
return ok("hwpod-ctl.help", {
|
||||||
|
contractVersion: "hwpod-ctl-v1",
|
||||||
|
usage: [
|
||||||
|
"bun tools/hwpod-ctl.ts spec init --spec .hwlab/hwpod-spec.yaml --node local-node",
|
||||||
|
"bun tools/hwpod-ctl.ts spec validate --spec .hwlab/hwpod-spec.yaml",
|
||||||
|
"bun tools/hwpod-ctl.ts spec set spec.workspace.path /workspace/firmware",
|
||||||
|
"bun tools/hwpod-ctl.ts bind --node pc-host-1"
|
||||||
|
],
|
||||||
|
defaultSpecPath: DEFAULT_HWPOD_SPEC_PATH
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliHelp() {
|
||||||
|
return ok("hwpod-cli.help", {
|
||||||
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||||
|
usage: [
|
||||||
|
"bun tools/hwpod-cli.ts inspect --dry-run",
|
||||||
|
"bun tools/hwpod-cli.ts workspace ls . --dry-run",
|
||||||
|
"bun tools/hwpod-cli.ts build --dry-run",
|
||||||
|
"bun tools/hwpod-cli.ts uart read --port uart1 --dry-run",
|
||||||
|
"bun tools/hwpod-cli.ts --api-base-url http://74.48.78.17:19667 inspect"
|
||||||
|
],
|
||||||
|
route: "POST /v1/hwpod-node-ops"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSpec(parsed: ParsedArgs, now: () => string) {
|
||||||
|
const specPath = specPathFrom(parsed);
|
||||||
|
const document = defaultSpecDocument(parsed, now);
|
||||||
|
await mkdir(path.dirname(specPath), { recursive: true });
|
||||||
|
if (parsed.force !== true) {
|
||||||
|
try {
|
||||||
|
await readFile(specPath, "utf8");
|
||||||
|
throw cliError("hwpod_spec_exists", `hwpod spec already exists: ${specPath}`, { specPath, next: "pass --force to replace it" });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeFile(specPath, stringifySimpleYaml(document), "utf8");
|
||||||
|
return ok("hwpod-ctl.spec.init", { specPath, document: normalizeHwpodSpec(document, specPath) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateSpec(parsed: ParsedArgs) {
|
||||||
|
const specPath = specPathFrom(parsed);
|
||||||
|
const document = await readHwpodSpec(specPath);
|
||||||
|
return ok("hwpod-ctl.spec.validate", { specPath, document, fourElements: ["targetDevice", "workspace", "debugProbe", "ioProbe"], nodeId: document.spec.nodeBinding.nodeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showSpec(parsed: ParsedArgs) {
|
||||||
|
const specPath = specPathFrom(parsed);
|
||||||
|
const document = await readHwpodSpec(specPath);
|
||||||
|
return ok("hwpod-ctl.spec.show", { specPath, document });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSpec(parsed: ParsedArgs, now: () => string) {
|
||||||
|
const specPath = specPathFrom(parsed);
|
||||||
|
const dotPath = requiredText(parsed._[2] ?? parsed.path, "path");
|
||||||
|
const value = scalarFromSetValue(requiredText(parsed._[3] ?? parsed.value, "value"));
|
||||||
|
const document = parseSimpleYaml(await readFile(specPath, "utf8"), specPath);
|
||||||
|
setDotPath(document, dotPath, value);
|
||||||
|
document.metadata = objectValue(document.metadata);
|
||||||
|
document.metadata.updatedAt = now();
|
||||||
|
const normalized = normalizeHwpodSpec(document, specPath);
|
||||||
|
await writeFile(specPath, stringifySimpleYaml(document), "utf8");
|
||||||
|
return ok("hwpod-ctl.spec.set", { specPath, path: dotPath, value, document: normalized });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindSpec(parsed: ParsedArgs, now: () => string) {
|
||||||
|
const nodeId = requiredText(parsed.node ?? parsed.nodeId ?? parsed._[1] ?? parsed._[2], "node");
|
||||||
|
const specPath = specPathFrom(parsed);
|
||||||
|
const document = parseSimpleYaml(await readFile(specPath, "utf8"), specPath);
|
||||||
|
setDotPath(document, "spec.nodeBinding.nodeId", nodeId);
|
||||||
|
document.metadata = objectValue(document.metadata);
|
||||||
|
document.metadata.updatedAt = now();
|
||||||
|
const normalized = normalizeHwpodSpec(document, specPath);
|
||||||
|
await writeFile(specPath, stringifySimpleYaml(document), "utf8");
|
||||||
|
return ok("hwpod-ctl.bind", { specPath, nodeId, document: normalized });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeout(parsed: ParsedArgs) {
|
||||||
|
return ok("hwpod-cli.closeout", {
|
||||||
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||||
|
specPath: specPathFrom(parsed),
|
||||||
|
note: "Closeout is a reporting step; use hwpod-cli operations and hwlab-cli runtime evidence before closing the issue."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandToIntent(parsed: ParsedArgs, stdinText?: string) {
|
||||||
|
const command = parsed._[0];
|
||||||
|
if (command === "inspect") return { intent: "inspect", args: {} };
|
||||||
|
if (command === "node") {
|
||||||
|
const subcommand = parsed._[1] || "health";
|
||||||
|
return { intent: `node.${subcommand}`, args: {} };
|
||||||
|
}
|
||||||
|
if (command === "workspace") {
|
||||||
|
const subcommand = parsed._[1] || "ls";
|
||||||
|
if (subcommand === "ls") return { intent: "workspace.ls", args: { path: text(parsed.path ?? parsed._[2] ?? ".") } };
|
||||||
|
if (subcommand === "cat") return { intent: "workspace.cat", args: { path: requiredText(parsed.path ?? parsed._[2], "path") } };
|
||||||
|
if (subcommand === "rg") return { intent: "workspace.rg", args: { pattern: requiredText(parsed.pattern ?? parsed._[2], "pattern"), path: text(parsed.path ?? parsed._[3] ?? "."), ignoreCase: parsed.ignoreCase === true } };
|
||||||
|
if (subcommand === "apply-patch") return { intent: "workspace.apply-patch", args: { patch: text(parsed.patch ?? parsed.patchText ?? stdinText), patchBase64: text(parsed.patchBase64), reason: text(parsed.reason) } };
|
||||||
|
throw cliError("unsupported_workspace_command", `unsupported workspace command: ${subcommand}`);
|
||||||
|
}
|
||||||
|
if (command === "build") return { intent: "debug.build", args: clean({ target: text(parsed.target), command: text(parsed.command) }) };
|
||||||
|
if (command === "download") return { intent: "debug.download", args: clean({ artifact: text(parsed.artifact ?? parsed._[1]), target: text(parsed.target) }) };
|
||||||
|
if (command === "reset") return { intent: "debug.reset", args: clean({ mode: text(parsed.mode ?? parsed._[1]) }) };
|
||||||
|
if (command === "uart") {
|
||||||
|
const subcommand = parsed._[1] || "read";
|
||||||
|
if (subcommand === "read") return { intent: "io.uart.read", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), maxBytes: numberValue(parsed.maxBytes), since: text(parsed.since) }) };
|
||||||
|
if (subcommand === "write") return { intent: "io.uart.write", args: clean({ port: text(parsed.port ?? parsed._[2] ?? "uart1"), data: requiredText(parsed.data ?? parsed._[3] ?? stdinText, "data") }) };
|
||||||
|
throw cliError("unsupported_uart_command", `unsupported uart command: ${subcommand}`);
|
||||||
|
}
|
||||||
|
if (command === "jsonrpc") {
|
||||||
|
const method = requiredText(parsed.method ?? parsed._[2] ?? parsed._[1], "method");
|
||||||
|
return { intent: "io.uart.jsonrpc", args: clean({ port: text(parsed.port ?? "uart1"), method, params: parseJsonObject(parsed.paramsJson ?? parsed.params ?? "{}", "params") }) };
|
||||||
|
}
|
||||||
|
if (command === "cmd") return { intent: "cmd.run", args: { command: requiredText(parsed._[1] ?? parsed.command, "command"), argv: parsed._.slice(2) } };
|
||||||
|
throw cliError("unsupported_hwpod_command", `unsupported hwpod-cli command: ${command}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function opsForIntent(intent: string, args: any, document: any) {
|
||||||
|
const common = commonOpArgs(document);
|
||||||
|
if (intent === "inspect") return [
|
||||||
|
{ op: "node.health", args: common },
|
||||||
|
{ op: "node.inventory", args: common }
|
||||||
|
];
|
||||||
|
if (intent === "node.health" || intent === "node.version" || intent === "node.inventory") return [{ op: intent, args: common }];
|
||||||
|
if (intent === "workspace.ls") return [{ op: "workspace.ls", args: { ...common, path: text(args.path) || "." } }];
|
||||||
|
if (intent === "workspace.cat") return [{ op: "workspace.cat", args: { ...common, path: requiredText(args.path, "path") } }];
|
||||||
|
if (intent === "workspace.rg") return [{ op: "workspace.rg", args: { ...common, pattern: requiredText(args.pattern, "pattern"), path: text(args.path) || ".", ignoreCase: args.ignoreCase === true } }];
|
||||||
|
if (intent === "workspace.apply-patch") return [{ op: "workspace.apply-patch", args: clean({ ...common, patch: text(args.patch), patchBase64: text(args.patchBase64), reason: text(args.reason) }) }];
|
||||||
|
if (intent === "debug.build") return [{ op: "debug.build", args: clean({ ...common, target: text(args.target), command: text(args.command) }) }];
|
||||||
|
if (intent === "debug.download") return [{ op: "debug.download", args: clean({ ...common, artifact: text(args.artifact), target: text(args.target) }) }];
|
||||||
|
if (intent === "debug.reset") return [{ op: "debug.reset", args: clean({ ...common, mode: text(args.mode) }) }];
|
||||||
|
if (intent === "io.uart.read") return [{ op: "io.uart.read", args: clean({ ...common, port: text(args.port) || "uart1", maxBytes: numberValue(args.maxBytes), since: text(args.since) }) }];
|
||||||
|
if (intent === "io.uart.write") return [{ op: "io.uart.write", args: clean({ ...common, port: text(args.port) || "uart1", data: requiredText(args.data, "data") }) }];
|
||||||
|
if (intent === "io.uart.jsonrpc") return [{ op: "io.uart.jsonrpc", args: clean({ ...common, port: text(args.port) || "uart1", method: requiredText(args.method, "method"), params: objectValue(args.params) }) }];
|
||||||
|
if (intent === "cmd.run") return [{ op: "cmd.run", args: clean({ ...common, command: requiredText(args.command, "command"), argv: Array.isArray(args.argv) ? args.argv : [] }) }];
|
||||||
|
throw cliError("unsupported_hwpod_intent", `unsupported hwpod intent: ${intent}`, { supportedIntents: Array.from(HWPOD_NODE_OPS).concat(["inspect"]) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonOpArgs(document: any) {
|
||||||
|
return {
|
||||||
|
hwpodId: document.metadata.uid || document.metadata.name,
|
||||||
|
workspacePath: document.spec.workspace.path,
|
||||||
|
targetDevice: document.spec.targetDevice,
|
||||||
|
debugProbe: document.spec.debugProbe,
|
||||||
|
ioProbe: document.spec.ioProbe
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIntent(value: unknown) {
|
||||||
|
const intent = requiredText(value, "intent");
|
||||||
|
if (intent === "build") return "debug.build";
|
||||||
|
if (intent === "download") return "debug.download";
|
||||||
|
if (intent === "reset") return "debug.reset";
|
||||||
|
if (intent === "uart.read") return "io.uart.read";
|
||||||
|
if (intent === "uart.write") return "io.uart.write";
|
||||||
|
if (intent === "jsonrpc.call") return "io.uart.jsonrpc";
|
||||||
|
return intent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSpecDocument(parsed: ParsedArgs, now: () => string) {
|
||||||
|
const name = text(parsed.name) || "local-hwpod";
|
||||||
|
return {
|
||||||
|
apiVersion: "hwlab.pikastech.com/v1alpha1",
|
||||||
|
kind: "Hwpod",
|
||||||
|
metadata: {
|
||||||
|
name,
|
||||||
|
uid: text(parsed.uid) || `hwpod-${name}`,
|
||||||
|
createdAt: now()
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
nodeBinding: {
|
||||||
|
nodeId: text(parsed.node ?? parsed.nodeId) || "local-node"
|
||||||
|
},
|
||||||
|
targetDevice: {
|
||||||
|
id: text(parsed.targetDevice ?? parsed.targetDeviceId) || "target-device-local"
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
path: text(parsed.workspace ?? parsed.workspacePath) || "."
|
||||||
|
},
|
||||||
|
debugProbe: {
|
||||||
|
id: text(parsed.debugProbe ?? parsed.debugProbeId) || "debug-probe-local",
|
||||||
|
kind: text(parsed.debugProbeKind) || "generic"
|
||||||
|
},
|
||||||
|
ioProbe: {
|
||||||
|
id: text(parsed.ioProbe ?? parsed.ioProbeId) || "io-probe-local",
|
||||||
|
kind: text(parsed.ioProbeKind) || "generic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHwpodSpec(document: any, specPath: string) {
|
||||||
|
const root = objectValue(document);
|
||||||
|
if (root.kind !== "Hwpod") throw cliError("invalid_hwpod_spec_kind", "hwpod-spec kind must be Hwpod", { specPath, kind: root.kind });
|
||||||
|
const spec = objectValue(root.spec);
|
||||||
|
const metadata = objectValue(root.metadata);
|
||||||
|
for (const key of ["targetDevice", "workspace", "debugProbe", "ioProbe"]) {
|
||||||
|
if (!isPlainObject(spec[key])) throw cliError("invalid_hwpod_spec_element", `hwpod-spec missing ${key}`, { specPath, element: key });
|
||||||
|
}
|
||||||
|
const workspacePath = requiredText(spec.workspace.path, "spec.workspace.path");
|
||||||
|
const nodeBinding = objectValue(spec.nodeBinding);
|
||||||
|
const nodeId = requiredText(nodeBinding.nodeId, "spec.nodeBinding.nodeId");
|
||||||
|
const name = requiredText(metadata.name ?? metadata.uid, "metadata.name");
|
||||||
|
return {
|
||||||
|
apiVersion: text(root.apiVersion) || "hwlab.pikastech.com/v1alpha1",
|
||||||
|
kind: "Hwpod",
|
||||||
|
metadata: { ...metadata, name },
|
||||||
|
spec: {
|
||||||
|
...spec,
|
||||||
|
nodeBinding: { ...nodeBinding, nodeId },
|
||||||
|
workspace: { ...spec.workspace, path: workspacePath },
|
||||||
|
targetDevice: spec.targetDevice,
|
||||||
|
debugProbe: spec.debugProbe,
|
||||||
|
ioProbe: spec.ioProbe
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitHwpodNodeOpsPlan({ parsed, env, fetchImpl, plan }: { parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike; plan: any }) {
|
||||||
|
const endpoint = resolveRuntimeEndpoint({ kind: "api", parsed, env });
|
||||||
|
const route = { method: "POST", path: "/v1/hwpod-node-ops" };
|
||||||
|
const url = `${endpoint.baseUrl}${route.path}`;
|
||||||
|
const headers = authHeaders(parsed, env);
|
||||||
|
const response = fetchImpl
|
||||||
|
? await fetchImpl(url, { method: route.method, headers, body: JSON.stringify(plan) })
|
||||||
|
: await fetch(url, { method: route.method, headers, body: JSON.stringify(plan), signal: AbortSignal.timeout(numberValue(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS) });
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
return { status: response.status, body, route, runtimeEndpoint: runtimeEndpointVisibility(endpoint) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(parsed: ParsedArgs, env: EnvLike) {
|
||||||
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||||
|
if (parsed.noAuth === true) return headers;
|
||||||
|
const apiKey = text(parsed.apiKey ?? env.HWLAB_API_KEY ?? env.HWLAB_DEVICE_POD_API_KEY ?? env.HWLAB_CODE_AGENT_DEVICE_POD_API_KEY);
|
||||||
|
if (apiKey) headers["x-hwlab-device-pod-api-key"] = apiKey;
|
||||||
|
const sessionToken = text(parsed.sessionToken ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN);
|
||||||
|
if (sessionToken) headers["x-hwlab-session-token"] = sessionToken;
|
||||||
|
const cookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE);
|
||||||
|
if (cookie) headers.cookie = cookie;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSimpleYaml(textValue: string, sourcePath: string) {
|
||||||
|
const root: any = {};
|
||||||
|
const stack = [{ indent: -1, value: root }];
|
||||||
|
const lines = textValue.replace(/\r\n?/gu, "\n").split("\n");
|
||||||
|
for (let index = 0; index < lines.length; index += 1) {
|
||||||
|
const rawLine = stripYamlComment(lines[index]);
|
||||||
|
if (!rawLine.trim()) continue;
|
||||||
|
const indent = rawLine.match(/^ */u)?.[0].length ?? 0;
|
||||||
|
if (indent % 2 !== 0) throw cliError("invalid_hwpod_spec_yaml", `invalid indentation at ${sourcePath}:${index + 1}`);
|
||||||
|
const line = rawLine.slice(indent);
|
||||||
|
const match = line.match(/^([A-Za-z0-9_.-]+):(?:\s*(.*))?$/u);
|
||||||
|
if (!match) throw cliError("invalid_hwpod_spec_yaml", `unsupported YAML line at ${sourcePath}:${index + 1}`, { line });
|
||||||
|
while (stack[stack.length - 1].indent >= indent) stack.pop();
|
||||||
|
const parent = stack[stack.length - 1].value;
|
||||||
|
const key = match[1];
|
||||||
|
const rawValue = match[2] ?? "";
|
||||||
|
if (rawValue.trim() === "") {
|
||||||
|
const next: any = {};
|
||||||
|
parent[key] = next;
|
||||||
|
stack.push({ indent, value: next });
|
||||||
|
} else {
|
||||||
|
parent[key] = parseScalar(rawValue.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifySimpleYaml(value: any, indent = 0): string {
|
||||||
|
return `${Object.entries(value).map(([key, child]) => {
|
||||||
|
if (isPlainObject(child)) return `${" ".repeat(indent)}${key}:\n${stringifySimpleYaml(child, indent + 2)}`;
|
||||||
|
return `${" ".repeat(indent)}${key}: ${formatScalar(child)}`;
|
||||||
|
}).join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripYamlComment(line: string) {
|
||||||
|
let quoted: string | null = null;
|
||||||
|
for (let index = 0; index < line.length; index += 1) {
|
||||||
|
const char = line[index];
|
||||||
|
if ((char === '"' || char === "'") && line[index - 1] !== "\\") quoted = quoted === char ? null : quoted || char;
|
||||||
|
if (char === "#" && !quoted && (index === 0 || /\s/u.test(line[index - 1]))) return line.slice(0, index).trimEnd();
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScalar(value: string) {
|
||||||
|
if (value === "true") return true;
|
||||||
|
if (value === "false") return false;
|
||||||
|
if (value === "null") return null;
|
||||||
|
if (/^-?\d+(?:\.\d+)?$/u.test(value)) return Number(value);
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
return value.startsWith('"') ? JSON.parse(value) : value.slice(1, -1).replace(/''/gu, "'");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScalar(value: any) {
|
||||||
|
if (value === null || value === undefined) return "null";
|
||||||
|
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
||||||
|
const stringValue = String(value);
|
||||||
|
return /^[A-Za-z0-9_./:@-]+$/u.test(stringValue) ? stringValue : JSON.stringify(stringValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptions(argv: string[]): ParsedArgs {
|
||||||
|
const parsed: ParsedArgs = { _: [] };
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const token = argv[index];
|
||||||
|
if (token === "--") {
|
||||||
|
parsed._.push(...argv.slice(index + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!token.startsWith("-") || token === "-") {
|
||||||
|
parsed._.push(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "-h") {
|
||||||
|
parsed.h = true;
|
||||||
|
parsed.help = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [rawKey, rawInline] = token.slice(2).split(/=(.*)/su, 2);
|
||||||
|
const key = camelCase(rawKey);
|
||||||
|
if (BOOLEAN_OPTIONS.has(key)) {
|
||||||
|
parsed[key] = rawInline === undefined || rawInline === "" ? true : truthy(rawInline);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawInline !== undefined && rawInline !== "") {
|
||||||
|
parsed[key] = rawInline;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = argv[index + 1];
|
||||||
|
if (next === undefined || next.startsWith("--")) {
|
||||||
|
parsed[key] = true;
|
||||||
|
} else {
|
||||||
|
parsed[key] = next;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(value: unknown, name: string) {
|
||||||
|
if (value === undefined || value === null || value === "") return {};
|
||||||
|
if (isPlainObject(value)) return value;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(value));
|
||||||
|
if (!isPlainObject(parsed)) throw new Error(`${name} must be a JSON object`);
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
throw cliError(`invalid_${name}_json`, `${name} must be a JSON object`, { reason: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDotPath(target: any, dotPath: string, value: any) {
|
||||||
|
const parts = dotPath.split(".").filter(Boolean);
|
||||||
|
if (parts.length === 0) throw cliError("invalid_hwpod_spec_path", "spec path is empty");
|
||||||
|
let cursor = target;
|
||||||
|
for (const part of parts.slice(0, -1)) {
|
||||||
|
if (!isPlainObject(cursor[part])) cursor[part] = {};
|
||||||
|
cursor = cursor[part];
|
||||||
|
}
|
||||||
|
cursor[parts[parts.length - 1]] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scalarFromSetValue(value: string) {
|
||||||
|
if (value === "true" || value === "false" || value === "null" || /^-?\d+(?:\.\d+)?$/u.test(value)) return parseScalar(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function specPathFrom(parsed: ParsedArgs) {
|
||||||
|
return text(parsed.spec ?? parsed.specPath) || DEFAULT_HWPOD_SPEC_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(exitCode: number, payload: any, now: () => string) {
|
||||||
|
return { exitCode, payload: { ...payload, observedAt: now() } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(action: string, extra: Record<string, unknown> = {}, status = "succeeded") {
|
||||||
|
return { ok: true, action, status, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
function failure(action: string, error: any) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
action,
|
||||||
|
status: "failed",
|
||||||
|
error: {
|
||||||
|
code: error?.code || "hwpod_cli_error",
|
||||||
|
message: error?.message || String(error),
|
||||||
|
details: error?.details || undefined
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliError(code: string, message: string, details: Record<string, unknown> = {}) {
|
||||||
|
const error = new Error(message) as Error & { code?: string; details?: Record<string, unknown> };
|
||||||
|
error.code = code;
|
||||||
|
error.details = details;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredText(value: unknown, name: string) {
|
||||||
|
const normalized = text(value);
|
||||||
|
if (!normalized) throw cliError("required_option_missing", `${name} is required`, { name });
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||||
|
return Number.isInteger(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean<T extends Record<string, unknown>>(value: T): T {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value: any) {
|
||||||
|
return isPlainObject(value) ? value : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: any) {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function camelCase(value: string) {
|
||||||
|
return value.replace(/-([a-z0-9])/giu, (_match, char) => char.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function truthy(value: unknown) {
|
||||||
|
return /^(?:1|true|yes|on)$/iu.test(String(value ?? ""));
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import { createServer } from "node:http";
|
||||||
|
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { HWPOD_NODE_OPS_CONTRACT_VERSION } from "./hwpod-harness-lib.ts";
|
||||||
|
|
||||||
|
const NODE_VERSION = "0.1.0-thin-node-ops";
|
||||||
|
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
||||||
|
const BOOLEAN_OPTIONS = new Set(["help", "h", "json"]);
|
||||||
|
|
||||||
|
type ParsedArgs = Record<string, unknown> & { _: string[] };
|
||||||
|
|
||||||
|
export async function mainHwpodNode(argv = process.argv.slice(2), options: any = {}) {
|
||||||
|
const result = await runHwpodNode(argv, options);
|
||||||
|
if (result?.keepAlive) return;
|
||||||
|
console.log(JSON.stringify(result.payload, null, 2));
|
||||||
|
process.exitCode = result.exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runHwpodNode(argv: string[], options: { stdinText?: string; now?: () => string } = {}) {
|
||||||
|
const now = options.now ?? (() => new Date().toISOString());
|
||||||
|
try {
|
||||||
|
const parsed = parseOptions(argv);
|
||||||
|
const command = parsed._[0] || "help";
|
||||||
|
if (["help", "--help", "-h"].includes(command)) return result(0, help(), now);
|
||||||
|
if (command === "run") {
|
||||||
|
const plan = planFromParsed(parsed, options.stdinText);
|
||||||
|
return result(0, await executeHwpodNodeOpsPlan(plan, { now }), now);
|
||||||
|
}
|
||||||
|
if (command === "serve") {
|
||||||
|
const server = createHwpodNodeServer({ now });
|
||||||
|
const host = text(parsed.host) || "127.0.0.1";
|
||||||
|
const port = numberValue(parsed.port) ?? 19678;
|
||||||
|
await new Promise((resolve) => server.listen(port, host, resolve));
|
||||||
|
console.log(JSON.stringify({ ok: true, action: "hwpod-node.serve", status: "listening", host, port: server.address()?.port ?? port, observedAt: now() }, null, 2));
|
||||||
|
return { exitCode: 0, payload: null, keepAlive: true, server };
|
||||||
|
}
|
||||||
|
throw cliError("unsupported_hwpod_node_command", `unsupported hwpod-node command: ${command}`);
|
||||||
|
} catch (error) {
|
||||||
|
return result(1, failure("hwpod-node", error), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHwpodNodeServer(options: any = {}) {
|
||||||
|
const now = options.now ?? (() => new Date().toISOString());
|
||||||
|
return createServer(async (request, response) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url || "/", "http://hwpod-node.local");
|
||||||
|
if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1/hwpod-node-ops")) {
|
||||||
|
return sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
status: "ready",
|
||||||
|
serviceId: "hwpod-node",
|
||||||
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||||
|
version: NODE_VERSION,
|
||||||
|
nodeId: process.env.HWPOD_NODE_ID || os.hostname(),
|
||||||
|
observedAt: now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && url.pathname === "/v1/hwpod-node-ops") {
|
||||||
|
const body = await readBody(request, options.bodyLimitBytes);
|
||||||
|
const plan = body ? JSON.parse(body) : {};
|
||||||
|
return sendJson(response, 200, await executeHwpodNodeOpsPlan(plan, { now }));
|
||||||
|
}
|
||||||
|
return sendJson(response, 404, { ok: false, error: { code: "not_found", message: "hwpod-node route not found" } });
|
||||||
|
} catch (error) {
|
||||||
|
return sendJson(response, 500, failure("hwpod-node.http", error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeHwpodNodeOpsPlan(plan: any, options: any = {}) {
|
||||||
|
const now = options.now ?? (() => new Date().toISOString());
|
||||||
|
if (!plan || typeof plan !== "object" || Array.isArray(plan)) throw cliError("invalid_hwpod_node_ops_plan", "plan must be a JSON object");
|
||||||
|
if (plan.contractVersion !== HWPOD_NODE_OPS_CONTRACT_VERSION) throw cliError("invalid_hwpod_node_ops_contract", `contractVersion must be ${HWPOD_NODE_OPS_CONTRACT_VERSION}`);
|
||||||
|
if (!Array.isArray(plan.ops) || plan.ops.length === 0) throw cliError("invalid_hwpod_node_ops", "ops must be a non-empty array");
|
||||||
|
const results = [];
|
||||||
|
for (const op of plan.ops) {
|
||||||
|
results.push(await executeOp(op, { plan, now }));
|
||||||
|
}
|
||||||
|
const failed = results.some((item) => item.ok === false);
|
||||||
|
return {
|
||||||
|
ok: !failed,
|
||||||
|
status: failed ? "failed" : "completed",
|
||||||
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||||
|
serviceId: "hwpod-node",
|
||||||
|
nodeVersion: NODE_VERSION,
|
||||||
|
planId: plan.planId ?? null,
|
||||||
|
hwpodId: plan.hwpodId ?? null,
|
||||||
|
nodeId: plan.nodeId ?? process.env.HWPOD_NODE_ID ?? os.hostname(),
|
||||||
|
results,
|
||||||
|
observedAt: now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeOp(op: any, context: any) {
|
||||||
|
const opId = text(op?.opId) || "op_unknown";
|
||||||
|
const name = text(op?.op);
|
||||||
|
const args = op?.args && typeof op.args === "object" && !Array.isArray(op.args) ? op.args : {};
|
||||||
|
try {
|
||||||
|
if (name === "node.health") return opOk(opId, name, { platform: process.platform, arch: process.arch, hostname: os.hostname(), cwd: process.cwd() });
|
||||||
|
if (name === "node.version") return opOk(opId, name, { version: NODE_VERSION });
|
||||||
|
if (name === "node.inventory") return opOk(opId, name, await nodeInventory(args));
|
||||||
|
if (name === "workspace.ls") return opOk(opId, name, await workspaceLs(args));
|
||||||
|
if (name === "workspace.cat") return opOk(opId, name, await workspaceCat(args));
|
||||||
|
if (name === "workspace.rg") return opOk(opId, name, await workspaceRg(args));
|
||||||
|
if (name === "workspace.apply-patch") return opOk(opId, name, await workspaceApplyPatch(args));
|
||||||
|
if (name === "cmd.run") return opOk(opId, name, await cmdRun(args));
|
||||||
|
if (["debug.build", "debug.download", "debug.reset", "io.uart.read", "io.uart.write", "io.uart.jsonrpc"].includes(name)) {
|
||||||
|
return opBlocked(opId, name, "hwpod_node_op_not_configured", `${name} requires node-side tool binding; the thin node contract is present but this local executor has no binding yet`);
|
||||||
|
}
|
||||||
|
return opBlocked(opId, name, "unsupported_hwpod_node_op", `unsupported hwpod-node op: ${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
return opBlocked(opId, name, error?.code || "hwpod_node_op_failed", error?.message || String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nodeInventory(args: any) {
|
||||||
|
const root = workspaceRoot(args);
|
||||||
|
const info = await stat(root).catch(() => null);
|
||||||
|
return { workspaceRoot: root, workspaceExists: Boolean(info), workspaceIsDirectory: info?.isDirectory?.() ?? false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workspaceLs(args: any) {
|
||||||
|
const target = resolveWorkspacePath(args, text(args.path) || ".");
|
||||||
|
const entries = await readdir(target, { withFileTypes: true });
|
||||||
|
return {
|
||||||
|
path: target,
|
||||||
|
entries: entries.map((entry) => ({ name: entry.name, type: entry.isDirectory() ? "dir" : entry.isFile() ? "file" : "other" }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workspaceCat(args: any) {
|
||||||
|
const target = resolveWorkspacePath(args, requiredText(args.path, "path"));
|
||||||
|
const maxBytes = numberValue(args.maxBytes) ?? 65536;
|
||||||
|
const content = await readFile(target, "utf8");
|
||||||
|
const truncated = Buffer.byteLength(content, "utf8") > maxBytes;
|
||||||
|
return { path: target, content: truncated ? content.slice(0, maxBytes) : content, truncated };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workspaceRg(args: any) {
|
||||||
|
const pattern = requiredText(args.pattern, "pattern");
|
||||||
|
const cwd = workspaceRoot(args);
|
||||||
|
const rgPath = text(args.path) || ".";
|
||||||
|
const command = ["rg", "--line-number", "--color", "never", ...(args.ignoreCase === true ? ["--ignore-case"] : []), pattern, rgPath];
|
||||||
|
const output = await spawnOutput(command, { cwd, timeoutMs: numberValue(args.timeoutMs) ?? 10000 });
|
||||||
|
return { cwd, command, ...output };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workspaceApplyPatch(args: any) {
|
||||||
|
const patch = text(args.patch) || (text(args.patchBase64) ? Buffer.from(text(args.patchBase64), "base64").toString("utf8") : "");
|
||||||
|
if (!patch) throw cliError("patch_required", "patch or patchBase64 is required");
|
||||||
|
const cwd = workspaceRoot(args);
|
||||||
|
return { cwd, changes: await applyPatchEnvelope(cwd, patch) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPatchEnvelope(root: string, patch: string) {
|
||||||
|
const lines = patch.replace(/\r\n?/gu, "\n").split("\n");
|
||||||
|
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||||
|
if (lines[0] !== "*** Begin Patch" || lines[lines.length - 1] !== "*** End Patch") {
|
||||||
|
throw cliError("invalid_apply_patch_envelope", "patch must start with *** Begin Patch and end with *** End Patch");
|
||||||
|
}
|
||||||
|
const changes = [];
|
||||||
|
for (let index = 1; index < lines.length - 1;) {
|
||||||
|
const header = lines[index];
|
||||||
|
if (header.startsWith("*** Add File: ")) {
|
||||||
|
const relativePath = header.slice("*** Add File: ".length).trim();
|
||||||
|
const filePath = resolvePatchFile(root, relativePath);
|
||||||
|
const addLines = [];
|
||||||
|
index += 1;
|
||||||
|
while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
|
||||||
|
if (!lines[index].startsWith("+")) throw cliError("invalid_add_file_hunk", `add file lines must start with + for ${relativePath}`);
|
||||||
|
addLines.push(lines[index].slice(1));
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, `${addLines.join("\n")}\n`, "utf8");
|
||||||
|
changes.push({ action: "add", path: relativePath, lines: addLines.length });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (header.startsWith("*** Delete File: ")) {
|
||||||
|
const relativePath = header.slice("*** Delete File: ".length).trim();
|
||||||
|
await rm(resolvePatchFile(root, relativePath), { force: false });
|
||||||
|
changes.push({ action: "delete", path: relativePath });
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (header.startsWith("*** Update File: ")) {
|
||||||
|
const relativePath = header.slice("*** Update File: ".length).trim();
|
||||||
|
const filePath = resolvePatchFile(root, relativePath);
|
||||||
|
const hunks = [];
|
||||||
|
index += 1;
|
||||||
|
while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
|
||||||
|
if (!lines[index].startsWith("@@")) throw cliError("invalid_update_hunk", `update hunk must start with @@ for ${relativePath}`);
|
||||||
|
index += 1;
|
||||||
|
const hunkLines = [];
|
||||||
|
while (index < lines.length - 1 && !lines[index].startsWith("@@") && !lines[index].startsWith("*** ")) {
|
||||||
|
const prefix = lines[index][0];
|
||||||
|
if (![" ", "-", "+"].includes(prefix)) throw cliError("invalid_update_hunk_line", `hunk lines must start with space, -, or + for ${relativePath}`);
|
||||||
|
hunkLines.push(lines[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
hunks.push(hunkLines);
|
||||||
|
}
|
||||||
|
const applied = await applyUpdateHunks(filePath, hunks, relativePath);
|
||||||
|
changes.push({ action: "update", path: relativePath, hunks: hunks.length, replacements: applied });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw cliError("unsupported_apply_patch_operation", `unsupported patch operation: ${header}`);
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyUpdateHunks(filePath: string, hunks: string[][], relativePath: string) {
|
||||||
|
const original = await readFile(filePath, "utf8");
|
||||||
|
const hadFinalNewline = original.endsWith("\n");
|
||||||
|
let fileLines = original.replace(/\r\n?/gu, "\n").split("\n");
|
||||||
|
if (hadFinalNewline) fileLines.pop();
|
||||||
|
let searchStart = 0;
|
||||||
|
for (const hunk of hunks) {
|
||||||
|
const oldLines = hunk.filter((line) => line[0] !== "+").map((line) => line.slice(1));
|
||||||
|
const newLines = hunk.filter((line) => line[0] !== "-").map((line) => line.slice(1));
|
||||||
|
const at = findLineSequence(fileLines, oldLines, searchStart);
|
||||||
|
if (at < 0) throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`);
|
||||||
|
fileLines = [...fileLines.slice(0, at), ...newLines, ...fileLines.slice(at + oldLines.length)];
|
||||||
|
searchStart = at + newLines.length;
|
||||||
|
}
|
||||||
|
await writeFile(filePath, `${fileLines.join("\n")}${hadFinalNewline ? "\n" : ""}`, "utf8");
|
||||||
|
return hunks.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLineSequence(lines: string[], sequence: string[], start: number) {
|
||||||
|
if (sequence.length === 0) return start;
|
||||||
|
for (let index = start; index <= lines.length - sequence.length; index += 1) {
|
||||||
|
if (sequence.every((line, offset) => lines[index + offset] === line)) return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePatchFile(root: string, relativePath: string) {
|
||||||
|
if (!relativePath || path.isAbsolute(relativePath)) throw cliError("invalid_patch_path", "patch file paths must be relative", { path: relativePath });
|
||||||
|
return resolveWorkspacePath({ workspacePath: root }, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdRun(args: any) {
|
||||||
|
const command = requiredText(args.command, "command");
|
||||||
|
const argv = Array.isArray(args.argv) ? args.argv.map(String) : [];
|
||||||
|
const cwd = workspaceRoot(args);
|
||||||
|
const output = await spawnOutput([command, ...argv], { cwd, timeoutMs: numberValue(args.timeoutMs) ?? 30000 });
|
||||||
|
return { cwd, command: [command, ...argv], ...output };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spawnOutput(command: string[], { cwd, stdinText = "", timeoutMs }: any) {
|
||||||
|
const proc = Bun.spawn(command, { cwd, stdin: stdinText ? "pipe" : "ignore", stdout: "pipe", stderr: "pipe" });
|
||||||
|
if (stdinText && proc.stdin) {
|
||||||
|
proc.stdin.write(stdinText);
|
||||||
|
proc.stdin.end();
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(() => proc.kill(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
||||||
|
return { exitCode, stdout, stderr, ok: exitCode === 0 };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceRoot(args: any) {
|
||||||
|
return path.resolve(text(args.workspacePath) || process.env.HWPOD_WORKSPACE_PATH || process.cwd());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkspacePath(args: any, relativePath: string) {
|
||||||
|
const root = workspaceRoot(args);
|
||||||
|
const target = path.resolve(root, relativePath);
|
||||||
|
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||||
|
throw cliError("workspace_path_outside_root", "workspace path must stay inside workspacePath", { workspacePath: root, path: relativePath });
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function planFromParsed(parsed: ParsedArgs, stdinText?: string) {
|
||||||
|
if (parsed.planJson) return JSON.parse(String(parsed.planJson));
|
||||||
|
if (parsed.plan) return JSON.parse(String(parsed.plan));
|
||||||
|
if (stdinText) return JSON.parse(stdinText);
|
||||||
|
throw cliError("hwpod_node_plan_required", "pass --plan-json or pipe a hwpod-node-ops plan to hwpod-node run");
|
||||||
|
}
|
||||||
|
|
||||||
|
function help() {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
action: "hwpod-node.help",
|
||||||
|
status: "succeeded",
|
||||||
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||||
|
version: NODE_VERSION,
|
||||||
|
role: "Thin node-side executor for stable hwpod-node-ops.",
|
||||||
|
usage: [
|
||||||
|
"bun tools/hwpod-node.ts run --plan-json '{...}'",
|
||||||
|
"bun tools/hwpod-node.ts serve --host 127.0.0.1 --port 19678"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(exitCode: number, payload: any, now: () => string) {
|
||||||
|
return { exitCode, payload: { ...payload, observedAt: payload?.observedAt ?? now() } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function opOk(opId: string, op: string, output: any) {
|
||||||
|
return { opId, op, ok: true, status: "completed", output };
|
||||||
|
}
|
||||||
|
|
||||||
|
function opBlocked(opId: string, op: string, code: string, summary: string) {
|
||||||
|
return { opId, op, ok: false, status: "blocked", blocker: { code, layer: "hwpod-node", retryable: true, summary } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function failure(action: string, error: any) {
|
||||||
|
return { ok: false, action, status: "failed", error: { code: error?.code || "hwpod_node_error", message: error?.message || String(error), details: error?.details || undefined } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptions(argv: string[]): ParsedArgs {
|
||||||
|
const parsed: ParsedArgs = { _: [] };
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const token = argv[index];
|
||||||
|
if (token === "--") {
|
||||||
|
parsed._.push(...argv.slice(index + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!token.startsWith("-") || token === "-") {
|
||||||
|
parsed._.push(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "-h") {
|
||||||
|
parsed.h = true;
|
||||||
|
parsed.help = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [rawKey, rawInline] = token.slice(2).split(/=(.*)/su, 2);
|
||||||
|
const key = rawKey.replace(/-([a-z0-9])/giu, (_match, char) => char.toUpperCase());
|
||||||
|
if (BOOLEAN_OPTIONS.has(key)) {
|
||||||
|
parsed[key] = rawInline === undefined || rawInline === "" ? true : /^(?:1|true|yes|on)$/iu.test(rawInline);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawInline !== undefined && rawInline !== "") {
|
||||||
|
parsed[key] = rawInline;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = argv[index + 1];
|
||||||
|
if (next === undefined || next.startsWith("--")) {
|
||||||
|
parsed[key] = true;
|
||||||
|
} else {
|
||||||
|
parsed[key] = next;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(request: any, limitBytes = DEFAULT_BODY_LIMIT_BYTES) {
|
||||||
|
const limit = Number.isInteger(limitBytes) && limitBytes > 0 ? limitBytes : DEFAULT_BODY_LIMIT_BYTES;
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
let body = "";
|
||||||
|
request.setEncoding("utf8");
|
||||||
|
request.on("data", (chunk: string) => {
|
||||||
|
body += chunk;
|
||||||
|
if (Buffer.byteLength(body, "utf8") > limit) request.destroy(new Error(`request body exceeds ${limit} bytes`));
|
||||||
|
});
|
||||||
|
request.on("end", () => resolve(body));
|
||||||
|
request.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(response: any, statusCode: number, body: any) {
|
||||||
|
response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
||||||
|
response.end(`${JSON.stringify(body)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliError(code: string, message: string, details: Record<string, unknown> = {}) {
|
||||||
|
const error = new Error(message) as Error & { code?: string; details?: Record<string, unknown> };
|
||||||
|
error.code = code;
|
||||||
|
error.details = details;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredText(value: unknown, name: string) {
|
||||||
|
const normalized = text(value);
|
||||||
|
if (!normalized) throw cliError("required_option_missing", `${name} is required`, { name });
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||||
|
return Number.isInteger(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user