fix: 收敛 device-pod DS 验收 CLI 摩擦
This commit is contained in:
@@ -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 的目标命名。
|
||||
|
||||
## 测试规格
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" } });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user