fix(device-pod-cli): add selector cheat sheet and fail-fast hints
Address the friction in #760 where Code Agent picked <pod>:workspace:/ keil download/flash and <pod>:debug-probe flash for firmware flashing, all of which are not SOP. Add to SKILL.md: - Selector Cheat Sheet: explicit per-surface allowed operations table - Common selector mistakes table: wrong selector -> error you would hit -> correct selector - Pitfalls Observed in Real Runs: --reason required for build/download even on read-only sub-actions, cloud-api job output is empty for build/download, workspace rg --pattern shell-pipe collision In tools/src/device-pod-cli-lib.ts: - workspace.keil now fail-fasts on any action that is not add-source or remove-source, with code unsupported_workspace_keil_action and a selector hint pointing to debug-probe download start - debug-probe flash now has a dedicated error code path with the same selector hint instead of the generic unsupported_debug_operation - catch-all debug-probe error message now includes the allowed list - help() payload exposes a selectorCheatSheet array so hwpod --help callers see the SOP table without reading the SKILL.md file Tests in tools/device-pod-cli.test.ts: 6 new cases lock in the new error codes, the selector cheat sheet, and that add-source still dispatches correctly. 26/26 pass.
This commit is contained in:
committed by
Codex
parent
0d06a4c5fe
commit
5c35f8349e
@@ -37,6 +37,100 @@ HWLAB_DEVICE_POD_SESSION_TOKEN=<assembled-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 `<devicePodId>:<surface>:<path> <operation> [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 `<relpath>` | `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` | `:<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/<n>` | `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 |
|
||||
| --- | --- | --- |
|
||||
| `<pod>:workspace:/ keil download` | `unsupported workspace keil command: download` (host-side, after dispatch) | `<pod>:debug-probe download start --reason "..."` |
|
||||
| `<pod>:workspace:/ keil flash` | `unsupported workspace keil command: flash` | `<pod>:debug-probe download start --reason "..."` |
|
||||
| `<pod>:debug-probe flash` | `unsupported_debug_operation: flash` (TS-side, fail-fast) | `<pod>:debug-probe download start --reason "..."` |
|
||||
| `<pod>:workspace:/ build` | `workspace.build` job runs but with empty `action`; always pass `start` | `<pod>: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 "<short text>"`
|
||||
for these selectors, including read-only `build status` and
|
||||
`download output`:
|
||||
|
||||
```sh
|
||||
# ok
|
||||
hwpod <pod>:workspace:/ build status <jobId> --reason "poll previous build"
|
||||
hwpod <pod>:debug-probe download output <jobId> --reason "poll previous download"
|
||||
|
||||
# fails with device_job_reason_required
|
||||
hwpod <pod>:workspace:/ build status <jobId>
|
||||
```
|
||||
|
||||
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 <pod> <jobId>` returns `status=completed`
|
||||
2. `hwpod <pod>: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_<ts>.log` / `download_FREQ_Controller_FW_<ts>.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 <pod>:workspace:/ rg --pattern "Programming Done|Verify OK" --path <relpath>
|
||||
|
||||
# bullet-proof: pattern as base64
|
||||
P=$(printf "%s" "Error\(s\)|Warning\(s\)|Erase Done|Programming Done|Verify OK|Application running" | base64 -w0)
|
||||
hwpod <pod>:workspace:/ rg --pattern-b64 "$P" --path <relpath> --reason "log scan"
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Primary Commands
|
||||
|
||||
|
||||
@@ -533,6 +533,86 @@ function close(server: ReturnType<typeof createServer>) {
|
||||
});
|
||||
}
|
||||
|
||||
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<typeof createServer>) {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("server address is unavailable");
|
||||
|
||||
@@ -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 <jobId>"
|
||||
],
|
||||
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/<n>)",
|
||||
"common mistakes: workspace keil download/flash and debug-probe flash are not SOP; use <pod>: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 "<devicePodId>:debug-probe download start"; see SKILL.md Selector Cheat Sheet.`,
|
||||
{ gotAction: keilAction, allowed: ["add-source", "remove-source"], hint: "<devicePodId>:debug-probe download start --reason <text>" }
|
||||
);
|
||||
}
|
||||
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 "<devicePodId>: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: "<devicePodId>:debug-probe download start --reason <text>" }
|
||||
);
|
||||
}
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user