diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index bbf24206..d80db386 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -2,23 +2,25 @@ > **hwpod-node 运维操作见 `hwpod-ops` skill**(`~/.agents/skills/hwpod-ops/SKILL.md`),包含 G14 serve 模式、D601 Windows connect 模式启停、cloud-api 注册状态检查、源码同步、日志路径和故障排查。 -本文是 HWLAB `v0.2` 的 HWPOD Harness 长期规格。概念体系和实施跟踪见 [pikasTech/HWLAB#897](https://github.com/pikasTech/HWLAB/issues/897)。当前实现只打通单线程核心业务闭环,不把鉴权、安全、并发、计量、复杂调度或 Evidence 体系作为前置条件。 +本文是 HWLAB `v0.2` 的 HWPOD Harness 长期规格。概念体系和实施跟踪见 [pikasTech/HWLAB#897](https://github.com/pikasTech/HWLAB/issues/897);HWPOD 集中服务式管理和 `hwpod-id` source-of-truth 收敛见 [pikasTech/HWLAB#1043](https://github.com/pikasTech/HWLAB/issues/1043)。当前实现只打通单线程核心业务闭环,不把鉴权、安全、并发、计量、复杂调度或 Evidence 体系作为前置条件。 本规格不保留旧设备执行路径作为迁移对照;新任务只按 HWPOD 单通道实现和验收。 -## 快速迭代阶段 +## 默认运行通道 -快速迭代阶段把业务翻译权放在 Code Agent workspace 内: +当前 Code Agent / CaseRun runner 的默认 HWPOD 合同是 `hwpod-id` 加本次任务的 workspace binding,不再要求 runner workspace 预装 `.hwlab/hwpod-spec.yaml`: ```text Code Agent workspace - .hwlab/hwpod-spec.yaml hwpod-cli hwpod-ctl hwpod-compiler-cli + task context: hwpodId + workspacePath hwpod-cli / hwpod-ctl - -> hwpod-compiler-cli + -> runtime HWPOD registry (/v1/hwpod/specs?hwpodId=...) + -> HWPOD document + run workspace override + -> hwpod-compiler-cli / in-process compiler -> hwpod-node-ops plan -> hwlab-api -> hwpod-node @@ -26,19 +28,22 @@ hwpod-cli / hwpod-ctl 核心边界: -- `hwpod-spec` 存放在 Code Agent workspace 内,是当前 hwpod 的本地声明式定义。 +- `hwpod-id` 是 WebUI、CLI、CaseRun、Code Agent 和 runner 的默认运行时引用。 +- HWPOD 定义由 runtime service 解析;runner-local YAML 只作为显式 debug/import/export,不作为默认合同。 - `hwpod-cli` 是用户和 Code Agent 执行研发动作的入口。 -- `hwpod-ctl` 是和 `hwpod-cli` 平级的管理入口,用于初始化、修改、检查 `hwpod-spec`,以及 smoke/node 状态类操作。 -- `hwpod-compiler-cli` 是 workspace-local 编译器,把高层 intent 和 `hwpod-spec` 编译为 `hwpod-node-ops`。 +- `hwpod-ctl` 是和 `hwpod-cli` 平级的管理入口,用于校验 `hwpod-id` 解析结果、检查四要素/node binding,以及 smoke/node 状态类操作。 +- `hwpod-compiler-cli` / in-process compiler 把高层 intent 和 runtime-resolved HWPOD document 编译为 `hwpod-node-ops`。 - `hwlab-api` 只接收 `hwpod-node-ops` plan,负责转发和返回结果,不承担业务翻译。 - `hwpod-node` 是靠近硬件的薄执行节点,只维护少量稳定 op handler,不保存完整 `hwpod-spec`,也不解释高层 hwpod intent。 ## 云端平台阶段 -快速闭环跑通后,再逐步迁移为云端平台能力: +完整服务式 registry/source-of-truth 仍按 #1043 收敛;当前已具备最小 discovery/resolve 能力: -- 将 workspace-local `hwpod-spec` 注册或同步到云端,形成云端 hwpod source of truth。 -- 将 `hwpod-ctl spec` 从本地文件管理迁移为云端 hwpod/spec 管理,同时保留本地导入、导出和调试能力。 +- `GET /v1/hwpod/specs` 可列出已知 HWPOD,并可按 `hwpodId` 查询用于 CLI/runner resolve。 +- `hwpod-ctl spec validate --hwpod-id --workspace-path ` 校验 runtime-resolved document 和四要素。 +- `hwpod-cli ... --hwpod-id --workspace-path ` 生成并提交 node-ops plan。 +- 将本地 YAML 文件管理降级为显式导入、导出和调试能力,不再作为 AgentRun runner 默认合同。 - 将 `hwpod-compiler-cli` 服务化为云端 `hwpod-compiler`,供 Web、Code Agent、batch harness 和 CI/HIL gate 共用。 - 将 `hwlab-api` 从薄转发面扩展为 hwpod run 管理面,承载 run 状态、计划版本、结果归档和后续 Evidence。 - 只在多个任务反复需要同一类基础动作时扩展 `hwpod-node-ops`,避免把业务变化下沉到 node。 @@ -48,10 +53,10 @@ hwpod-cli / hwpod-ctl | 概念 | 定义 | 当前阶段含义 | | --- | --- | --- | | `hwpod` | 硬件研发执行逻辑实体 | 由 target device、workspace、debug probe、io probe 四要素组成 | -| `hwpod-spec` | hwpod 的声明式定义 | 先存放在 Code Agent workspace 内,后续迁移到云端维护 | +| `hwpod-spec` | hwpod 的声明式定义 | 由 runtime service 解析;本地 YAML 仅用于显式 debug/import/export | | `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-ctl` | HWPOD 管理入口 | 检查 `hwpod-id`、四要素、node binding、执行 smoke 和临时维护动作 | +| `hwpod-compiler-cli` | HWPOD 编译器 | 把高层 intent + resolved document 编译为 `hwpod-node-ops` plan | | `hwpod-compiler` | 云端化后的编译服务 | 由 `hwpod-compiler-cli` 成熟后服务化而来 | | `hwpod-node-ops` | node 最小操作协议 | `hwlab-api` 和 `hwpod-node` 之间的稳定执行契约 | | `hwpod-node` | 靠近硬件的薄执行节点 | PC host、AI 网关、调试器边缘设备或实验室控制盒上的 executor | @@ -66,9 +71,11 @@ hwpod-cli / hwpod-ctl `nodeBinding` 是 `hwpod-spec` 的部署字段,不属于 hwpod 四个本体要素。 -## hwpod-spec +## HWPOD identity and document -快速迭代阶段的默认 spec 路径是 `.hwlab/hwpod-spec.yaml`。CLI 也可以通过 `--spec ` 显式指定。第一版 spec 保持轻量: +默认入口通过 `--hwpod-id ` 解析 HWPOD document。`--workspace-path ` 是 CaseRun/单步调试绑定本次 subject worktree 的运行时 override。`--spec ` 只保留为显式 debug/import/export path,不允许作为 Code Agent runner 缺少 service resolve 时的 fallback。 + +第一版 HWPOD document 保持轻量: ```yaml apiVersion: hwlab.dev/v0alpha1 @@ -126,9 +133,6 @@ Keil MDK 装配规则:当 `spec.workspace.toolchain` 为 `keil-mdk`、`keil` | `workspace.cat` | 读文件 | | `workspace.rg` | 递归搜索文件内容,标准别名还有 `grep` / `search` | | `workspace.apply-patch` | 应用 Codex apply_patch envelope 源码补丁 | -| `workspace.write` | 带 `expectedSha`、换行策略和 diff 摘要的整文件写入 | -| `workspace.replace` | 带 `expectedSha`、唯一匹配检查和 diff 摘要的精确文本替换 | -| `workspace.insert-after` | 按精确 anchor 插入文本,避免 PowerShell/cmd quoting | | `cmd.run` | 通用 host 命令透传,用于组合 Keil、serial-monitor、job status、下载、复位和临时维护动作 | `workspace.rg` 是查找符号、结构体、函数签名和错误文本的标准入口。它在 node 侧直接做受限递归文本搜索,不依赖目标 host 预装 `rg`,并支持 `--context`、`--before-context`、`--after-context`、`--max-matches`、`--max-files`、`--max-bytes-per-file` 和 `--max-line-bytes`。输出会保留结构化 `matches`、`scannedFiles`、`truncated` 和 `limits`,用于在不爆炸输出的前提下追踪上下文。需要精准读整文件时才用 `workspace.cat`;查找 ARM-2D 头文件、函数原型或 Demo 入口时,优先用 `workspace.rg` / `workspace.search`,不要再把 `cat` 管道给 shell `grep`。 @@ -137,7 +141,7 @@ Keil MDK 装配规则:当 `spec.workspace.toolchain` 为 `keil-mdk`、`keil` `hwpod-node` 是 HWPOD 的唯一受控节点执行器。第一阶段的业务扩展优先落在 `hwpod-compiler-cli` / `hwpod-cli`,通过已有文件读写 op 和 `cmd.run` 组合 Keil、serial-monitor 等成熟工具;不要因为某个 case 需要 build、job status、download、UART 或临时维护动作就新增专用 node op。`cmd.run` 的命令解析、PATH 稳定性和跨平台差异必须在 `hwpod-node` 本体内处理;当 Windows service、后台进程或 D601 host 的环境变量与交互 shell 不一致时,不得用 gateway shell、手工 PowerShell、预先手工创建 worktree 或其他旁路代替 `hwpod-node-ops -> hwpod-node`。D601/Windows 实地调试应使用 UniDesk SSH 透传 `D601:win` 观察进程、PATH、工具安装位置和日志,但修复必须回到 `hwpod-node` 源码、配置或启动入口,并用 `/v1/hwpod-node-ops` 原链路复测。只有 `cmd.run` 自身的执行语义、环境继承或可见性不满足真实 host 执行时,才修改 `hwpod-node`;否则优先改 compiler/CLI。 -Windows subject worktree 文本修改优先使用 `workspace.apply-patch`、`workspace.replace`、`workspace.insert-after` 或 `workspace.write`,不要把 `cmd.run` 的 PowerShell/cmd quoting 当成标准编辑路径。文本编辑结果必须返回编辑前后 SHA、文件字节数、换行类型、diff 摘要和 dry-run 状态;`apply-patch` 匹配失败时必须返回 normalized preview、候选行号、CRLF/LF 统计、文件 SHA/bytes 和 hwpod-node implementation version,便于判断是上下文不匹配、CRLF 问题还是远端实现版本问题。`workspace.apply-patch` 应保留原文件换行风格,尤其不能把 CRLF subject 文件静默改写成 LF。 +Windows subject worktree 文本修改优先使用 `workspace.apply-patch`,不要把 `cmd.run` 的 PowerShell/cmd quoting 当成标准编辑路径,也不要把旧 `workspace.write` / `workspace.replace` / `workspace.insert-after` 当成新 case 的推荐入口。文本编辑结果必须返回编辑前后 SHA、文件字节数、换行类型、diff 摘要和 dry-run 状态;`apply-patch` 匹配失败时必须返回 normalized preview、候选行号、CRLF/LF 统计、文件 SHA/bytes 和 hwpod-node implementation version,便于判断是上下文不匹配、CRLF 问题还是远端实现版本问题。`workspace.apply-patch` 应保留原文件换行风格,尤其不能把 CRLF subject 文件静默改写成 LF。 UART read 默认由 compiler 把 `hwpod-cli uart read` 编译为已有 `cmd.run` 内的一段短序列,调用节点本地 `serial-monitor` CLI 的 `monitor start` 和 `fetch --session-only`。两步必须在同一个 `cmd.run` 内顺序执行:`monitor start` 的 JSON 若返回 `success:false`,该 `cmd.run` 必须以非 0 退出并停止,不得继续 fetch 旧会话数据。默认绑定路径是 `C:\Users\liang\.agents\skills\serial-monitor`,也可通过 `spec.tooling.serialMonitorDir`、`spec.ioProbe.serialMonitorDir` 或 CLI 参数覆盖;`spec.ioProbe.uart.port` 和 `spec.ioProbe.uart.baudrate` 是物理串口和波特率权威。串口采集失败时必须把 serial-monitor 的 stdout/stderr、exit code 和 command argv 原样留在 `cmd.run` result 中;不得静默伪造空读数,也不得要求 Agent 改走手写 PowerShell/串口脚本旁路。 @@ -177,21 +181,19 @@ type HwpodNodeOpResult = { 快速阶段的源码入口使用 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-cli.ts workspace read --spec .hwlab/hwpod-spec.yaml projects/01_baseline/User/main.c -bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --spec .hwlab/hwpod-spec.yaml -bun tools/hwpod-cli.ts workspace replace --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c --find "old text" --replace "new text" --expected-sha -bun tools/hwpod-cli.ts workspace insert-after --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c --anchor "while (1)" --line " /* marker */" -bun tools/hwpod-cli.ts download --spec .hwlab/hwpod-spec.yaml -bun tools/hwpod-cli.ts job status --spec .hwlab/hwpod-spec.yaml -bun tools/hwpod-cli.ts uart read --spec .hwlab/hwpod-spec.yaml --port uart1 +bun tools/hwpod-ctl.ts spec validate --hwpod-id d601-f103-v2 --workspace-path +bun tools/hwpod-compiler-cli.ts compile --hwpod-id d601-f103-v2 --workspace-path --intent workspace.ls --args '{"path":"."}' +bun tools/hwpod-cli.ts inspect --hwpod-id d601-f103-v2 --workspace-path --dry-run +bun tools/hwpod-cli.ts workspace read projects/01_baseline/User/main.c --hwpod-id d601-f103-v2 --workspace-path +bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --hwpod-id d601-f103-v2 --workspace-path +cat patch.txt | bun tools/hwpod-cli.ts workspace apply-patch --hwpod-id d601-f103-v2 --workspace-path --reason "edit subject workspace through hwpod-node" +bun tools/hwpod-cli.ts download --hwpod-id d601-f103-v2 --workspace-path +bun tools/hwpod-cli.ts job status --hwpod-id d601-f103-v2 --workspace-path +bun tools/hwpod-cli.ts uart read --hwpod-id d601-f103-v2 --workspace-path --port uart1 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 链路。 +`hwpod-cli` 的正常任务路径是:按 `hwpod-id` 从 runtime service resolve HWPOD document、应用 workspace override、编译 node-ops plan、交给 `hwlab-api`,最后输出 closeout/result。`hwpod-ctl` 的管理路径用于校验 resolved document 和 node 链路;本地 YAML 修改只用于显式 debug/import/export。 ## HWPOD CLI 调试入口分层 @@ -199,7 +201,7 @@ HWPOD 调试固定保留两种入口,二者必须调用同一套 `hwpod-cli` / ### 人工单步直调 hwpod-cli -人工或维护 agent 定位 CaseRun 卡点时,优先在 G14 v0.2 workspace 直接调用 `hwpod-cli` 的单步命令。调用前必须加载 v0.2 CLI API key 和 runtime endpoint,并显式选择 run-local spec 或 case registry spec;spec 是 DAPLink、Keil target、COM 口、波特率和 subject worktree 的唯一输入权威。 +人工或维护 agent 定位 CaseRun 卡点时,优先在 G14 v0.2 workspace 直接调用 `hwpod-cli` 的单步命令。调用前必须加载 v0.2 CLI API key 和 runtime endpoint,并显式传入 `--hwpod-id` 和本次 subject worktree 的 `--workspace-path`;resolved HWPOD document 是 DAPLink、Keil target、COM 口和波特率的输入权威,workspace path 是本次运行的覆盖参数。 ```bash . /root/.config/hwlab-v02/master-server-admin-api-key.env @@ -207,34 +209,34 @@ export HWLAB_RUNTIME_API_URL=http://74.48.78.17:19667 export HWLAB_RUNTIME_WEB_URL=http://74.48.78.17:19666 export HWLAB_RUNTIME_ENDPOINT_LOCKED=1 -bun tools/hwpod-ctl.ts spec validate --spec .hwlab/hwpod-spec.yaml -bun tools/hwpod-cli.ts inspect --spec .hwlab/hwpod-spec.yaml -bun tools/hwpod-cli.ts workspace cat --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c -bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --spec .hwlab/hwpod-spec.yaml -bun tools/hwpod-cli.ts build --spec .hwlab/hwpod-spec.yaml -bun tools/hwpod-cli.ts download --spec .hwlab/hwpod-spec.yaml -bun tools/hwpod-cli.ts job status --spec .hwlab/hwpod-spec.yaml -bun tools/hwpod-cli.ts uart read --spec .hwlab/hwpod-spec.yaml --port uart1 --max-bytes 4096 +bun tools/hwpod-ctl.ts spec validate --hwpod-id d601-f103-v2 --workspace-path +bun tools/hwpod-cli.ts inspect --hwpod-id d601-f103-v2 --workspace-path +bun tools/hwpod-cli.ts workspace cat projects/01_baseline/User/main.c --hwpod-id d601-f103-v2 --workspace-path +bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --hwpod-id d601-f103-v2 --workspace-path +bun tools/hwpod-cli.ts build --hwpod-id d601-f103-v2 --workspace-path +bun tools/hwpod-cli.ts download --hwpod-id d601-f103-v2 --workspace-path +bun tools/hwpod-cli.ts job status --hwpod-id d601-f103-v2 --workspace-path +bun tools/hwpod-cli.ts uart read --hwpod-id d601-f103-v2 --workspace-path --port uart1 --max-bytes 4096 ``` 直调入口只用于单步定位和原链路复测:`build` / `download` 返回 Keil async job accepted 只能证明命令已投递,不能等同于编译或下载成功;必须继续用独立短命令 `hwpod-cli job status ` 轮询终态,不要把轮询包进 `sleep &&`、`timeout`、`watch`、`head`、pipe 或 shell loop。解释结果时结合 `uart read`、Keil result 或目标串口日志。串口被人工工具占用时,应先释放 COM 口或读取已有 serial-monitor 数据解释现象,不得把 COM 口占用误判成 DAPLink、目标板或 API 不可用。 ### CaseRun 让 code agent 调 hwpod-cli -CaseRun 验证 code agent 能力时,runner 的职责是布置舞台,而不是代替 agent 完成源码修改或硬件动作。标准流程是:CaseRun 从 case registry 读取 `case.json` 和 `hwpod-spec.yaml`,创建 subject 隔离 worktree,生成 run-local `.hwlab/hwpod-spec.yaml` 并把 `spec.workspace.path` 改写到该 worktree,再把当前 HWPOD CLI/skill 文件随 AgentRun workspace 一起提供给 code agent。 +CaseRun 验证 code agent 能力时,runner 的职责是布置舞台,而不是代替 agent 完成源码修改或硬件动作。标准流程是:CaseRun 从 case registry 读取 `case.json` 和 HWPOD 引用,创建 subject 隔离 worktree,把 `hwpodId` 与 `hwpodWorkspaceArgs` 写入 agent prompt/context,再把当前 HWPOD CLI/skill 文件随 AgentRun gitbundle workspace 一起提供给 code agent。runner 不再要求 `.hwlab/hwpod-spec.yaml` 进入 AgentRun workspace。 code agent 必须在自己的 AgentRun workspace 内调用同一套入口,例如: ```bash -hwpod-ctl spec validate --spec .hwlab/hwpod-spec.yaml -hwpod inspect --spec .hwlab/hwpod-spec.yaml -hwpod workspace cat --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c -hwpod workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --spec .hwlab/hwpod-spec.yaml -hwpod workspace replace --spec .hwlab/hwpod-spec.yaml --path projects/01_baseline/User/main.c --find --replace --expected-sha -hwpod build --spec .hwlab/hwpod-spec.yaml -hwpod download --spec .hwlab/hwpod-spec.yaml -hwpod job status --spec .hwlab/hwpod-spec.yaml -hwpod uart read --spec .hwlab/hwpod-spec.yaml --port uart1 --max-bytes 4096 +hwpod-ctl spec validate --hwpod-id d601-f103-v2 --workspace-path +hwpod inspect --hwpod-id d601-f103-v2 --workspace-path +hwpod workspace cat projects/01_baseline/User/main.c --hwpod-id d601-f103-v2 --workspace-path +hwpod workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --hwpod-id d601-f103-v2 --workspace-path +cat patch.txt | hwpod workspace apply-patch --hwpod-id d601-f103-v2 --workspace-path --reason "edit subject workspace through hwpod-node" +hwpod build --hwpod-id d601-f103-v2 --workspace-path +hwpod download --hwpod-id d601-f103-v2 --workspace-path +hwpod job status --hwpod-id d601-f103-v2 --workspace-path +hwpod uart read --hwpod-id d601-f103-v2 --workspace-path --port uart1 --max-bytes 4096 ``` 对 Code Agent 而言,Keil/HWPOD 长任务同样遵循 cli-spec 短连接组合:`build` 或 `download` 只负责返回 async job id,`job status` 必须作为独立短命令少量轮询;若仍处于 running,应报告 job id、status、diagnostics 和当前判断,不要用长 shell 等待把 agent 子阶段拖成黑盒。 @@ -243,7 +245,7 @@ CaseRun prompt 只能描述任务、边界、允许修改的文件、必须尝 ## CaseRun 无服务阶段 -第一阶段 CaseRun 不引入常驻调度服务,由 `hwlab-cli case` 组合 case registry repo、本地 `.state`、`hwpod-cli`、`hwpod-compiler-cli`、`hwlab-api` 和在线 `hwpod-node` 完成单次运行。标准 case registry repo 是 `pikasTech/hwlab-case-registry`,本地默认 checkout 路径是 `/root/hwlab-case-registry`;历史 `pikasTech/hwpod-cases` 只作为迁移前来源。case registry repo 提供 `cases//case.json` 与同目录 `hwpod-spec.yaml`;每次运行会先在 subject repo 本地 checkout 下创建隔离 Git worktree,再把 spec 复制到 `.state/hwlab-cli/caserun//.hwlab/hwpod-spec.yaml` 并把 `spec.workspace.path` 改写到该隔离 worktree,形成 run-local spec。 +第一阶段 CaseRun 不引入常驻调度服务,由 `hwlab-cli case` 组合 case registry repo、本地 `.state`、`hwpod-cli`、`hwpod-compiler-cli`、`hwlab-api` 和在线 `hwpod-node` 完成单次运行。标准 case registry repo 是 `pikasTech/hwlab-case-registry`,本地默认 checkout 路径是 `/root/hwlab-case-registry`;历史 `pikasTech/hwpod-cases` 只作为迁移前来源。case registry repo 提供 `cases//case.json` 与 HWPOD spec 或 `hwpodId` 引用;每次运行会先在 subject repo 本地 checkout 下创建隔离 Git worktree,再把 `hwpodId` 与该 worktree 路径组合成 `hwpodWorkspaceArgs`。run-local YAML 可作为审计或 runner 后置对照产物保存,但不能作为 Code Agent runner 的注入合同。 CaseRun 的 case registry repo 与 subject repo 必须分离。标准 registry repo 是 `pikasTech/hwlab-case-registry`,G14 标准 checkout 是 `/root/hwlab-case-registry`;历史 `pikasTech/hwpod-cases` 和 `/root/hwpod-cases` 只作为迁移前来源,不再作为新 CaseRun 入口或写入目标。case registry repo 只保存 `case.json`、HWPOD spec 或 spec 引用、`subject` 引用、期望结果和 `runs///` 审计产物;它不保存或替代实际固件、工程、测试样例或产品代码库。subject repo 是被开发、被测试、被编译或被下载验证的真实源码仓库,`case.json.subject` 必须明确写入 `repoLocalPath` 和 `commitId`,其中 `repoLocalPath` 是绑定 HWPOD node 上的本地 Git checkout,`commitId` 是完整 40 字符 subject commit id。`case run` 准备阶段只能从 `repoLocalPath` 执行 `git worktree add --detach `,不得 clone/fetch GitHub,也不得把 branch、tag 或当前 HEAD 作为执行依据或 fallback。若 HWPOD spec 声明 Keil MDK 工程,准备阶段还必须把同名 `.uvoptx` / `.uvopt` sidecar 从 `repoLocalPath` 同步到 isolated worktree,并在 prepare 结果里暴露 copied/missing 诊断;这些 sidecar 承载 CMSIS-DAP/UV4 探头绑定、flash sequence 和 reset/run 配置,不能被 git worktree 的 tracked-file 视角静默丢失。`subdir` 只描述 subject repo 内的 case 关注区域,不能替代 `spec.workspace.path` 的隔离 worktree。`evidence.json` 必须记录 subject repo 本地路径、subject commit、subdir 和 run-local worktree,避免把 floating ref 或外部脏 workspace 当作可审计证据。 @@ -263,7 +265,7 @@ CaseRun 的 case registry repo 与 subject repo 必须分离。标准 registry r ### CaseRun 当前实现边界 -当前 `d601-f103-v2-compile` 是 compile-only smoke,用于验证 case registry repo 与 subject repo 分离、`repoLocalPath` 加 `commitId` 的 subject provenance、隔离 subject worktree、run-local spec rewrite 和 D601 Keil 编译证据。它不创建 HWLAB Code Agent session,不调用 DS/DeepSeek/provider,不向 Code Agent 下发任务 prompt,不等待 agent trace,也不验证 agent 在源码仓库内完成修改。因此,compile-only CaseRun 是 HWPOD runner 和 Keil 编译底座 smoke,不是 Code Agent 能力评测。 +当前 `d601-f103-v2-compile` 是 compile-only smoke,用于验证 case registry repo 与 subject repo 分离、`repoLocalPath` 加 `commitId` 的 subject provenance、隔离 subject worktree、`hwpodId`/workspace binding 和 D601 Keil 编译证据。它不创建 HWLAB Code Agent session,不调用 DS/DeepSeek/provider,不向 Code Agent 下发任务 prompt,不等待 agent trace,也不验证 agent 在源码仓库内完成修改。因此,compile-only CaseRun 是 HWPOD runner 和 Keil 编译底座 smoke,不是 Code Agent 能力评测。 ### CaseRun 目标边界:布置舞台与任务 prompt @@ -279,7 +281,7 @@ CaseRun 是强化学习 Harness 的最小执行单元,但它本身不是完整 用法详见 `hwlab-caserun` skill,包含同步运行、异步 start/status/result/logs、分步 prepare/build/collect 和 provider profile 选择等完整用法。 -`case run` 在当前版本只负责编排流程和记录原始证据,不做 evidence 自动评价、自动打分或门禁判断。流程是:prepare 先在 subject repo 本地 checkout 下创建 `.worktree/caserun-` 隔离 worktree并写入 run-local spec,然后生成 Code Agent prompt/context,创建或调用 HWLAB Code Agent session,把 isolated worktree、run-local HWPOD spec、任务目标和约束交给 Code Agent;agent 阶段结束或超时后,CaseRun 记录 agent identity、terminal result 里已有的终态字段、trace lookup hint、subject worktree 的 `git status` / `git diff --stat` / `git diff --binary`,再按 case 定义执行 runner 后置 `hwpod-cli build` 和 `cmd.run` Keil 异步 job-status 查询,最后把原始证据和可读归档写入 case registry repo 的 `runs///`。固定归档包括 `evidence.json`、`summary.md`、`run.json`、`result.json`、`agent-prompt.md`、`agent-diff.patch`、run-local `.hwlab/hwpod-spec.yaml`、worker stdout/stderr、`artifact-manifest.json`,以及 `agent-messages.json`、`agent-trace.md`、`agent-transcript.md` 和 `final-response.md`。`agent-trace.md` / `agent-transcript.md` 必须包含 trace lookup hint、renderer 标识和 subject diff 区块;如果归档了完整 rendered rows,其语义必须来自共享 Web/CLI trace renderer,不能由 CaseRun 自行发明。`final-response.md` 在 terminal result 存在权威 finalResponse 时保存正文,不存在时显式记录 `finalResponse=null`、terminal/error rows 和缺失原因。下载不作为 compile-only CaseRun 的隐式步骤;后续需要下载时,应作为独立 action 显式加入 case 定义和流程记录。 +`case run` 在当前版本只负责编排流程和记录原始证据,不做 evidence 自动评价、自动打分或门禁判断。流程是:prepare 先在 subject repo 本地 checkout 下创建 `.worktree/caserun-` 隔离 worktree,解析 `hwpodId` 并生成 `hwpodWorkspaceArgs`,然后生成 Code Agent prompt/context,创建或调用 HWLAB Code Agent session,把 isolated worktree、`hwpodId`、`hwpodWorkspaceArgs`、任务目标和约束交给 Code Agent;agent 阶段结束或超时后,CaseRun 记录 agent identity、terminal result 里已有的终态字段、trace lookup hint、subject worktree 的 `git status` / `git diff --stat` / `git diff --binary`,再按 case 定义执行 runner 后置 `hwpod-cli build` 和 `cmd.run` Keil 异步 job-status 查询,最后把原始证据和可读归档写入 case registry repo 的 `runs///`。固定归档包括 `evidence.json`、`summary.md`、`run.json`、`result.json`、`agent-prompt.md`、`agent-diff.patch`、worker stdout/stderr、`artifact-manifest.json`,以及 `agent-messages.json`、`agent-trace.md`、`agent-transcript.md` 和 `final-response.md`;run-local YAML 若存在,只是审计/对照产物,不是 runner 注入合同。`agent-trace.md` / `agent-transcript.md` 必须包含 trace lookup hint、renderer 标识和 subject diff 区块;如果归档了完整 rendered rows,其语义必须来自共享 Web/CLI trace renderer,不能由 CaseRun 自行发明。`final-response.md` 在 terminal result 存在权威 finalResponse 时保存正文,不存在时显式记录 `finalResponse=null`、terminal/error rows 和缺失原因。下载不作为 compile-only CaseRun 的隐式步骤;后续需要下载时,应作为独立 action 显式加入 case 定义和流程记录。 CaseRun 下发给 HWLAB Code Agent 的请求不得再携带 ad hoc `workspaceFiles` 或 source tree 工具种子;当前工具和 skills 必须由 AgentRun `ResourceBundleRef.kind="gitbundle"` 装配,固定把 repo `tools/` 复制到 workspace `tools/`,把 repo `skills/` 复制到 workspace `.agents/skills`。这不是把 prompt 写成答案,而是保证 Code Agent 看到的 `hwpod` 能力、help、wrapper 和 `cmd.run` 组合语义与当前 `v0.2` 源码一致。若 agent 报告标准 `hwpod` 命令缺失或 help 明显过时,优先修 gitbundle runtime assembly 或 HWPOD CLI/skill 装配,不新增 hwpod-node 专用 op,也不把业务动作旁路到 shell 脚本。 @@ -317,9 +319,9 @@ hwlab-cli case aggregate d601-f103-v2-arm2d-integration \ 快速阶段最小验收: -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,也不读取其他本地旧 profile authority。 +1. `hwpod-ctl spec validate --hwpod-id --workspace-path ` 能从 runtime service resolve HWPOD document,并验证四要素和 node binding。 +2. `hwpod-compiler-cli compile` 能把同一个 `hwpod-id`、workspace override 和高层 intent 编译为稳定 `hwpod-node-ops-v1` plan。 +3. `hwpod-cli --dry-run` 输出的 plan 不读取 runner-local `.hwlab/hwpod-spec.yaml`,也不读取其他本地旧 profile authority。 4. `hwlab-api` 的 node-ops 入口能接收 plan,返回 JSON result;无可用 node 时必须返回结构化 blocker,而不是静默伪造 DEV-LIVE。 5. 真实 CLI 验收必须证明 `hwpod-cli -> hwpod-compiler-cli -> hwlab-api /v1/hwpod-node-ops -> hwpod-node` 全链路走通。 6. CaseRun 流程必须能用 `hwlab-cli case run ` 从 case registry repo 读取 case 定义,按 `subject.repoLocalPath` 和 `subject.commitId` 创建隔离 subject worktree,生成 Code Agent prompt/context,记录 agent session/trace/conversation/provider,采集 subject worktree diff,执行 compile-only smoke,并产出 isolated run state 和 case registry repo 全量产物归档;当前版本只记录流程证据和 trace manifest,不设置通过条件、不自动评价 evidence,并明确记录 downloadSkipped。 @@ -327,5 +329,5 @@ hwlab-cli case aggregate d601-f103-v2-arm2d-integration \ 8. `d601-f103-v2-compile` 的真实流程只覆盖当前编排链路和 compile-only smoke;操作员直接运行 `hwpod-cli build`、直接调用 Keil 或只检查 node 侧 job,不等同于 CaseRun 流程跑完。 9. agent-task CaseRun 的真实流程必须证明 `case run` 已创建或调用 Code Agent / AgentRun session,任务 prompt 已进入 agent 上下文,CaseRun 已采集隔离 subject worktree diff,并已继续执行编译、下载或 I/O smoke 等后续动作;当前版本只记录这些事实,不自动判断 agent 是否完成任务。 10. 已完成 run 的 registry 聚合验收必须通过 `hwlab-cli case aggregate --run-id ` 输出单个 `aggregate.md`,并证明该文件包含运行环境、HWPOD、Code Agent、Prompt、低噪声 Trace、final response 和最后 diff;Trace 中 message 直接展示,工具调用用 `已运行 ` summary 折叠。该命令不得启动新 CaseRun,也不得提交当前聚合 markdown 以外的并行 registry dirty 文件。 -11. Windows subject worktree 文本编辑必须能在 CRLF 文件中通过 `workspace.insert-after` 或 `workspace.replace` 完成一行源码修改,返回 before/after SHA 与 diff 摘要,并保留原文件 CRLF;`workspace.apply-patch` 在 context 不匹配时必须返回可诊断 payload,而不是只给 `context not found`。 +11. Windows subject worktree 文本编辑必须能在 CRLF 文件中通过 `workspace.apply-patch` 完成源码修改,返回 before/after SHA 与 diff 摘要,并保留原文件 CRLF;`workspace.apply-patch` 在 context 不匹配时必须返回可诊断 payload,而不是只给 `context not found`。 12. Keil `debug.download` 自动装配必须输出结构化 `cmd.run` argv 步骤;带空格的 `probeName` 在 plan 中必须是单独 argv 元素。`io.uart.read` 必须通过节点本地 `serial-monitor` 读取正在监控的 COM/baud;未监控或工具失败时必须返回包含 `ioProbe`/COM 口/baud/monitor status/启动建议的结构化 blocker details。 diff --git a/internal/agent/prompts/hwlab-v02-runtime.md b/internal/agent/prompts/hwlab-v02-runtime.md index ea9b00e2..3d12d564 100644 --- a/internal/agent/prompts/hwlab-v02-runtime.md +++ b/internal/agent/prompts/hwlab-v02-runtime.md @@ -6,8 +6,8 @@ Use the repo-local resource bundle as the source of runtime rules: - Required runtime files are mounted from `ResourceBundleRef.kind="gitbundle"`: repo `tools/` is copied to workspace `tools/`, and repo `skills/` is copied to workspace `.agents/skills` for this run. - The expected HWLAB skills are `hwpod-cli`, `hwpod-ctl`, and `hwlab-agent-runtime`. -- Use `hwpod` from PATH for HWPOD work. It is the standard `hwpod-cli` entry and must read `.hwlab/hwpod-spec.yaml`, call `hwpod-compiler-cli`, submit `/v1/hwpod-node-ops`, and wait for `hwpod-node` results. -- The runner workspace should include a preinstalled `.hwlab/hwpod-spec.yaml`. When the user asks which HWPOD or `hwpod-spec` is available, first query `${HWLAB_RUNTIME_API_URL}/v1/hwpod/specs?probe=1` or validate the preinstalled spec; report the discovered spec count and names before explaining concepts. +- Use `hwpod` from PATH for HWPOD work. It is the standard `hwpod-cli` entry and must resolve HWPOD definitions by `hwpod-id` through the runtime service, submit `/v1/hwpod-node-ops`, and wait for `hwpod-node` results. +- The runner workspace is not required to contain `.hwlab/hwpod-spec.yaml`. When the user asks which HWPOD or `hwpod-spec` is available, query `${HWLAB_RUNTIME_API_URL}/v1/hwpod/specs?probe=1`; report the discovered spec count and names before explaining concepts. - Use `unidesk-ssh` only for UniDesk passthrough tasks that are not covered by HWPOD APIs. Do not use fallback execution paths: @@ -29,6 +29,6 @@ MiniMax-M3 tool-call guidance: - For GitHub issue/PR reads, prefer `gh issue view --repo owner/name --json title,body,state,comments` or `gh pr view ... --json ...`; avoid plain `gh issue view` output that can be polluted by Projects Classic GraphQL warnings. - If a command fails because the command arguments were malformed, report the malformed command and retry once with a shorter single-purpose command. -When the user asks to compile or operate hardware such as `D601-F103-V2`, start from the HWPOD path: validate `.hwlab/hwpod-spec.yaml` with `hwpod-ctl`, run `hwpod inspect`, then use `hwpod build` / `hwpod download` / `hwpod reset` as needed. If HWPOD returns a named blocker, report and fix that blocker in the HWPOD path. +When the user asks to compile or operate hardware such as `D601-F103-V2`, start from the HWPOD path. If the task prompt provides `hwpodWorkspaceArgs`, reuse it exactly with `hwpod-ctl spec validate`, `hwpod inspect`, `hwpod build`, `hwpod download`, `hwpod reset`, and `hwpod job status`. Otherwise discover a `hwpod-id` through the runtime service and pass `--hwpod-id ` plus the task workspace path when one is provided. If HWPOD returns a named blocker, report and fix that blocker in the HWPOD path. -When `.hwlab/hwpod-spec.yaml` is missing, do not answer that zero HWPODs are available until `/v1/hwpod/specs?probe=1` also returns an empty list. If discovery returns a spec, initialize or restore the workspace-local spec from that preinstalled/registry source and continue through the HWPOD path. +When `.hwlab/hwpod-spec.yaml` is missing, do not initialize, copy, restore, or repair a runner-local spec. Use `/v1/hwpod/specs?probe=1` and the task `hwpod-id`/`hwpodWorkspaceArgs` instead. Only use `--spec ` when the user explicitly asks to debug or import a local YAML export. diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index ffdce70f..423825f5 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -288,7 +288,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI }); let runnerJob = null; try { - const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities, backendProfile, params }); + const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities, backendProfile }); runnerJob = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(reusable.mapping.runId)}/runner-jobs`, { method: "POST", body: runnerJobInput, @@ -386,7 +386,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI message: "AgentRun command " + commandId + " created; hwlab-cloud-api will start a runner Job explicitly without relying on scheduler automation.", runId, commandId, backendProfile, waitingFor: "agentrun-runner-job-create", valuesPrinted: false, }); - const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities, backendProfile, params }); + const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities, backendProfile }); try { runnerJob = await agentRunJson(fetchImpl, managerUrl, "/api/v1/runs/" + encodeURIComponent(runId) + "/runner-jobs", { method: "POST", body: runnerJobInput, timeoutMs }); } catch (error) { @@ -919,8 +919,8 @@ async function resolveOwnerApiKey({ params, options, now }) { if (!key) return ""; return text(key.displaySecret ?? ""); } -function buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities = null, backendProfile, params = {} }) { - const baseTransient = buildAgentRunTransientEnv(env, { providerProfile: backendProfile, parentTraceId: traceId, hwpodSpecContent: params.hwpodSpecContent }); +function buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities = null, backendProfile }) { + const baseTransient = buildAgentRunTransientEnv(env, { providerProfile: backendProfile, parentTraceId: traceId }); const hwpodAllowed = toolCapabilityAllowed(toolCapabilities, "hwpod"); const transientEnv = ownerApiKey && hwpodAllowed ? baseTransient.concat([{ name: "HWLAB_API_KEY", value: ownerApiKey, sensitive: true }]) @@ -942,7 +942,6 @@ function buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, too function buildAgentRunTransientEnv(env = process.env, options = {}) { const providerProfile = firstNonEmpty(options.providerProfile); const parentTraceId = firstNonEmpty(options.parentTraceId); - const hwpodSpecContent = hwpodSpecContentForEnv(options.hwpodSpecContent); const entries = []; for (const name of [ "HWLAB_RUNTIME_API_URL", @@ -958,18 +957,9 @@ function buildAgentRunTransientEnv(env = process.env, options = {}) { } if (providerProfile) entries.push({ name: "HWLAB_CODE_AGENT_PROVIDER_PROFILE", value: providerProfile, sensitive: false }); if (parentTraceId) entries.push({ name: "HWLAB_CODE_AGENT_PARENT_TRACE_ID", value: parentTraceId, sensitive: false }); - if (hwpodSpecContent) entries.push({ name: "HWPOD_SPEC_CONTENT", value: `base64:${Buffer.from(hwpodSpecContent, "utf8").toString("base64")}`, sensitive: false }); return entries; } -function hwpodSpecContentForEnv(value) { - const content = typeof value === "string" ? value.trim() : ""; - if (!content) return ""; - if (Buffer.byteLength(content, "utf8") > 64 * 1024) throw adapterError("hwpod_spec_too_large", "hwpodSpecContent must be 64 KiB or smaller"); - if (!/^apiVersion:/mu.test(content) || !/\nkind:\s*Hwpod\b/mu.test(content)) throw adapterError("invalid_hwpod_spec_content", "hwpodSpecContent must be a Hwpod YAML document"); - return content; -} - function agentRunToolCredentials(env = process.env, toolCapabilities = null) { const namespace = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_TOOL_SECRET_NAMESPACE, env.HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE, DEFAULT_RUNNER_NAMESPACE); const githubSecretName = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME, "agentrun-v01-tool-github-pr"); diff --git a/skills/hwpod-cli/SKILL.md b/skills/hwpod-cli/SKILL.md index 4df1e289..e542fb9f 100644 --- a/skills/hwpod-cli/SKILL.md +++ b/skills/hwpod-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: hwpod-cli -description: Use HWLAB HWPOD from a code agent runner for hardware-development tasks. The hwpod-cli skill reads workspace-local hwpod-spec, calls hwpod-compiler-cli, submits hwpod-node-ops through hwlab-api, and waits for hwpod-node results. +description: Use HWLAB HWPOD from a code agent runner for hardware-development tasks. The hwpod-cli skill resolves an HWPOD by hwpod-id through the HWLAB runtime service, submits hwpod-node-ops through hwlab-api, and waits for hwpod-node results. --- # hwpod-cli @@ -12,27 +12,29 @@ This skill follows Skill(cli-spec). It is the standard task entry for operating ```text hwpod from PATH -> tools/hwpod-cli.ts - -> tools/hwpod-compiler-cli.ts + -> runtime HWPOD registry by hwpod-id + -> compiler-backed hwpod-node-ops plan -> /v1/hwpod-node-ops on hwlab-api -> hwpod-node on the host side ``` -The HWPOD spec is code-agent-side state. The default path is `.hwlab/hwpod-spec.yaml`; pass `--spec ` only when a task explicitly uses another workspace-local spec. +The HWPOD definition is runtime service state. The standard runner contract is `--hwpod-id ` plus `--workspace-path ` when the task is bound to a run-local workspace. A task may provide the complete `hwpodWorkspaceArgs` string; reuse it exactly. `--spec ` is only an explicit debug/import path for a local exported YAML, not the default runner contract. ## Commands -- Inspect the HWPOD path: `hwpod inspect --spec .hwlab/hwpod-spec.yaml`. -- Workspace search/read/edit: use `hwpod workspace ls|cat|read|rg|grep|search|write|replace|insert-after|apply-patch --spec .hwlab/hwpod-spec.yaml`. `read` is an alias for `cat`; `grep` and `search` are aliases for `rg`; `--file` and `--remote-path` are accepted as path aliases for file-oriented commands. -- For symbol lookup or ARM-2D header hunting, prefer `hwpod workspace rg [path] --context 3 --spec .hwlab/hwpod-spec.yaml` instead of `cat | grep`. +- Inspect the HWPOD path: `hwpod inspect --hwpod-id d601-f103-v2 --workspace-path `. +- Workspace search/read/edit: use `hwpod workspace ls|cat|read|rg|grep|search|apply-patch --hwpod-id d601-f103-v2 --workspace-path `. `read` is an alias for `cat`; `grep` and `search` are aliases for `rg`; `--file` and `--remote-path` are accepted as path aliases for file-oriented commands. +- For symbol lookup or ARM-2D header hunting, prefer `hwpod workspace rg [path] --context 3 --hwpod-id d601-f103-v2 --workspace-path ` instead of `cat | grep`. - Build/download/reset: use `hwpod build`, `hwpod download`, and `hwpod reset`; the compiler must translate these through existing file ops and `cmd.run` where possible. -- Keil async status: after build/download returns a job id, use separate short `hwpod job status --spec .hwlab/hwpod-spec.yaml` calls instead of shell polling wrappers such as `sleep &&`, `timeout`, `watch`, `head`, pipes, or loops. -- UART read: use `hwpod uart read --spec .hwlab/hwpod-spec.yaml --port uart1`; the compiler runs the configured serial-monitor CLI through `cmd.run`. -- For early debug bindings, put command bindings in `.hwlab/hwpod-spec.yaml` with `spec.workspace.buildCommand`, `spec.debugProbe.downloadCommand`, or `spec.debugProbe.resetCommand`. +- Keil async status: after build/download returns a job id, use separate short `hwpod job status --hwpod-id d601-f103-v2 --workspace-path ` calls instead of shell polling wrappers such as `sleep &&`, `timeout`, `watch`, `head`, pipes, or loops. +- UART read: use `hwpod uart read --hwpod-id d601-f103-v2 --workspace-path --port uart1`; the compiler runs the configured serial-monitor CLI through `cmd.run`. ## Boundaries - Do not pass manual API URLs in locked AgentRun runtimes. Let `HWLAB_RUNTIME_API_URL` / `HWLAB_RUNTIME_NAMESPACE` select the current lane. - Do not put full `hwpod-spec` on `hwpod-node`. The node only accepts `hwpod-node-ops`. +- Do not create, copy, restore, or repair runner-local `.hwlab/hwpod-spec.yaml` as a workaround. Use `hwpod-id` through the runtime service. +- Do not use deprecated workspace mutation commands (`workspace write`, `replace`, or `insert-after`) for normal source edits. Use `workspace apply-patch`. - Treat `cmd.run` as the full-capability host execution op. Do not request new hwpod-node ops for build, download, job status, UART, PowerShell/cmd passthrough, or tool maintenance; improve `hwpod-compiler-cli` / `hwpod-cli` combinations first. - Use workspace file operations for subject text edits. Do not use PowerShell/cmd heredocs for normal source edits unless debugging `cmd.run` itself. - For multi-line edits, prefer `hwpod workspace apply-patch` with a Codex/UniDesk apply_patch v2 envelope from stdin or `--patch-content`. Put file paths in `*** Update File:` / `*** Add File:` / `*** Delete File:` headers. Do not pass raw unified diff (`---`, `+++`, `@@ -N`) as the whole patch; HWPOD deliberately rejects that format with a clear hint. diff --git a/skills/hwpod-ctl/SKILL.md b/skills/hwpod-ctl/SKILL.md index eb9e1564..98a9fa2b 100644 --- a/skills/hwpod-ctl/SKILL.md +++ b/skills/hwpod-ctl/SKILL.md @@ -1,27 +1,26 @@ --- name: hwpod-ctl -description: Manage the workspace-local HWLAB hwpod-spec and HWPOD node binding from a code agent runner. The hwpod-ctl skill is separate from hwpod-cli and is used for spec editing, validation, smoke checks, and temporary maintenance. +description: Manage and validate HWLAB HWPOD runtime bindings from a code agent runner. The hwpod-ctl skill is separate from hwpod-cli and is used for hwpod-id validation, smoke checks, and explicit local spec import/export maintenance. --- # hwpod-ctl -This skill follows Skill(cli-spec). It is the standard management entry for the workspace-local HWPOD spec and node binding. +This skill follows Skill(cli-spec). It is the standard management entry for runtime-resolved HWPOD identity and node binding checks. ## Scope -`hwpod-ctl` is a peer of `hwpod-cli`, not a fallback task path. Use it to maintain `.hwlab/hwpod-spec.yaml`, validate the four HWPOD elements, bind a node, and run smoke or temporary maintenance actions. +`hwpod-ctl` is a peer of `hwpod-cli`, not a fallback task path. Use it to validate a service-resolved `hwpod-id`, inspect the four HWPOD elements, check node binding, and run smoke or temporary maintenance actions. `hwpod-ctl` does not replace `hwpod-cli` for task execution. When a task needs source lookup, use `hwpod workspace rg/search` for symbol hunting and `hwpod workspace cat` only for full file bodies; when a task needs build/download/UART/job status, hand it back to `hwpod-cli` and its compiler-backed plan path. ## Commands -- Initialize or repair local spec: `hwpod-ctl spec init --spec .hwlab/hwpod-spec.yaml --node `. -- Validate local spec before hardware work: `hwpod-ctl spec validate --spec .hwlab/hwpod-spec.yaml`. -- Edit fields: `hwpod-ctl spec set --spec .hwlab/hwpod-spec.yaml`. -- Bind a node: `hwpod-ctl bind --spec .hwlab/hwpod-spec.yaml --node `. +- Validate runtime HWPOD before hardware work: `hwpod-ctl spec validate --hwpod-id d601-f103-v2 --workspace-path `. +- Show the resolved HWPOD document: `hwpod-ctl spec show --hwpod-id d601-f103-v2 --workspace-path `. +- For explicit debug/import only, local YAML can still be passed with `--spec `; do not use this as a runner default. ## Boundaries -- `hwpod-ctl` owns workspace-local spec management during the fast iteration stage; cloud-side HWPOD spec authority is a later stage. +- `hwpod-ctl` must not initialize or restore runner-local `.hwlab/hwpod-spec.yaml` to make a task pass. HWPOD authority is the runtime service and `hwpod-id`. - `hwpod-node` must not receive or store the full `hwpod-spec`. - Temporary maintenance commands belong here, not in normal `hwpod-cli` task flows. diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index 4cf96bbf..3e5b951b 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -473,18 +473,21 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit assert.doesNotMatch(prompt, /path: "F:\\\\Work\\\\HWLAB-CASE-F103/u); assert.doesNotMatch(prompt, /CaseRun has already placed the run-local spec/u); assert.doesNotMatch(prompt, /hwpod-ctl spec validate --spec \.hwlab\/hwpod-spec\.yaml/u); + assert.match(prompt, /hwpodId: d601-f103-v2/u); + assert.match(prompt, /hwpodWorkspaceArgs: --hwpod-id d601-f103-v2 --workspace-path 'F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-run-agent-flow'/u); + assert.match(prompt, /hwpod-ctl spec validate --hwpod-id d601-f103-v2 --workspace-path/u); + assert.match(prompt, /不要创建、复制或修补本地 spec 文件/u); assert.match(prompt, /standard hwpod\/hwpod-ctl commands/u); assert.match(prompt, /hwpod job status /u); assert.match(prompt, /不要再用 shell sleep\/&&\/timeout\/watch\/head\/pipe/u); - assert.match(prompt, /separate short `hwpod job status ` commands/u); + assert.match(prompt, /separate short `hwpod job status --hwpod-id d601-f103-v2 --workspace-path/u); const diff = await readFile(path.join(cwd, ".state", "hwlab-cli", "caserun", "run-agent-flow", "agent-diff.patch"), "utf8"); assert.match(diff, /printf\("hello"\)/u); assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/sessions"), true); assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat"), true); const agentChatRequest = requests.find((item) => item.url === "http://web.test/v1/agent/chat"); assert.equal(Object.hasOwn(agentChatRequest.body, "workspaceFiles"), false); - assert.equal(typeof agentChatRequest.body.hwpodSpecContent, "string"); - assert.match(agentChatRequest.body.hwpodSpecContent, /HWLAB-CASE-F103.*caserun-run-agent-flow/u); + assert.equal(Object.hasOwn(agentChatRequest.body, "hwpodSpecContent"), false); assert.doesNotMatch(agentChatRequest.body.message, /path: "F:\\Work\\HWLAB-CASE-F103\\\.worktree\\caserun-run-agent-flow"/u); assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat/result/" + result.payload.agent.traceId), true); assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat/trace/" + result.payload.agent.traceId), false); diff --git a/tools/hwpod-harness.test.ts b/tools/hwpod-harness.test.ts index 48c9c2fc..b5b3e565 100644 --- a/tools/hwpod-harness.test.ts +++ b/tools/hwpod-harness.test.ts @@ -45,43 +45,51 @@ test("hwpod-ctl initializes, validates, edits, and binds workspace-local hwpod-s } }); -test("hwpod cli materializes workspace spec from env", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-env-spec-")); - const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml"); - const content = [ - "apiVersion: hwlab.dev/v0alpha1", - "kind: Hwpod", - "metadata:", - " uid: D601-F103-V2", - " name: d601-f103-v2", - "spec:", - " targetDevice:", - " board: D601-F103-V2", - " mcu: STM32F103", - " workspace:", - " path: F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-env-spec", - " debugProbe:", - " type: daplink", - " ioProbe:", - " uart:", - " id: uart/1", - " nodeBinding:", - " nodeId: node-d601-f103-v2", - "" - ].join("\n"); - try { - const encoded = `base64:${Buffer.from(content, "utf8").toString("base64")}`; - const validate = await runHwpodCtl(["spec", "validate", "--spec", specPath], { now: () => NOW, env: { HWPOD_SPEC_CONTENT: encoded } }); - assert.equal(validate.exitCode, 0); - assert.equal(validate.payload.document.metadata.name, "d601-f103-v2"); - assert.match(await readFile(specPath, "utf8"), /caserun-env-spec/u); +test("hwpod cli resolves hwpod-id from runtime discovery and applies workspace override", async () => { + const runWorkspace = "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-hwpod-id"; + const requests: any[] = []; + const fetchImpl: any = async (url: string, init: any) => { + requests.push({ url, init }); + return { + status: 200, + json: async () => ({ + ok: true, + status: "completed", + specs: [{ + hwpodId: "d601-f103-v2", + authority: "preinstalled-verified-spec", + document: { + apiVersion: "hwlab.dev/v0alpha1", + kind: "Hwpod", + metadata: { uid: "D601-F103-V2", name: "d601-f103-v2" }, + spec: { + targetDevice: { board: "D601-F103-V2", mcu: "STM32F103" }, + workspace: { path: "F:\\Work\\D601-HWLAB" }, + debugProbe: { type: "daplink" }, + ioProbe: { uart: { id: "uart/1" } }, + nodeBinding: { nodeId: "node-d601-f103-v2" } + } + } + }] + }) + }; + }; - const plan = await runHwpodCli(["workspace", "ls", ".", "--spec", specPath, "--dry-run"], { now: () => NOW, env: { HWPOD_SPEC_CONTENT: encoded } }); - assert.equal(plan.exitCode, 0); - assert.equal(plan.payload.plan.resourceHints.workspacePath, "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-env-spec"); - } finally { - await rm(root, { recursive: true, force: true }); - } + const env = { HWLAB_RUNTIME_API_URL: "http://api.test" }; + const validate = await runHwpodCtl(["spec", "validate", "--hwpod-id", "d601-f103-v2", "--workspace-path", runWorkspace], { now: () => NOW, env, fetchImpl }); + assert.equal(validate.exitCode, 0); + assert.equal(validate.payload.specPath, "hwpod://d601-f103-v2"); + assert.equal(validate.payload.specAuthority, "preinstalled-verified-spec"); + assert.equal(validate.payload.document.spec.workspace.path, runWorkspace); + + const plan = await runHwpodCli(["workspace", "ls", ".", "--hwpod-id", "d601-f103-v2", "--workspace-path", runWorkspace, "--dry-run"], { now: () => NOW, env, fetchImpl }); + assert.equal(plan.exitCode, 0); + assert.equal(plan.payload.hwpodId, "d601-f103-v2"); + assert.equal(plan.payload.specPath, "hwpod://d601-f103-v2"); + assert.equal(plan.payload.plan.resourceHints.workspacePath, runWorkspace); + assert.equal(plan.payload.plan.source.specAuthority, "preinstalled-verified-spec"); + assert.equal(plan.payload.plan.ops[0].args.workspacePath, runWorkspace); + assert.match(requests[0].url, /\/v1\/hwpod\/specs\?hwpodId=d601-f103-v2/u); }); test("hwpod-compiler-cli compiles workspace-local spec into node ops", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-compiler-")); @@ -189,14 +197,14 @@ test("hwpod-cli dry-run invokes hwpod-compiler-cli and exposes hwpod-node-ops pl assert.equal(inspect.exitCode, 0); assert.equal(inspect.payload.dryRun, true); assert.equal(inspect.payload.compilerInvocation.compiler, "hwpod-compiler-cli"); - assert.equal(inspect.payload.compilerInvocation.mode, "subprocess"); + assert.equal(inspect.payload.compilerInvocation.mode, "in-process"); assert.equal(inspect.payload.compilerInvocation.action, "hwpod-compiler.compile"); assert.equal(inspect.payload.plan.source.compiler, "hwpod-compiler-cli"); 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", "--command", "printf cli-build", "--reason", "target flow smoke"], { now: () => NOW }); assert.equal(build.exitCode, 0); - assert.equal(build.payload.compilerInvocation.mode, "subprocess"); + assert.equal(build.payload.compilerInvocation.mode, "in-process"); assert.equal(build.payload.plan.ops[0].op, "debug.build"); assert.equal(build.payload.plan.ops[0].args.target, "Debug"); assert.equal(build.payload.plan.ops[0].args.command, "printf cli-build"); @@ -276,7 +284,8 @@ test("hwpod-cli exposes subcommand help before required args and accepts workspa const workspaceHelp = await runHwpodCli(["workspace", "--help"], { now: () => NOW }); assert.equal(workspaceHelp.exitCode, 0); assert.equal(workspaceHelp.payload.action, "hwpod-cli.workspace.help"); - assert.match(JSON.stringify(workspaceHelp.payload.usage), /workspace cat --file /u); + assert.match(JSON.stringify(workspaceHelp.payload.usage), /--hwpod-id d601-f103-v2/u); + assert.match(JSON.stringify(workspaceHelp.payload.usage), /--workspace-path /u); const writeHelp = await runHwpodCli(["workspace", "write", "--help"], { now: () => NOW }); assert.equal(writeHelp.exitCode, 0); @@ -324,7 +333,7 @@ test("hwpod-cli submits compiled node ops to hwlab-api when not dry-run", async }); assert.equal(result.exitCode, 0); assert.equal(result.payload.compilerInvocation.compiler, "hwpod-compiler-cli"); - assert.equal(result.payload.compilerInvocation.mode, "subprocess"); + assert.equal(result.payload.compilerInvocation.mode, "in-process"); assert.equal(seen[0].url, "http://cloud.test/v1/hwpod-node-ops"); assert.equal(seen[0].init.headers.authorization, "Bearer hwl_live_test"); assert.equal(seen[0].body.contractVersion, "hwpod-node-ops-v1"); diff --git a/tools/src/hwlab-caserun-lib.ts b/tools/src/hwlab-caserun-lib.ts index 27bb695d..9e86bf47 100644 --- a/tools/src/hwlab-caserun-lib.ts +++ b/tools/src/hwlab-caserun-lib.ts @@ -749,8 +749,7 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) { const threadId = session.threadId; const traceId = text(context.parsed.traceId) || `trc_case_${slug(run.caseId)}_${randomUUID().replace(/-/gu, "")}`; run = await updateRun(context, run, { stage: "agent-submitting", agent: agentFailureStage({ stageStatus: "session_created", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, sessionResponse: compactObject(session.body) }) }); - const hwpodSpecContent = await readFile(run.specPath, "utf8"); - const accepted = await submitAgentTask(context, { baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, prompt, hwpodSpecContent }); + const accepted = await submitAgentTask(context, { baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, prompt }); if (!accepted.ok) { const agent = agentFailureStage({ stageStatus: "submit_failed", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, accepted: accepted.body, error: accepted.error, sessionResponse: session.body }); const nextRun = await updateRun(context, run, { agent }); @@ -881,7 +880,7 @@ async function createAgentSession(context: CaseContext, input: { baseUrl: string }; } -async function submitAgentTask(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId: string; threadId: string; traceId: string; prompt: string; hwpodSpecContent?: string }) { +async function submitAgentTask(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId: string; threadId: string; traceId: string; prompt: string }) { return caseFetchJson(context, `${input.baseUrl}/v1/agent/chat`, { method: "POST", headers: { ...caseAgentHeaders(context), "x-trace-id": input.traceId, prefer: "respond-async", "x-hwlab-short-connection": "1" }, @@ -892,7 +891,6 @@ async function submitAgentTask(context: CaseContext, input: { baseUrl: string; p threadId: input.threadId, traceId: input.traceId, providerProfile: input.providerProfile, - hwpodSpecContent: input.hwpodSpecContent, shortConnection: true, projectId: input.projectId, updatedByClient: "hwlab-cli.case-run" @@ -1078,15 +1076,20 @@ async function updateRun(context: CaseContext, run: PreparedCaseRun, patch: Part } async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAgentTask) { + const hwpodDocument = await readHwpodSpec(run.specPath); + const hwpodId = text(hwpodDocument.metadata.name) || text(hwpodDocument.metadata.uid) || run.caseId; + const hwpodWorkspaceArgs = `--hwpod-id ${hwpodId} --workspace-path ${promptShellArg(run.subject.worktreePath)}`; const constraints = [ ...agentTask.constraints, "只能修改 isolated subject worktree,不得修改 case registry repo。", "不得修改原 subject repo checkout;所有源码修改必须落在 subjectWorktreePath。", "AgentRun 通过 kind=gitbundle 装配当前 v0.2 的 tools/ 与 .agents/skills;若标准 hwpod 命令能力缺失,报告 gitbundle runtime assembly 问题,不要改走旁路。", - "不要依赖 workspaceFiles、seed files、hostPath skill 目录或 ConfigMap 作为工具/skill 注入 fallback。", + "不要依赖 workspaceFiles、seed files、hostPath skill 目录、ConfigMap 或 runner 本地 .hwlab/hwpod-spec.yaml 作为工具/skill/HWPOD 注入 fallback。", + `必须通过 HWPOD registry/service 引用 hwpodId=${hwpodId};所有 hwpod/hwpod-ctl 命令都携带 ${hwpodWorkspaceArgs}。`, + "如果 case prompt 或旧帮助文本提到 .hwlab/hwpod-spec.yaml,把它视为过时写法并替换为本任务给出的 --hwpod-id/--workspace-path 参数;不要创建、复制或修补本地 spec 文件。", "若 case prompt 要求源码修改,必须只改 subjectWorktreePath;若 prompt 未要求源码修改,则保持 subject 源码不变。", "不要运行 CaseRun 答案执行器;你本人必须通过 hwpod/hwpod-ctl 标准入口触发编译验证。", - "hwpod build/download 是长任务短连接入口;不要再用 shell sleep/&&/timeout/watch/head/pipe 或 shell loop 包住它们。记录返回 JSON 里的 jobId/job_id,再用独立的 hwpod job status --spec .hwlab/hwpod-spec.yaml 短命令做有限轮询。" + `hwpod build/download 是长任务短连接入口;不要再用 shell sleep/&&/timeout/watch/head/pipe 或 shell loop 包住它们。记录返回 JSON 里的 jobId/job_id,再用独立的 hwpod job status ${hwpodWorkspaceArgs} 短命令做有限轮询。` ].filter(Boolean); return [ `# HWPOD CaseRun Code Agent Task`, @@ -1096,11 +1099,22 @@ async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAg `subjectRepoLocalPath: ${run.subject.repoLocalPath}`, `subjectCommitId: ${run.subject.commitId}`, `subjectWorktreePath: ${run.subject.worktreePath}`, + `hwpodId: ${hwpodId}`, + `hwpodWorkspaceArgs: ${hwpodWorkspaceArgs}`, `verificationMode: compile-only build check; no download or runtime smoke unless the case explicitly asks for it`, ``, `## Runtime Assembly`, `AgentRun materializes HWLAB runtime assets from ResourceBundleRef kind=gitbundle: repo subpath \`tools\` is copied to workspace \`tools\`, and repo subpath \`skills\` is copied to workspace \`.agents/skills\`. CaseRun no longer sends ad hoc workspaceFiles or seed-file payloads.`, ``, + `## HWPOD Runtime`, + `Use HWPOD by id through the runtime service. Do not require a runner-local \`.hwlab/hwpod-spec.yaml\`.`, + `Use these arguments on every hwpod/hwpod-ctl command: \`${hwpodWorkspaceArgs}\`.`, + `Standard smoke sequence for this task:`, + `- \`hwpod-ctl spec validate ${hwpodWorkspaceArgs}\``, + `- \`hwpod inspect ${hwpodWorkspaceArgs}\``, + `- workspace reads/searches/edits: \`hwpod workspace ... ${hwpodWorkspaceArgs}\``, + `- compile check: \`hwpod build ${hwpodWorkspaceArgs}\``, + ``, `## Task`, agentTask.prompt, ``, @@ -1109,8 +1123,8 @@ async function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAg ``, `## Flow`, `- Confirm standard \`hwpod\`, \`hwpod-ctl\` and \`hwpod-compiler\` commands are available from the gitbundle tools directory.`, - `- Follow the case task using standard hwpod/hwpod-ctl commands. Run build/download/UART steps only when the case explicitly asks for them, and report returned JSON/job/artifact/serial summaries.`, - `- For hwpod build/download, keep the HWPOD command unwrapped so it can return async JSON; then poll the returned job id with separate short \`hwpod job status \` commands a bounded number of times. Do not wrap status polling with shell sleep, &&, timeout, watch, head, pipes, or shell loops.`, + `- Follow the case task using standard hwpod/hwpod-ctl commands with \`${hwpodWorkspaceArgs}\`. Run build/download/UART steps only when the case explicitly asks for them, and report returned JSON/job/artifact/serial summaries.`, + `- For hwpod build/download, keep the HWPOD command unwrapped so it can return async JSON; then poll the returned job id with separate short \`hwpod job status ${hwpodWorkspaceArgs}\` commands a bounded number of times. Do not wrap status polling with shell sleep, &&, timeout, watch, head, pipes, or shell loops.`, `- CaseRun will inspect git diff under subjectWorktreePath after your turn completes and may run a runner post-check compile as separate evidence.`, `- CaseRun records trace/session/conversation, agent commandExecution, workspace diff and Keil build evidence without auto-grading them.` ].join("\n"); @@ -1367,6 +1381,10 @@ function sha256(value: string) { return createHash("sha256").update(value).digest("hex"); } +function promptShellArg(value: string) { + return `'${String(value).replace(/'/gu, `'\\''`)}'`; +} + function sha256Buffer(value: Buffer) { return createHash("sha256").update(value).digest("hex"); } diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index ee3ff240..b72f908f 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -3,13 +3,11 @@ import http from "node:http"; import https from "node:https"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "./hwpod-node-ops-contract.ts"; import { resolveRuntimeEndpoint, runtimeEndpointVisibility } from "./runtime-endpoint-resolver.ts"; export const DEFAULT_HWPOD_SPEC_PATH = ".hwlab/hwpod-spec.yaml"; -const HWPOD_SPEC_ENV_NAMES = ["HWPOD_SPEC_CONTENT"]; const COMPILER_NAME = "hwpod-compiler-cli"; const CTL_NAME = "hwpod-ctl"; @@ -57,27 +55,25 @@ async function readCliStdinForCommand(argv: string[]): Promise string } = {}) { +export async function runHwpodCompilerCli(argv: string[], options: { env?: EnvLike; fetchImpl?: FetchLike; now?: () => string } = {}) { const env = options.env ?? process.env; const now = options.now ?? (() => new Date().toISOString()); try { const parsed = parseOptions(argv); - await ensureSpecFromEnv(specPathFrom(parsed), env); 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); + const resolved = await resolveHwpodDocument({ parsed, env, fetchImpl: options.fetchImpl }); + const plan = compileHwpodNodeOpsPlan({ document: resolved.document, specPath: resolved.specPath, specAuthority: resolved.authority, intent, args, now }); + return result(0, ok("hwpod-compiler.compile", { specPath: resolved.specPath, hwpodId: resolved.hwpodId, specAuthority: resolved.authority, 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 } = {}) { +export async function runHwpodCtl(argv: string[], options: { env?: EnvLike; fetchImpl?: FetchLike; now?: () => string } = {}) { const env = options.env ?? process.env; const now = options.now ?? (() => new Date().toISOString()); try { @@ -85,15 +81,13 @@ export async function runHwpodCtl(argv: string[], options: { env?: EnvLike; now? const group = parsed._[0] || "help"; if (["help", "--help", "-h"].includes(group)) return result(0, ctlHelp(), now); if (group === "bind") { - await ensureSpecFromEnv(specPathFrom(parsed), env); 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); - await ensureSpecFromEnv(specPathFrom(parsed), env); - if (subcommand === "validate") return result(0, await validateSpec(parsed), now); - if (subcommand === "show") return result(0, await showSpec(parsed), now); + if (subcommand === "validate") return result(0, await validateSpec(parsed, env, options.fetchImpl), now); + if (subcommand === "show") return result(0, await showSpec(parsed, env, options.fetchImpl), 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}`); @@ -107,22 +101,20 @@ export async function runHwpodCli(argv: string[], options: { env?: EnvLike; fetc const now = options.now ?? (() => new Date().toISOString()); try { const parsed = parseOptions(argv); - await ensureSpecFromEnv(specPathFrom(parsed), env); const command = parsed._[0] || "help"; if (["help", "--help", "-h"].includes(command)) return result(0, cliHelp(), now); if (parsed.help === true || parsed.h === true) return result(0, hwpodCliCommandHelp(command, parsed), now); if (command === "closeout") return result(0, closeout(parsed), now); - const specPath = specPathFrom(parsed); const { intent, args } = commandToIntent(parsed, options.stdinText); - const compiled = await compilePlanWithCompilerCli({ specPath, intent, args }); + const compiled = await compilePlanWithCompilerCli({ parsed, env, fetchImpl: options.fetchImpl, intent, args }); const plan = compiled.plan; if (parsed.dryRun === true) { - return result(0, ok("hwpod-cli.plan", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, compilerInvocation: compiled.compilerInvocation, plan, dryRun: true }), now); + return result(0, ok("hwpod-cli.plan", { specPath: compiled.specPath, hwpodId: compiled.hwpodId, specAuthority: compiled.specAuthority, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, compilerInvocation: compiled.compilerInvocation, 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; - const payload = ok("hwpod-cli.invoke", { specPath, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, compilerInvocation: compiled.compilerInvocation, route: response.route, runtimeEndpoint: response.runtimeEndpoint, body: response.body, httpStatus: response.status }, response.body?.status ?? (exitCode === 0 ? "succeeded" : "failed")); + const payload = ok("hwpod-cli.invoke", { specPath: compiled.specPath, hwpodId: compiled.hwpodId, specAuthority: compiled.specAuthority, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, compilerInvocation: compiled.compilerInvocation, route: response.route, runtimeEndpoint: response.runtimeEndpoint, body: response.body, httpStatus: response.status }, response.body?.status ?? (exitCode === 0 ? "succeeded" : "failed")); if (exitCode !== 0) payload.ok = false; return result(exitCode, payload, now); } catch (error) { @@ -135,38 +127,7 @@ export async function readHwpodSpec(specPath = DEFAULT_HWPOD_SPEC_PATH) { return normalizeHwpodSpec(parseSimpleYaml(text, specPath), specPath); } -async function ensureSpecFromEnv(specPath = DEFAULT_HWPOD_SPEC_PATH, env: EnvLike = process.env) { - for (const name of HWPOD_SPEC_ENV_NAMES) { - const raw = text(env[name]); - if (!raw) continue; - try { - await readFile(specPath, "utf8"); - return { source: "existing", specPath }; - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - const content = decodeHwpodSpecEnv(raw); - await mkdir(path.dirname(specPath), { recursive: true }); - await writeFile(specPath, content.endsWith("\n") ? content : `${content}\n`, "utf8"); - return { source: name, specPath }; - } - return null; -} - -function decodeHwpodSpecEnv(value: string) { - if (/^base64:/iu.test(value)) return Buffer.from(value.replace(/^base64:/iu, ""), "base64").toString("utf8"); - if (!/[\r\n]/u.test(value) && /^[A-Za-z0-9+/=_-]+$/u.test(value)) { - try { - const decoded = Buffer.from(value, "base64").toString("utf8"); - if (/^apiVersion:/mu.test(decoded) && /\nkind:\s*Hwpod\b/mu.test(decoded)) return decoded; - } catch { - // Plain YAML without newlines is allowed to fall through unchanged. - } - } - return value; -} - -export function compileHwpodNodeOpsPlan({ document, specPath = DEFAULT_HWPOD_SPEC_PATH, intent, args = {}, now = () => new Date().toISOString() }: any) { +export function compileHwpodNodeOpsPlan({ document, specPath = DEFAULT_HWPOD_SPEC_PATH, specAuthority = "code-agent-workspace", intent, args = {}, now = () => new Date().toISOString() }: any) { const normalizedIntent = normalizeIntent(intent); const nodeId = document.spec.nodeBinding.nodeId; const hwpodId = document.metadata.name || document.metadata.uid; @@ -183,7 +144,7 @@ export function compileHwpodNodeOpsPlan({ document, specPath = DEFAULT_HWPOD_SPE source: { compiler: COMPILER_NAME, specPath, - specAuthority: "code-agent-workspace" + specAuthority }, resourceHints: { workspacePath: document.spec.workspace.path, @@ -200,10 +161,11 @@ function compilerHelp() { return ok("hwpod-compiler.help", { contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, usage: [ + "bun tools/hwpod-compiler-cli.ts compile --hwpod-id d601-f103-v2 --workspace-path --intent workspace.ls --args '{\"path\":\".\"}'", "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." + role: "Translate a runtime-resolved hwpodId plus high-level intent into hwpod-node-ops. Local --spec is an explicit debug/import path." }); } @@ -211,6 +173,7 @@ function ctlHelp() { return ok("hwpod-ctl.help", { contractVersion: "hwpod-ctl-v1", usage: [ + "bun tools/hwpod-ctl.ts spec validate --hwpod-id d601-f103-v2 --workspace-path ", "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", @@ -224,18 +187,18 @@ 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 workspace cat projects/01_baseline/User/main.c --spec .hwlab/hwpod-spec.yaml", - "bun tools/hwpod-cli.ts workspace read projects/01_baseline/User/main.c --spec .hwlab/hwpod-spec.yaml", - "bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --spec .hwlab/hwpod-spec.yaml", - "cat patch.txt | bun tools/hwpod-cli.ts workspace apply-patch --spec .hwlab/hwpod-spec.yaml --reason \"edit subject workspace through hwpod-node\"", + "bun tools/hwpod-cli.ts inspect --hwpod-id d601-f103-v2 --workspace-path --dry-run", + "bun tools/hwpod-cli.ts workspace ls . --hwpod-id d601-f103-v2 --workspace-path --dry-run", + "bun tools/hwpod-cli.ts workspace cat projects/01_baseline/User/main.c --hwpod-id d601-f103-v2 --workspace-path ", + "bun tools/hwpod-cli.ts workspace read projects/01_baseline/User/main.c --hwpod-id d601-f103-v2 --workspace-path ", + "bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --hwpod-id d601-f103-v2 --workspace-path ", + "cat patch.txt | bun tools/hwpod-cli.ts workspace apply-patch --hwpod-id d601-f103-v2 --workspace-path --reason \"edit subject workspace through hwpod-node\"", "bun tools/hwpod-cli.ts workspace replace --path projects/01_baseline/User/main.c --find \"old text\" --replace \"new text\" --expected-sha ", "bun tools/hwpod-cli.ts workspace insert-after --path projects/01_baseline/User/main.c --anchor \"while (1)\" --line \" /* marker */\"", - "bun tools/hwpod-cli.ts build --dry-run", - "bun tools/hwpod-cli.ts download --dry-run", - "bun tools/hwpod-cli.ts job status --dry-run", - "bun tools/hwpod-cli.ts uart read --port uart1 --dry-run", + "bun tools/hwpod-cli.ts build --hwpod-id d601-f103-v2 --workspace-path --dry-run", + "bun tools/hwpod-cli.ts download --hwpod-id d601-f103-v2 --workspace-path --dry-run", + "bun tools/hwpod-cli.ts job status --hwpod-id d601-f103-v2 --workspace-path --dry-run", + "bun tools/hwpod-cli.ts uart read --port uart1 --hwpod-id d601-f103-v2 --workspace-path --dry-run", "bun tools/hwpod-cli.ts --api-base-url http://74.48.78.17:19667 inspect" ], workspace: { @@ -281,19 +244,15 @@ function hwpodWorkspaceHelp(subcommand = "") { command: "workspace", subcommand: subcommand || null, usage: [ - "hwpod workspace ls [path] --spec .hwlab/hwpod-spec.yaml", - "hwpod workspace cat --spec .hwlab/hwpod-spec.yaml", - "hwpod workspace read --spec .hwlab/hwpod-spec.yaml", - "hwpod workspace cat --file --spec .hwlab/hwpod-spec.yaml", - "hwpod workspace cat --remote-path --spec .hwlab/hwpod-spec.yaml", - "hwpod workspace rg [path] --context 3 --max-matches 20 --spec .hwlab/hwpod-spec.yaml", - "hwpod workspace grep [path] --spec .hwlab/hwpod-spec.yaml", - "hwpod workspace search [path] --spec .hwlab/hwpod-spec.yaml", + "hwpod workspace ls [path] --hwpod-id d601-f103-v2 --workspace-path ", + "hwpod workspace cat --hwpod-id d601-f103-v2 --workspace-path ", + "hwpod workspace read --hwpod-id d601-f103-v2 --workspace-path ", + "hwpod workspace rg [path] --context 3 --max-matches 20 --hwpod-id d601-f103-v2 --workspace-path ", "hwpod workspace write --path --content --expected-sha ", "hwpod workspace replace --path --find --replace --expected-sha ", "hwpod workspace insert-after --path --anchor --line --expected-sha ", - "cat patch.txt | hwpod workspace apply-patch --spec .hwlab/hwpod-spec.yaml --reason ", - "hwpod workspace apply-patch --spec .hwlab/hwpod-spec.yaml --patch-content ''" + "cat patch.txt | hwpod workspace apply-patch --hwpod-id d601-f103-v2 --workspace-path --reason ", + "hwpod workspace apply-patch --hwpod-id d601-f103-v2 --workspace-path --patch-content ''" ], aliases: { read: "workspace.cat", @@ -314,7 +273,7 @@ function hwpodJobHelp(subcommand = "") { contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, command: "job", subcommand: subcommand || null, - usage: ["hwpod job status --spec .hwlab/hwpod-spec.yaml"], + usage: ["hwpod job status --hwpod-id d601-f103-v2 --workspace-path "], boundary: "Job status compiles to cmd.run against the configured Keil CLI; it is not a separate hwpod-node op." }); } @@ -322,7 +281,7 @@ function hwpodJobHelp(subcommand = "") { function hwpodBuildHelp() { return ok("hwpod-cli.build.help", { contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, - usage: ["hwpod build --spec .hwlab/hwpod-spec.yaml", "hwpod build --spec .hwlab/hwpod-spec.yaml --dry-run"], + usage: ["hwpod build --hwpod-id d601-f103-v2 --workspace-path ", "hwpod build --hwpod-id d601-f103-v2 --workspace-path --dry-run"], boundary: "Build is a short HWPOD CLI invocation that normally starts an async Keil job through cmd.run." }); } @@ -330,7 +289,7 @@ function hwpodBuildHelp() { function hwpodDownloadHelp() { return ok("hwpod-cli.download.help", { contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, - usage: ["hwpod download --spec .hwlab/hwpod-spec.yaml", "hwpod download --spec .hwlab/hwpod-spec.yaml --dry-run"], + usage: ["hwpod download --hwpod-id d601-f103-v2 --workspace-path ", "hwpod download --hwpod-id d601-f103-v2 --workspace-path --dry-run"], boundary: "Download compiles to a Keil flash cmd.run plan; do not add a download-specific hwpod-node op." }); } @@ -340,7 +299,7 @@ function hwpodUartHelp(subcommand = "") { contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, command: "uart", subcommand: subcommand || null, - usage: ["hwpod uart read --spec .hwlab/hwpod-spec.yaml --port uart1"], + usage: ["hwpod uart read --hwpod-id d601-f103-v2 --workspace-path --port uart1"], boundary: "UART read is compiled to cmd.run against the configured serial-monitor CLI." }); } @@ -348,7 +307,7 @@ function hwpodUartHelp(subcommand = "") { function hwpodCmdHelp() { return ok("hwpod-cli.cmd.help", { contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, - usage: ["hwpod cmd [...argv] --spec .hwlab/hwpod-spec.yaml"], + usage: ["hwpod cmd [...argv] --hwpod-id d601-f103-v2 --workspace-path "], boundary: "cmd.run is the full-capability host execution op; add compiler/CLI combinations before considering new hwpod-node ops." }); } @@ -369,16 +328,15 @@ async function initSpec(parsed: ParsedArgs, now: () => string) { 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 validateSpec(parsed: ParsedArgs, env: EnvLike = process.env, fetchImpl?: FetchLike) { + const resolved = await resolveHwpodDocument({ parsed, env, fetchImpl }); + const document = resolved.document; + return ok("hwpod-ctl.spec.validate", { specPath: resolved.specPath, hwpodId: resolved.hwpodId, specAuthority: resolved.authority, 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 showSpec(parsed: ParsedArgs, env: EnvLike = process.env, fetchImpl?: FetchLike) { + const resolved = await resolveHwpodDocument({ parsed, env, fetchImpl }); + return ok("hwpod-ctl.spec.show", { specPath: resolved.specPath, hwpodId: resolved.hwpodId, specAuthority: resolved.authority, document: resolved.document }); } async function setSpec(parsed: ParsedArgs, now: () => string) { @@ -414,51 +372,74 @@ function closeout(parsed: ParsedArgs) { }); } -async function compilePlanWithCompilerCli({ specPath, intent, args }: { specPath: string; intent: string; args: Record }) { - const compilerPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../hwpod-compiler-cli.ts"); - const argsJson = JSON.stringify(args ?? {}); - const command = [process.execPath, compilerPath, "compile", "--spec", specPath, "--intent", intent, "--args", argsJson]; - const proc = Bun.spawn(command, { cwd: process.cwd(), env: process.env, stdout: "pipe", stderr: "pipe" }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited - ]); - const compilerInvocation = clean({ - compiler: COMPILER_NAME, - mode: "subprocess", - entrypoint: "tools/hwpod-compiler-cli.ts", - argv: ["compile", "--spec", specPath, "--intent", intent, "--args", ""], - exitCode, - stderr: clipText(stderr) - }); - if (exitCode !== 0) { - throw cliError("hwpod_compiler_failed", "hwpod-compiler-cli failed to compile hwpod-node-ops plan", { - compilerInvocation, - stdout: clipText(stdout), - stderr: clipText(stderr) - }); - } - const payload = parseJsonObject(stdout, "compiler_stdout"); - const plan = isPlainObject(payload.plan) ? payload.plan : null; - if (payload.ok === false || !plan) { - throw cliError("hwpod_compiler_invalid_output", "hwpod-compiler-cli did not return a valid hwpod-node-ops plan", { - compilerInvocation, - payload: clean({ ok: payload.ok, action: payload.action, status: payload.status, error: payload.error }) - }); - } +async function compilePlanWithCompilerCli({ parsed, env, fetchImpl, intent, args }: { parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike; intent: string; args: Record }) { + const resolved = await resolveHwpodDocument({ parsed, env, fetchImpl }); + const plan = compileHwpodNodeOpsPlan({ document: resolved.document, specPath: resolved.specPath, specAuthority: resolved.authority, intent, args }); return { plan, + specPath: resolved.specPath, + hwpodId: resolved.hwpodId, + specAuthority: resolved.authority, compilerInvocation: clean({ - ...compilerInvocation, - action: payload.action, - contractVersion: payload.contractVersion, + compiler: COMPILER_NAME, + mode: "in-process", + entrypoint: "tools/src/hwpod-harness-lib.ts", + argv: ["compile", resolved.specPath.startsWith("hwpod://") ? "--hwpod-id" : "--spec", resolved.specPath.startsWith("hwpod://") ? resolved.hwpodId : resolved.specPath, "--intent", intent, "--args", ""], + exitCode: 0, + action: "hwpod-compiler.compile", + contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, planId: plan.planId, source: plan.source }) }; } +async function resolveHwpodDocument({ parsed, env, fetchImpl }: { parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike }) { + const explicitSpecPath = text(parsed.spec ?? parsed.specPath); + if (explicitSpecPath) { + const document = applyHwpodRuntimeOverrides(await readHwpodSpec(explicitSpecPath), parsed, env, explicitSpecPath); + return { document, specPath: explicitSpecPath, hwpodId: document.metadata.name || document.metadata.uid, authority: "code-agent-workspace" }; + } + + const hwpodId = requiredText(parsed.hwpodId ?? parsed.hwpod ?? env.HWPOD_ID ?? env.HWLAB_HWPOD_ID, "hwpodId"); + const spec = await fetchHwpodSpecById({ hwpodId, parsed, env, fetchImpl }); + const document = applyHwpodRuntimeOverrides(spec.document, parsed, env, `hwpod://${hwpodId}`); + return { + document, + specPath: `hwpod://${hwpodId}`, + hwpodId: document.metadata.name || document.metadata.uid || hwpodId, + authority: text(spec.authority) || text(spec.source?.kind) || "runtime-hwpod-registry" + }; +} + +async function fetchHwpodSpecById({ hwpodId, parsed, env, fetchImpl }: { hwpodId: string; parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike }) { + const endpoint = resolveRuntimeEndpoint({ kind: "api", parsed, env }); + const route = { method: "GET", path: `/v1/hwpod/specs?hwpodId=${encodeURIComponent(hwpodId)}` }; + const url = `${endpoint.baseUrl}${route.path}`; + const headers = authHeaders(parsed, env); + const response = fetchImpl + ? await fetchImpl(url, { method: route.method, headers }) + : await requestJsonNative(url, { method: route.method, headers, timeoutMs: numberValue(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS }); + const body = await response.json().catch(() => null); + if (!body || body.ok === false) throw cliError("hwpod_registry_request_failed", "failed to query HWPOD registry", { hwpodId, httpStatus: response.status, body: compactObject(body), runtimeEndpoint: runtimeEndpointVisibility(endpoint) }); + const specs = Array.isArray(body.specs) ? body.specs : []; + const match = specs.find((item: any) => text(item.hwpodId) === hwpodId || text(item.name) === hwpodId || text(item.uid) === hwpodId); + if (!match) throw cliError("hwpod_id_not_found", `HWPOD id not found in runtime registry: ${hwpodId}`, { hwpodId, count: specs.length, runtimeEndpoint: runtimeEndpointVisibility(endpoint) }); + const document = objectValue(match.document); + if (!isPlainObject(document)) throw cliError("hwpod_registry_document_missing", `HWPOD registry item has no document: ${hwpodId}`, { hwpodId, item: compactObject(match) }); + return { ...match, document }; +} + +function applyHwpodRuntimeOverrides(document: any, parsed: ParsedArgs, env: EnvLike, sourcePath: string) { + const copy = JSON.parse(JSON.stringify(document)); + const workspacePath = text(parsed.workspacePath ?? parsed.subjectWorktreePath ?? env.HWPOD_WORKSPACE_PATH ?? env.HWLAB_HWPOD_WORKSPACE_PATH); + if (workspacePath) { + copy.spec = objectValue(copy.spec); + copy.spec.workspace = { ...objectValue(copy.spec.workspace), path: workspacePath }; + } + return normalizeHwpodSpec(copy, sourcePath); +} + function commandToIntent(parsed: ParsedArgs, stdinText?: string) { const command = parsed._[0]; if (command === "inspect") return { intent: "inspect", args: {} }; @@ -973,6 +954,27 @@ function hwpodApiKey(parsed: ParsedArgs, env: EnvLike) { return text(env.HWLAB_API_KEY); } +function requestJsonNative(urlValue: string, { method, headers, timeoutMs }: { method: string; headers: Record; timeoutMs: number }) { + return new Promise((resolve, reject) => { + const url = new URL(urlValue); + const client = url.protocol === "https:" ? https : http; + const request = client.request(url, { method, headers, timeout: timeoutMs }, (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + response.on("end", () => { + const textValue = Buffer.concat(chunks).toString("utf8"); + resolve({ + status: response.statusCode ?? 0, + json: async () => textValue ? JSON.parse(textValue) : null + }); + }); + }); + request.on("timeout", () => request.destroy(cliError("hwpod_registry_timeout", `HWPOD registry request timed out after ${timeoutMs}ms`))); + request.on("error", reject); + request.end(); + }); +} + function postJsonNative(urlValue: string, { method, headers, body, timeoutMs }: { method: string; headers: Record; body: string; timeoutMs: number }) { return new Promise((resolve, reject) => { const url = new URL(urlValue); @@ -1223,6 +1225,16 @@ function clean>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; } +function compactObject(value: any) { + if (value === null || value === undefined) return null; + if (!isPlainObject(value) && !Array.isArray(value)) return value; + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return { value: String(value) }; + } +} + function cleanTextEditArgs>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([key, item]) => item !== undefined && item !== null && (item !== "" || ["replace", "line"].includes(key)))) as T; }