diff --git a/skills/device-pod-cli/SKILL.md b/skills/device-pod-cli/SKILL.md index 79684c8a..f8e50003 100644 --- a/skills/device-pod-cli/SKILL.md +++ b/skills/device-pod-cli/SKILL.md @@ -37,6 +37,100 @@ HWLAB_DEVICE_POD_SESSION_TOKEN= `hwpod` automatically locates cloud-api from the assembled runtime env and automatically uses `HWLAB_DEVICE_POD_SESSION_TOKEN`. When `HWLAB_RUNTIME_ENDPOINT_LOCKED=1`, passing `--api-base-url`, `--api-url`, `--cloud-api-url`, or `--base-url` is a hard error. This prevents stale trace examples or model guesses from overriding the current cluster/lane. `--api-base-url` and `--session-token` are local-debug-only options outside locked HWLAB runners. Do not use them in normal DS / AgentRun / Cloud Workbench flows. +## Selector Cheat Sheet + +The selector is `:: [args...]`. The CLI +dispatches to a fixed set of operations per surface. Anything outside this +table is rejected either fail-fast in the TS CLI (`unsupported_*` code) or by +the host tool after dispatch. Always pick from this table; if a need is not +listed, that is a missing intent - file an issue instead of guessing. + +| Surface | Selector path | Allowed operations (intent) | Notes | +| --- | --- | --- | --- | +| `workspace` | `/` or `` | `ls`, `cat`, `rg`, `bootsharp`, `apply-patch`, `put`, `rm`, `rmdir`, `keil add-source`, `keil remove-source`, `build start\|status\|output\|cancel\|wait` | `keil` is for **project-file maintenance only**. Compile/link goes through `build start`, not through `keil`. | +| `debug-probe` | `:` (default `/`) | `status`, `chip-id`, `download start\|status\|output\|cancel\|wait`, `reset` | Firmware flash is `download`, not `flash`. There is no `debug-probe flash` op. | +| `io-probe` | `:/uart/` | `read`, `write`, `jsonrpc`, `read-after-launch-flash`, `ports` | UART id is a strict path token (`/uart/1`); do not split it across argv. | + +### Common selector mistakes (do not retry - pick the right one) + +| Wrong selector | Error you would hit | Correct selector | +| --- | --- | --- | +| `:workspace:/ keil download` | `unsupported workspace keil command: download` (host-side, after dispatch) | `:debug-probe download start --reason "..."` | +| `:workspace:/ keil flash` | `unsupported workspace keil command: flash` | `:debug-probe download start --reason "..."` | +| `:debug-probe flash` | `unsupported_debug_operation: flash` (TS-side, fail-fast) | `:debug-probe download start --reason "..."` | +| `:workspace:/ build` | `workspace.build` job runs but with empty `action`; always pass `start` | `:workspace:/ build start --reason "..."` | + +If a CLI call returns an `unsupported_*` error, do not retry the same +selector - re-check the table and switch to the right one. When the right +selector still does not exist, file an issue against `pikasTech/HWLAB` with +the selector name and the underlying intent you wanted. +## Pitfalls Observed in Real Runs + +These are concrete friction points seen in production runs against +`D601-71-FREQ` and `D601-F103-V2`. They are not bugs in the SOP but are +worth knowing before you start a long build+download job. + +### 1. `--reason` is required for mutating `workspace.build` / `debug-probe.download` + +`cloud-api` enforces `device_job_reason_required` on any job whose intent +is `workspace.build` or `debug-probe.download`, even when the sub-action +is `status` / `wait` / `output` / `cancel` and even when the job id points +to an already-completed host sub-job. Always pass `--reason ""` +for these selectors, including read-only `build status` and +`download output`: + +```sh +# ok +hwpod :workspace:/ build status --reason "poll previous build" +hwpod :debug-probe download output --reason "poll previous download" + +# fails with device_job_reason_required +hwpod :workspace:/ build status +``` + +If you do not have a meaningful business reason yet, use a placeholder +like `--reason "v0.2 smoke"` and surface that in the trace. + +### 2. `job output` for build/download is empty; host evidence is on the Windows host + +For `intent=workspace.build` and `intent=debug.download` jobs, cloud-api's +`body.output.text` is currently `""` and `body.bytes == 0`. The +`Programming Done` / `Verify OK` / `Application running` / `0 Error(s)` / +`0 Warning(s)` evidence is on the D601 host in +`F:\\Work\\ConStart\\.device-pod\\.state\\device-host-cli\\keil\\`. +Do not loop on `workspace rg` looking for those strings - that selector +sees the project source tree, not the host job log directory. + +Until cloud-api propagates host `logTail` / `buildSummary` to +`body.output.text` (tracked separately), the only SOP-compatible +verification path is: + +1. `hwpod job status --pod-id ` returns `status=completed` +2. `hwpod :workspace:/ bootsharp` re-runs and shows the workspace is + unchanged (host job store keeps the latest log file) +3. SSH into D601 directly (out of band) and read the matching + `build_FREQ_Controller_FW_.log` / `download_FREQ_Controller_FW_.log` + under `.device-pod\\.state\\device-host-cli\\keil\\` + +If you need the evidence in-band, file a follow-up issue against +`pikasTech/HWLAB` requesting a `download evidence` selector that returns +host `logTail` + `buildSummary` directly through the formal REST job path. + +### 3. `workspace rg --pattern "regex|pipes"` collides with local shell + +When the regex contains `|` or other shell metacharacters, the local shell +interprets them before `hwpod` sees the pattern. Two safe forms: + +```sh +# shell-safe: pattern as one arg, but | still needs quoting +hwpod :workspace:/ rg --pattern "Programming Done|Verify OK" --path + +# bullet-proof: pattern as base64 +P=$(printf "%s" "Error\(s\)|Warning\(s\)|Erase Done|Programming Done|Verify OK|Application running" | base64 -w0) +hwpod :workspace:/ rg --pattern-b64 "$P" --path --reason "log scan" +``` + + ## Primary Commands diff --git a/tools/device-pod-cli.test.ts b/tools/device-pod-cli.test.ts index a9bfcd86..c187b550 100644 --- a/tools/device-pod-cli.test.ts +++ b/tools/device-pod-cli.test.ts @@ -533,6 +533,86 @@ function close(server: ReturnType) { }); } +test("device-pod-cli fails fast on debug-probe flash with selector hint", async () => { + const result = await runAssembledDevicePodCli([ + "device-pod-71-freq:debug-probe", "flash" + ], { fetchImpl: async () => { throw new Error("should not reach cloud-api"); } }); + assert.equal(result.exitCode, 1); + const payload = result.payload; + assert.equal(payload.ok, false); + assert.equal(payload.error.code, "unsupported_debug_operation"); + assert.match(payload.error.message, /flash/); + assert.match(payload.error.message, /debug-probe download start/); + assert.match(payload.error.message, /Selector Cheat Sheet/); + assert.deepEqual(payload.error.details.allowed, ["status", "chip-id", "download", "reset"]); + assert.match(payload.error.details.hint, /debug-probe download start/); +}); + +test("device-pod-cli fails fast on workspace.keil download with selector hint", async () => { + const result = await runAssembledDevicePodCli([ + "device-pod-71-freq:workspace:/", "keil", "download", "FREQ_Controller_FW.hex" + ], { fetchImpl: async () => { throw new Error("should not reach cloud-api"); } }); + assert.equal(result.exitCode, 1); + const payload = result.payload; + assert.equal(payload.ok, false); + assert.equal(payload.error.code, "unsupported_workspace_keil_action"); + assert.match(payload.error.message, /download/); + assert.match(payload.error.message, /add-source/); + assert.match(payload.error.message, /remove-source/); + assert.match(payload.error.message, /debug-probe download start/); + assert.deepEqual(payload.error.details.allowed, ["add-source", "remove-source"]); +}); + +test("device-pod-cli fails fast on workspace.keil flash with selector hint", async () => { + const result = await runAssembledDevicePodCli([ + "device-pod-71-freq:workspace:/", "keil", "flash" + ], { fetchImpl: async () => { throw new Error("should not reach cloud-api"); } }); + assert.equal(result.exitCode, 1); + const payload = result.payload; + assert.equal(payload.ok, false); + assert.equal(payload.error.code, "unsupported_workspace_keil_action"); + assert.match(payload.error.message, /flash/); +}); + +test("device-pod-cli still accepts workspace.keil add-source and remove-source", async () => { + const seen: any[] = []; + const result = await runAssembledDevicePodCli([ + "device-pod-71-freq:workspace:/", "keil", "add-source", "User/new.c", "--group", "User", "--reason", "DEV smoke" + ], { fetchImpl: async (url, init) => { + seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) }); + return jsonResponse(202, { ok: true, status: "running", job: { id: "job_keil_add", status: "running" } }); + } }); + assert.equal(result.exitCode, 0); + assert.equal(seen[0].body.intent, "workspace.keil"); + assert.equal(seen[0].body.args.action, "add-source"); + assert.equal(seen[0].body.args.path, "User/new.c"); +}); + +test("device-pod-cli rejects unknown debug-probe operation with allowed list", async () => { + const result = await runAssembledDevicePodCli([ + "device-pod-71-freq:debug-probe", "wiggle" + ], { fetchImpl: async () => { throw new Error("should not reach cloud-api"); } }); + assert.equal(result.exitCode, 1); + const payload = result.payload; + assert.equal(payload.error.code, "unsupported_debug_operation"); + assert.match(payload.error.message, /wiggle/); + assert.match(payload.error.message, /status, chip-id, download, reset/); + assert.deepEqual(payload.error.details.allowed, ["status", "chip-id", "download", "reset"]); +}); + +test("device-pod-cli help surfaces selectorCheatSheet with SOP table and common-mistake list", async () => { + const result = await runAssembledDevicePodCli(["--help"], { fetchImpl: async () => { throw new Error("help should not hit network"); } }); + assert.equal(result.exitCode, 0); + const payload = result.payload; + assert.ok(Array.isArray(payload.selectorCheatSheet), "selectorCheatSheet array should be present"); + const joined = payload.selectorCheatSheet.join("\n"); + assert.match(joined, /workspace: ls \| cat/); + assert.match(joined, /debug-probe: status \| chip-id \| download/); + assert.match(joined, /io-probe: ports/); + assert.match(joined, /no flash op/); + assert.match(joined, /Selector Cheat Sheet/); +}); + function serverUrl(server: ReturnType) { const address = server.address(); if (!address || typeof address === "string") throw new Error("server address is unavailable"); diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts index 1c03897c..b20e05a6 100644 --- a/tools/src/device-pod-cli-lib.ts +++ b/tools/src/device-pod-cli-lib.ts @@ -75,7 +75,14 @@ function help() { "hwpod device-pod-71-freq:io-probe:/uart/1 jsonrpc gpio.read --params-json '{\"pin\":\"PB5\"}' --reason TEXT", "hwpod job output --pod-id device-pod-71-freq " ], - startupProbe: "Run bootsharp first after selecting a Device Pod or resuming context; it returns workspace tree and AGENTS.md hints through the formal REST job path." + startupProbe: "Run bootsharp first after selecting a Device Pod or resuming context; it returns workspace tree and AGENTS.md hints through the formal REST job path.", + selectorCheatSheet: [ + "workspace: ls | cat | rg | bootsharp | apply-patch | put | rm | rmdir | keil {add-source,remove-source} | build {start,status,output,cancel,wait}", + "debug-probe: status | chip-id | download {start,status,output,cancel,wait} | reset (no flash op; firmware flash = download)", + "io-probe: ports | read | write | jsonrpc | read-after-launch-flash (path = /uart/)", + "common mistakes: workspace keil download/flash and debug-probe flash are not SOP; use :debug-probe download start", + "full table: skills/device-pod-cli/SKILL.md Selector Cheat Sheet" + ] }); } @@ -265,6 +272,14 @@ async function buildJob(selector: any, operation: string, rest: string[], parsed args.path = joinPath(basePath, requiredText(rest[1] ?? parsed.path, "path")); } else if (operation === "keil") { + const keilAction = String(rest[1] ?? "").trim(); + if (keilAction && !["add-source", "remove-source"].includes(keilAction)) { + throw cliError( + "unsupported_workspace_keil_action", + `unsupported workspace.keil action: ${keilAction}. workspace.keil only supports add-source and remove-source (Keil project-file maintenance). Firmware flashing goes through ":debug-probe download start"; see SKILL.md Selector Cheat Sheet.`, + { gotAction: keilAction, allowed: ["add-source", "remove-source"], hint: ":debug-probe download start --reason " } + ); + } intent = "workspace.keil"; args.action = requiredText(rest[1], "keil action"); args.base = basePath; @@ -286,6 +301,13 @@ async function buildJob(selector: any, operation: string, rest: string[], parsed else throw cliError("unsupported_workspace_operation", `unsupported workspace operation: ${operation}`); } else if (selector.surface === "debug-probe") { if (operation === "chip-id") intent = "debug.chip-id"; + else if (operation === "flash") { + throw cliError( + "unsupported_debug_operation", + `unsupported debug-probe operation: flash. The correct selector for firmware flashing is ":debug-probe download start". Allowed debug-probe operations: status, chip-id, download, reset; see SKILL.md Selector Cheat Sheet.`, + { gotOperation: operation, allowed: ["status", "chip-id", "download", "reset"], hint: ":debug-probe download start --reason " } + ); + } else if (operation === "download") { intent = "debug.download"; args.action = rest[1] || "start"; @@ -301,7 +323,11 @@ async function buildJob(selector: any, operation: string, rest: string[], parsed ])); } else if (operation === "reset") intent = "debug.reset"; - else throw cliError("unsupported_debug_operation", `unsupported debug-probe operation: ${operation}`); + else throw cliError( + "unsupported_debug_operation", + `unsupported debug-probe operation: ${operation}. Allowed debug-probe operations: status, chip-id, download, reset; see SKILL.md Selector Cheat Sheet.`, + { gotOperation: operation, allowed: ["status", "chip-id", "download", "reset"] } + ); } else if (selector.surface === "io-probe") { const uartId = normalizeProbePath(selector.path || "uart/1"); if (operation === "ports") { intent = "io.ports"; args.uartId = uartId; }