Merge pull request #765 from pikasTech/fix/v02-device-pod-cli-selector-friction-20260603

fix(device-pod-cli): add selector cheat sheet and fail-fast hints for #760
This commit is contained in:
Lyon
2026-06-03 19:54:36 +08:00
committed by GitHub
3 changed files with 202 additions and 2 deletions
+94
View File
@@ -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
+80
View File
@@ -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");
+28 -2
View File
@@ -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; }