diff --git a/AGENTS.md b/AGENTS.md index 8cbe10f2..cf162223 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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)。 diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index e6665419..691e21a7 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.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 评论。 -旧的 `device-pod-cli` 本地 profile 闭环只用于 CLI MVP 和真实硬件最小验证。进入正式多用户系统后,所有用户态设备访问必须收敛到: +旧的 `device-pod-cli` 本地 profile 闭环只用于 CLI MVP 和真实硬件最小验证。既有正式多用户 device-pod 路径曾收敛到: ```text browser Cloud Web UI or hwpod/device-pod-cli diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md new file mode 100644 index 00000000..0c8b38e7 --- /dev/null +++ b/docs/reference/spec-hwpod-harness.md @@ -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 ` 显式指定。第一版 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` 中。 diff --git a/internal/cloud/hwpod-node-ops.test.ts b/internal/cloud/hwpod-node-ops.test.ts new file mode 100644 index 00000000..9c70d584 --- /dev/null +++ b/internal/cloud/hwpod-node-ops.test.ts @@ -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}`; +} diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 4d858e20..20c3c090 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -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 || ""}`, 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" } }); diff --git a/tools/hwpod-cli.ts b/tools/hwpod-cli.ts new file mode 100644 index 00000000..42ee8786 --- /dev/null +++ b/tools/hwpod-cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env bun +import { mainHwpodCli } from "./src/hwpod-harness-lib.ts"; + +await mainHwpodCli(); diff --git a/tools/hwpod-compiler-cli.ts b/tools/hwpod-compiler-cli.ts new file mode 100644 index 00000000..29ac96be --- /dev/null +++ b/tools/hwpod-compiler-cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env bun +import { mainHwpodCompilerCli } from "./src/hwpod-harness-lib.ts"; + +await mainHwpodCompilerCli(); diff --git a/tools/hwpod-ctl.ts b/tools/hwpod-ctl.ts new file mode 100644 index 00000000..4038130f --- /dev/null +++ b/tools/hwpod-ctl.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env bun +import { mainHwpodCtl } from "./src/hwpod-harness-lib.ts"; + +await mainHwpodCtl(); diff --git a/tools/hwpod-harness.test.ts b/tools/hwpod-harness.test.ts new file mode 100644 index 00000000..2a2c7309 --- /dev/null +++ b/tools/hwpod-harness.test.ts @@ -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 }); + } +}); diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts new file mode 100644 index 00000000..a6c5229f --- /dev/null +++ b/tools/hwpod-node.test.ts @@ -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 }); + } +}); diff --git a/tools/hwpod-node.ts b/tools/hwpod-node.ts new file mode 100644 index 00000000..081d57b3 --- /dev/null +++ b/tools/hwpod-node.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env bun +import { mainHwpodNode } from "./src/hwpod-node-lib.ts"; + +await mainHwpodNode(); diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts new file mode 100644 index 00000000..6a17a493 --- /dev/null +++ b/tools/src/hwpod-harness-lib.ts @@ -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; +type ParsedArgs = Record & { _: 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 = { "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 = {}, 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 = {}) { + const error = new Error(message) as Error & { code?: string; details?: Record }; + 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>(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 ?? "")); +} diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts new file mode 100644 index 00000000..cec842c2 --- /dev/null +++ b/tools/src/hwpod-node-lib.ts @@ -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[] }; + +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((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 = {}) { + const error = new Error(message) as Error & { code?: string; details?: Record }; + 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; +}