From 72535cb32cdf979deb979c8424513685f8cdd431 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 31 May 2026 20:03:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20device-pod=20DS=20?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=20CLI=20=E6=91=A9=E6=93=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/spec-device-pod.md | 20 +++++++++++ skills/device-pod-cli/SKILL.md | 4 +++ tools/device-pod-cli.test.ts | 60 +++++++++++++++++++++++++++++++ tools/hwlab-cli/client.test.ts | 27 ++++++++++++++ tools/src/device-pod-cli-lib.ts | 21 +++++++++-- tools/src/hwlab-cli-lib.ts | 28 +++++++++++++-- 6 files changed, 154 insertions(+), 6 deletions(-) diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index 135189b8..53bbd42b 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.md @@ -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 ` 和 `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 --api-base-url --cookie "$COOKIE" +hwpod job output --pod-id D601-F103-V2 --api-base-url --cookie "$COOKIE" +``` + +`build status/output/cancel ` 和 `download status/output/cancel ` 只作为 selector 便捷别名;当 `` 是 `job_devicepod_*` 这类 cloud-api job 时,别名必须映射到 `/jobs/` 或 `/jobs//output`,不得误启动新的 build/download job。Keil host 侧子 job 仍保留 `build status ` 或 `download status ` 语义,用于查看 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 --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 的目标命名。 ## 测试规格 diff --git a/skills/device-pod-cli/SKILL.md b/skills/device-pod-cli/SKILL.md index 071cb053..e2756cbd 100644 --- a/skills/device-pod-cli/SKILL.md +++ b/skills/device-pod-cli/SKILL.md @@ -96,6 +96,7 @@ Run read-only workspace, debug, and I/O jobs: ```sh hwpod device-pod-71-freq:workspace:/ ls --api-base-url --session-token hwpod device-pod-71-freq:workspace:/src rg main --api-base-url --session-token +hwpod device-pod-71-freq:workspace:/ rg --pattern "Programming Done|Verify OK|Application running|Error" --path projects/01_baseline/captures --api-base-url --session-token hwpod device-pod-71-freq:debug-probe chip-id --api-base-url --session-token hwpod device-pod-71-freq:io-probe:/uart/1 read --duration-ms 5000 --api-base-url --session-token ``` @@ -165,6 +166,8 @@ hwpod device-pod-71-freq:workspace:/ build status --api-base-url - hwpod device-pod-71-freq:debug-probe download output --api-base-url --session-token ``` +Use the canonical `hwpod job output --pod-id ` 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 ` or `download status ` 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/.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 ` 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 --path ` or `--pattern-b64 ` 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. diff --git a/tools/device-pod-cli.test.ts b/tools/device-pod-cli.test.ts index f7f63727..1a3af8c0 100644 --- a/tools/device-pod-cli.test.ts +++ b/tools/device-pod-cli.test.ts @@ -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" } }); } diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 0bbd1fc8..fcfb6e9b 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -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", diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts index 3c12fd55..db60b9c4 100644 --- a/tools/src/device-pod-cli-lib.ts +++ b/tools/src/device-pod-cli-lib.ts @@ -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 }; diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 88353927..48ec73bf 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -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; type ParsedArgs = Record & { _: 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") {