feat: add hwpod harness node-ops loop

This commit is contained in:
Codex Agent
2026-06-05 11:47:00 +08:00
parent 45bd95e9d1
commit c8a8aa505e
13 changed files with 1795 additions and 3 deletions
+1
View File
@@ -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 `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)。
- 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-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)。
+5 -3
View File
@@ -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 评论。
旧的 `device-pod-cli` 本地 profile 闭环只用于 CLI MVP 和真实硬件最小验证。进入正式多用户系统后,所有用户态设备访问必须收敛到:
旧的 `device-pod-cli` 本地 profile 闭环只用于 CLI MVP 和真实硬件最小验证。既有正式多用户 device-pod 路径曾收敛到:
```text
browser Cloud Web UI or hwpod/device-pod-cli
+198
View File
@@ -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 opsdebug/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 probeUART、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` 中。
+152
View File
@@ -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}`;
}
+220
View File
@@ -68,6 +68,23 @@ import {
} from "./skills-store.ts";
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) {
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",
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)
});
return;
@@ -411,6 +437,11 @@ async function handleRestAdapter(request, response, url, options) {
return;
}
if (url.pathname === "/v1/hwpod-node-ops") {
await handleHwpodNodeOpsHttp(request, response, options);
return;
}
if (request.method === "GET" && url.pathname === M3_IO_CONTROL_ROUTE) {
sendJson(response, 200, await describeM3IoControlLive(options));
return;
@@ -573,6 +604,195 @@ async function handleRestAdapter(request, response, url, options) {
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) {
if (request.method !== "POST") {
sendJson(response, 405, { error: { code: "method_not_allowed", message: "Internal device-pod gateway dispatch only supports POST" } });
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bun
import { mainHwpodCli } from "./src/hwpod-harness-lib.ts";
await mainHwpodCli();
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bun
import { mainHwpodCompilerCli } from "./src/hwpod-harness-lib.ts";
await mainHwpodCompilerCli();
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bun
import { mainHwpodCtl } from "./src/hwpod-harness-lib.ts";
await mainHwpodCtl();
+118
View File
@@ -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 });
}
});
+88
View File
@@ -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 });
}
});
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bun
import { mainHwpodNode } from "./src/hwpod-node-lib.ts";
await mainHwpodNode();
+600
View File
@@ -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 ?? ""));
}
+397
View File
@@ -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;
}