fix: 收敛 device-pod DS 验收 CLI 摩擦

This commit is contained in:
Codex
2026-05-31 20:03:43 +08:00
parent ca714bfa27
commit 72535cb32c
6 changed files with 154 additions and 6 deletions
+20
View File
@@ -386,6 +386,26 @@ bun tools/device-pod-cli.ts D601-F103-V2:debug-probe download start \
Code-agent/DS runner 内执行同类验收时,把 `bun tools/device-pod-cli.ts` 换成 `hwpod`;只有 `hwpod` 不在 PATH 时才临时 fallback 到 `/app/skills/device-pod-cli/scripts/device-pod-cli.mjs`。UART 串口若被 Windows 侧工具占用,应把 COM busy/access denied 记录为可选串口证据缺口,Keil build/download 是否通过以 job output 中的 build summary、`Programming Done``Verify OK``Application running` 为准。
DS 或其他 code-agent runner 由 `hwlab-cli client harness submit` 触发时,控制面应保持短连接:提交后用 `client harness result``client harness trace --limit <N>``client harness audit --require-bootsharp` 轮询,不把 UniDesk `ssh/tran` 连接长期挂在一次 `wait` 上。`client harness wait` 只用于短窗口观察,CLI 会把超长等待压到低于 UniDesk 透传硬超时的安全窗口,并在 JSON 中返回后续短轮询命令。
job 观察优先使用 cloud-api job 入口,避免从 shell 管道里解析整段 JSON
```bash
hwpod job status --pod-id D601-F103-V2 <jobId> --api-base-url <url> --cookie "$COOKIE"
hwpod job output --pod-id D601-F103-V2 <jobId> --api-base-url <url> --cookie "$COOKIE"
```
`build status/output/cancel <jobId>``download status/output/cancel <jobId>` 只作为 selector 便捷别名;当 `<jobId>``job_devicepod_*` 这类 cloud-api job 时,别名必须映射到 `/jobs/<jobId>``/jobs/<jobId>/output`,不得误启动新的 build/download job。Keil host 侧子 job 仍保留 `build status <hostJobId>``download status <hostJobId>` 语义,用于查看 Windows `device-host-cli` 创建的嵌套 job。
搜索下载、烧录或串口证据时,带空格、管道符或较长正则的 `workspace rg` 应使用显式参数,避免本地 shell、selector 和 Windows host 三层参数解析产生歧义:
```bash
hwpod D601-F103-V2:workspace:/ rg \
--pattern "Programming Done|Verify OK|Application running|Error" \
--path projects/01_baseline/captures \
--api-base-url <url> --cookie "$COOKIE"
```
验收通过条件:`client device-pods status` 返回 `status=ok``blocker=null`gateway sessions 中 `gws_D601_F103` 为 online`device-pod-cli` job 进入 `completed`,且 D601 Windows gateway `/status.lastDispatch.operationId` 与该 job 的 `operationId` 一致并有 `lastResultAt`。旧 `gws_d601_win_71_freq` session 只能作为历史 stale 线索,不能作为 v0.2 F103 gateway 的目标命名。
## 测试规格
+4
View File
@@ -96,6 +96,7 @@ Run read-only workspace, debug, and I/O jobs:
```sh
hwpod device-pod-71-freq:workspace:/ ls --api-base-url <url> --session-token <token>
hwpod device-pod-71-freq:workspace:/src rg main --api-base-url <url> --session-token <token>
hwpod device-pod-71-freq:workspace:/ rg --pattern "Programming Done|Verify OK|Application running|Error" --path projects/01_baseline/captures --api-base-url <url> --session-token <token>
hwpod device-pod-71-freq:debug-probe chip-id --api-base-url <url> --session-token <token>
hwpod device-pod-71-freq:io-probe:/uart/1 read --duration-ms 5000 --api-base-url <url> --session-token <token>
```
@@ -165,6 +166,8 @@ hwpod device-pod-71-freq:workspace:/ build status <jobId> --api-base-url <url> -
hwpod device-pod-71-freq:debug-probe download output <jobId> --api-base-url <url> --session-token <token>
```
Use the canonical `hwpod job output --pod-id <devicePodId> <jobId>` form when in doubt. Selector aliases are convenience forms only for cloud-api job ids such as `job_devicepod_*`; host-side Keil subjobs still use `build status <hostJobId>` or `download status <hostJobId>` as nested device-host actions.
## Removed Legacy Path
`profile create` is removed from formal v0.2 mode. If a profile is missing or wrong, an admin must update cloud-api with the admin Device Pod APIs and then grant users. The CLI returns `legacy_profile_create_removed` instead of creating `.device-pod/<devicePodId>.json`.
@@ -176,6 +179,7 @@ Do not treat local files such as `.device-pod/device-pod-71-freq.json` or `DEVIC
- Use `hwpod`/`device-pod-cli` first for workspace, build, download, reset, UART, and debug-probe tasks.
- After selecting a pod or resuming context, run `bootsharp --pod-id <devicePodId>` before edits, build, or download.
- Prefer `workspace apply-patch` for source edits. Use `workspace put` only for intentional whole-file writes or new files, because it bypasses hunk-level conflict detection.
- For regex searches with spaces, pipes, or multiple path tokens, prefer explicit `rg --pattern <regex> --path <workspaceRel>` or `--pattern-b64 <base64>` instead of positional shell parsing.
- If apply-patch fails, read the returned `patchHint`, re-read the current file with `workspace cat` or `workspace rg`, and retry a smaller exact-context hunk before using whole-file write.
- Do not start by calling `/app/tools/hwlab-gateway-tran.mjs`, Windows-side Keil skills, serial-monitor skills, `keil-cli.py`, or generic shell when the Device Pod REST path is available.
- If the CLI returns an auth, grant, lease, executor, or gateway blocker, fix that named blocker in the cloud-api/device-pod path; do not bypass the profile with generic gateway shell.
+60
View File
@@ -275,6 +275,66 @@ test("device-pod-cli forwards host build and download job ids as named device-po
});
});
test("device-pod-cli maps selector build/download cloud job aliases to job routes", async () => {
const seen: any[] = [];
const fetchImpl = async (url, init) => {
seen.push({ url: String(url), method: init?.method });
return jsonResponse(200, { ok: true, status: "completed", output: { text: "done" } });
};
const build = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
"D601-F103-V2:workspace:/",
"build",
"status",
"job_devicepod_build"
], { fetchImpl, now: () => "2026-05-31T00:00:00.000Z" });
assert.equal(build.exitCode, 0);
assert.equal(build.payload.action, "job.status");
assert.equal(seen[0].url, "http://cloud.test/v1/device-pods/D601-F103-V2/jobs/job_devicepod_build");
const download = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
"D601-F103-V2:debug-probe",
"download",
"output",
"job_devicepod_download"
], { fetchImpl, now: () => "2026-05-31T00:00:00.000Z" });
assert.equal(download.exitCode, 0);
assert.equal(download.payload.action, "job.output");
assert.equal(seen[1].url, "http://cloud.test/v1/device-pods/D601-F103-V2/jobs/job_devicepod_download/output");
});
test("device-pod-cli accepts explicit workspace rg pattern and path options", async () => {
const seen: any[] = [];
const result = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
"D601-F103-V2:workspace:/",
"rg",
"--pattern",
"Programming Done|Verify OK|Application running|Error",
"--path",
"projects/01_baseline/captures"
], {
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_rg", status: "running" } });
},
now: () => "2026-05-31T00:00:00.000Z"
});
assert.equal(result.exitCode, 0);
assert.deepEqual(seen[0].body, {
intent: "workspace.rg",
args: {
pattern: "Programming Done|Verify OK|Application running|Error",
path: "projects/01_baseline/captures"
}
});
});
function jsonResponse(status: number, body: unknown) {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
+27
View File
@@ -393,8 +393,35 @@ test("hwlab-cli client harness submits waits and audits trace friction", async (
});
assert.equal(wait.exitCode, 0);
assert.equal(wait.payload.timedOut, false);
assert.equal(wait.payload.timeoutMsRequested, 1000);
assert.equal(wait.payload.timeoutMsEffective, 1000);
assert.equal(wait.payload.waitPolicy.remotePassthroughSafe, true);
assert.equal(wait.payload.body.assistantText, "done");
const cappedWait = await runHwlabCli([
"client",
"harness",
"wait",
"trc_harness",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a",
"--poll-interval-ms",
"1000",
"--timeout-ms",
"60000"
], {
fetchImpl: async () => new Response(JSON.stringify({ status: "completed", traceId: "trc_harness", reply: { content: "done" } }), { status: 200 }),
sleep: async () => {}
});
assert.equal(cappedWait.exitCode, 0);
assert.equal(cappedWait.payload.status, "succeeded");
assert.equal(cappedWait.payload.timeoutMsRequested, 60000);
assert.equal(cappedWait.payload.timeoutMsEffective, 50000);
assert.equal(cappedWait.payload.waitPolicy.capped, true);
assert.ok(cappedWait.payload.waitPolicy.nextCommands.some((command) => command.includes("harness result trc_harness")));
const audit = await runHwlabCli([
"client",
"harness",
+18 -3
View File
@@ -238,8 +238,8 @@ async function buildJob(selector: any, operation: string, rest: string[], parsed
}
else if (operation === "rg") {
intent = "workspace.rg";
args.pattern = requiredText(rest[1], "pattern");
args.path = joinPath(basePath, rest[2] || "");
args.pattern = workspaceRgPattern(parsed, rest[1]);
args.path = joinPath(basePath, text(parsed.path) || rest[2] || "");
Object.assign(args, passthroughOptions(parsed, [
"glob",
"g",
@@ -347,9 +347,24 @@ async function buildJob(selector: any, operation: string, rest: string[], parsed
function jobRouteFromSelector(selector: any, operation: string, rest: string[], parsed: ParsedArgs) {
if (selector.surface === "workspace" && (operation === "status" || operation === "output" || operation === "cancel")) return [operation, requiredText(rest[1] ?? parsed.jobId, "jobId")];
if (selector.surface === "workspace" && operation === "build" && isSelectorCloudJobAlias(rest[1], rest[2] ?? parsed.jobId)) return [String(rest[1]), requiredText(rest[2] ?? parsed.jobId, "jobId")];
if (selector.surface === "debug-probe" && operation === "download" && isSelectorCloudJobAlias(rest[1], rest[2] ?? parsed.jobId)) return [String(rest[1]), requiredText(rest[2] ?? parsed.jobId, "jobId")];
return null;
}
function isSelectorCloudJobAlias(subcommand: unknown, candidateJobId: unknown) {
const subcommandText = String(subcommand ?? "");
if (!["status", "output", "cancel"].includes(subcommandText)) return false;
if (subcommandText === "output" || subcommandText === "cancel") return true;
return /^job(?:_|-)/u.test(text(candidateJobId));
}
function workspaceRgPattern(parsed: ParsedArgs, positional: unknown) {
const encoded = text(parsed.patternB64);
if (encoded) return Buffer.from(encoded, "base64").toString("utf8");
return requiredText(parsed.pattern ?? parsed.query ?? positional, "pattern");
}
async function requestJson({ parsed, env, fetchImpl, method, path, body, auth = true, extraHeaders = {} }: any) {
const baseUrl = apiBaseUrl(parsed, env);
const controller = new AbortController();
@@ -506,7 +521,7 @@ function parseJsonObjectOption(value: string, field: string) {
}
function normalizePath(value: string) { return String(value || ".").trim().replace(/\\/gu, "/").replace(/\s*\/\s*/gu, "/").replace(/\/+/gu, "/").replace(/^\/+|\/+$/gu, "") || "."; }
function normalizeProbePath(value: string) { const normalized = normalizePath(value); if (normalized === ".") return "uart/1"; if (normalized === "uart") throw cliError("invalid_io_probe_path", "io-probe path must be concrete, for example /uart/1"); return normalized; }
function joinPath(base: string, child: string) { const left = normalizePath(base); const right = normalizePath(child || "."); if (!right || right === ".") return left; if (child.startsWith("/")) return right; return normalizePath(`${left}/${right}`); }
function joinPath(base: string, child: string) { const left = normalizePath(base); const right = normalizePath(child || "."); if (!right || right === ".") return left; if (child.startsWith("/")) return right; if (left === ".") return right; return normalizePath(`${left}/${right}`); }
async function patchText(parsed: ParsedArgs, stdinText?: string) { if (typeof parsed.patch === "string") return parsed.patch; if (typeof parsed.patchB64 === "string") return Buffer.from(parsed.patchB64, "base64").toString("utf8"); if (stdinText !== undefined) return stdinText; const chunks = []; for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); const textValue = Buffer.concat(chunks).toString("utf8"); if (!textValue.trim()) throw cliError("patch_text_required", "workspace apply-patch requires patch text on stdin or --patch/--patch-b64"); return textValue; }
async function contentPayload(parsed: ParsedArgs, stdinText?: string) {
if (typeof parsed.contentB64 === "string") return { contentB64: parsed.contentB64 };
+25 -3
View File
@@ -7,6 +7,8 @@ const DEFAULT_BASE_URL = "http://74.48.78.17:19666";
const DEFAULT_TIMEOUT_MS = 30000;
const DEFAULT_AGENT_TIMEOUT_MS = 120000;
const DEFAULT_POLL_INTERVAL_MS = 1000;
const HARNESS_WAIT_DEFAULT_TIMEOUT_MS = 50000;
const HARNESS_WAIT_MAX_TIMEOUT_MS = 50000;
type EnvLike = Record<string, string | undefined>;
type ParsedArgs = Record<string, unknown> & { _: string[] };
@@ -90,7 +92,7 @@ function help() {
"hwlab-cli client agent inspect --trace-id TRACE_ID",
"hwlab-cli client agent cancel TRACE_ID",
"hwlab-cli client harness submit --message TEXT --provider-profile deepseek",
"hwlab-cli client harness wait TRACE_ID --timeout-ms 55000",
"hwlab-cli client harness wait TRACE_ID --timeout-ms 50000",
"hwlab-cli client harness audit TRACE_ID --require-bootsharp"
]
});
@@ -430,8 +432,10 @@ async function harnessTrace(context: any) {
async function harnessWait(context: any) {
const traceId = requiredTraceId(context.rest[0] ?? context.parsed.traceId);
const timeoutMs = Math.min(numberOption(context.parsed.timeoutMs) ?? 55000, 60000);
const requestedTimeoutMs = numberOption(context.parsed.timeoutMs) ?? HARNESS_WAIT_DEFAULT_TIMEOUT_MS;
const timeoutMs = Math.min(requestedTimeoutMs, HARNESS_WAIT_MAX_TIMEOUT_MS);
const pollIntervalMs = Math.max(numberOption(context.parsed.pollIntervalMs) ?? 1000, 250);
const waitPolicy = harnessWaitPolicy(traceId, requestedTimeoutMs, timeoutMs);
const deadline = Date.now() + timeoutMs;
const pathName = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
let polls = 0;
@@ -450,6 +454,9 @@ async function harnessWait(context: any) {
traceId,
polls,
timedOut: false,
timeoutMsRequested: requestedTimeoutMs,
timeoutMsEffective: timeoutMs,
waitPolicy,
body: responseBodyForCli(lastResponse.body, context.parsed)
});
}
@@ -461,11 +468,26 @@ async function harnessWait(context: any) {
route: route("GET", pathName),
polls,
timedOut: true,
timeoutMs,
timeoutMsRequested: requestedTimeoutMs,
timeoutMsEffective: timeoutMs,
waitPolicy,
body: lastResponse ? responseBodyForCli(lastResponse.body, context.parsed) : null
}, "timeout");
}
function harnessWaitPolicy(traceId: string, requestedTimeoutMs: number, timeoutMs: number) {
return {
remotePassthroughSafe: true,
capped: requestedTimeoutMs > timeoutMs,
reason: "UniDesk ssh/tran has a 60s top-level runtime timeout; keep harness wait below that and continue with short polling.",
nextCommands: [
`hwlab-cli client harness result ${traceId}`,
`hwlab-cli client harness trace ${traceId} --limit 24`,
`hwlab-cli client harness audit ${traceId} --require-bootsharp`
]
};
}
async function harnessAudit(context: any) {
let traceObject: any;
if (typeof context.parsed.traceFile === "string") {