From 153aabf5347aaa51f1f6e208feb0f501b223a850 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 31 May 2026 20:13:57 +0800 Subject: [PATCH] fix: compact device pod job output for code agent --- docs/reference/spec-v02-hwlab-cli.md | 6 ++++ scripts/run-bun.mjs | 13 ++++++- tools/device-pod-cli.test.ts | 42 ++++++++++++++++++++++ tools/src/device-pod-cli-lib.ts | 52 ++++++++++++++++++++++++++-- 4 files changed, 110 insertions(+), 3 deletions(-) diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index e3cdbbc4..4ccbda2b 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -14,6 +14,7 @@ - 运行时不做内部证明型校验、旧健康诊断或重断言;CI/CD 只保留能证明代码可构建、语法正确和最小冒烟可用的校验。功能正确性通过 `hwlab-cli client` 短连接真实业务 E2E 暴露和修复。 - 专用子命令覆盖高频用户工作台;`client request METHOD /path` 覆盖 WEB 同源代理允许的其他非视觉 API。`client request` 只接受以 `/` 开头的 Cloud Web 相对路径,禁止绝对 URL,避免绕过 Cloud Web 直接打内部服务。 - 输出默认是 JSON;任何失败都要有 `ok:false`、`action`、`status`、HTTP 状态、route 和可定位错误,不允许无 stdout 成功。可能返回大对象的 `client` 子命令默认返回紧凑摘要,避免高频排障输出爆炸;需要完整响应体时显式加 `--full`。 +- `device-pod-cli`/`hwpod` 的 `job output` 默认也必须返回紧凑 JSON:保留 job/status/blocker/freshness/text/evidence 摘要,省略嵌套 gateway dispatch 和长命令;需要完整 payload 时显式加 `--full`。Code Agent 和人工不得用 `| head`、`grep` 或 shell 管道作为默认输出压缩方式,避免 stdout pipe、子进程信号转发或长输出造成 commandExecution 黑洞。 - Code Agent 交互必须默认暴露 `traceId`、`resultUrl`、终态和 assistant 回复文本摘要;不能要求用户先拉全量 trace 再手工查找回复。 - `client harness`、`client harness-ops` 和 `client harness-opt` 吸收 G14 harness-ops 的短连接业务能力:health、submit、result、trace、wait 和 audit。 - harness 系列命令只调用 Cloud Web/Code Agent 同源 API,不创建镜像、Job、常驻服务,也不执行 hot-sync、kubectl cp 或硬编码 namespace/pod 的运行面写路径。 @@ -73,6 +74,10 @@ 阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `client harness health`、`client harness submit --message "你好" --provider-profile deepseek --timeout-ms 120000`、`client harness wait ` 和 `client harness audit `。确认它们全部是短连接 JSON 输出,不创建镜像、Job、CronJob、Deployment 或 hot-sync 写路径。 +## T6 + +阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `hwpod job output --pod-id D601-F103-V2 --api-base-url http://74.48.78.17:19667`,确认默认输出包含 `body.compacted=true`、状态、job 摘要和 bounded text,且不包含嵌套 `dispatch.command`;再加 `--full` 确认完整 payload 可按需展开。通过 `client harness submit` 让 Code Agent 执行同一 `hwpod job output`,确认 trace 中 commandExecution 可以完成,不需要 `| head`。 + ## 规格的实现情况 | 规格项 | 状态 | 说明 | @@ -82,6 +87,7 @@ | JSON-RPC 同源 API | 目标状态 | `client rpc` 自动补齐 Web JSON-RPC envelope 的 `meta` 字段。 | | 通用同源 API request | 目标状态 | `client request` 用于追平低频和新增 WEB API,禁止绝对 URL。 | | G14 harness-ops 短连接能力 | 目标状态 | `client harness` / `client harness-ops` / `client harness-opt` 覆盖 submit/result/trace/wait/audit,只作为业务 API client。 | +| Device Pod job output 紧凑输出 | 已实现 | `hwpod job output` 默认省略嵌套 dispatch,`--full` 才展开完整 payload,防止 Code Agent 通过 shell pipe 压输出。 | | 本地 cookie session | 目标状态 | `.state/hwlab-cli/session.json` 只保存 cookie/session 摘要。 | | 镜像/Service/Job template | 已废弃 | 相关 deploy、GitOps、artifact 和 Tekton 口径必须删除。 | | PR/CI/CD/worktree 流程 | 已废弃 | CLI 变更不走常驻服务发布流程。 | diff --git a/scripts/run-bun.mjs b/scripts/run-bun.mjs index 1ab07fff..85667e8a 100644 --- a/scripts/run-bun.mjs +++ b/scripts/run-bun.mjs @@ -46,7 +46,18 @@ child.on("error", (error) => { child.on("exit", (code, signal) => { if (signal) { - process.kill(process.pid, signal); + try { + process.kill(process.pid, signal); + } catch (error) { + process.stderr.write(JSON.stringify({ + script: "run-bun", + status: "failed", + error: "bun_child_signal_forward_failed", + signal, + reason: error?.message ?? String(error) + }, null, 2) + "\n"); + process.exit(128); + } return; } process.exit(code ?? 0); diff --git a/tools/device-pod-cli.test.ts b/tools/device-pod-cli.test.ts index 1a3af8c0..070c19fc 100644 --- a/tools/device-pod-cli.test.ts +++ b/tools/device-pod-cli.test.ts @@ -121,6 +121,48 @@ test("device-pod-cli bootsharp creates a formal workspace bootsharp REST job", a assert.equal(result.payload.localProfileAuthority, false); }); +test("device-pod-cli job output is compact by default and full with --full", async () => { + const payload = { + serviceId: "hwlab-cloud-api", + contractVersion: "device-pod-job-v1", + status: "completed", + devicePodId: "D601-F103-V2", + traceId: "trc_job", + operationId: "op_job", + job: { id: "job_1", status: "completed" }, + output: { + text: "x".repeat(5000), + dispatch: { command: "verbose command that should stay out of compact output" }, + bytes: 5000, + summary: "gateway/device-host-cli dispatch completed" + } + }; + const fetchImpl = async () => jsonResponse(200, payload); + const compact = await runDevicePodCli([ + "--api-base-url", "http://cloud.test", + "--session-token", "session-a", + "job", "output", + "--pod-id", "D601-F103-V2", + "job_1" + ], { fetchImpl, now: () => "2026-05-31T00:00:00.000Z" }); + assert.equal(compact.exitCode, 0); + assert.equal(compact.payload.body.compacted, true); + assert.equal(compact.payload.body.text.length, 4000); + assert.equal(JSON.stringify(compact.payload).includes("verbose command"), false); + + const full = await runDevicePodCli([ + "--api-base-url", "http://cloud.test", + "--session-token", "session-a", + "--full", + "job", "output", + "--pod-id", "D601-F103-V2", + "job_1" + ], { fetchImpl, now: () => "2026-05-31T00:00:00.000Z" }); + assert.equal(full.exitCode, 0); + assert.equal(full.payload.full, true); + assert.equal(full.payload.body.output.dispatch.command, "verbose command that should stay out of compact output"); +}); + test("device-pod-cli sends reason and lease token for mutating jobs", async () => { const seen: any[] = []; const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "--lease-token", "lease-a", "--reason", "reset smoke", "device-pod-71-freq:debug-probe", "reset"], { diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts index db60b9c4..a327fb3b 100644 --- a/tools/src/device-pod-cli-lib.ts +++ b/tools/src/device-pod-cli-lib.ts @@ -1,6 +1,7 @@ const VERSION = "0.2.0-rest"; const CLI_NAME = "device-pod-cli"; const DEFAULT_TIMEOUT_MS = 30000; +const BOOLEAN_OPTIONS = new Set(["dryRun", "full", "help", "h"]); type FetchLike = typeof fetch; type EnvLike = Record; @@ -196,7 +197,7 @@ async function jobCommand({ parsed, rest, env, fetchImpl }: any) { const method = subcommand === "cancel" ? "POST" : "GET"; const path = `/v1/device-pods/${encodeURIComponent(podId)}/jobs/${encodeURIComponent(jobId)}${suffix}`; const response = await requestJson({ parsed, env, fetchImpl, method, path }); - return responsePayload(`job.${subcommand}`, response, { route: { method, path }, devicePodId: podId, jobId }); + return responsePayload(`job.${subcommand}`, response, { route: { method, path }, devicePodId: podId, jobId, compactKind: subcommand === "output" ? "device-job-output" : null }, parsed); } async function invokeSelector(selector: any, context: any) { @@ -399,6 +400,7 @@ function parseOptions(argv: string[]): ParsedArgs { const rawKey = eq >= 0 ? item.slice(2, eq) : item.slice(2); const key = rawKey.replace(/-([a-z])/gu, (_, c) => String(c).toUpperCase()); if (eq >= 0) { setOption(out, key, item.slice(eq + 1)); continue; } + if (BOOLEAN_OPTIONS.has(key)) { setOption(out, key, true); continue; } const next = argv[i + 1]; if (next && !next.startsWith("--")) { setOption(out, key, next); i += 1; } else setOption(out, key, true); @@ -466,7 +468,15 @@ const uartJsonRpcOptionKeys = [ function defaultOperation(surface: string) { return surface === "workspace" ? "ls" : surface === "debug-probe" ? "status" : "read"; } function apiBaseUrl(parsed: ParsedArgs, env: EnvLike) { const value = text(parsed.apiBaseUrl ?? parsed.apiUrl ?? env.HWLAB_DEVICE_POD_API_URL ?? env.HWLAB_CLOUD_API_URL); if (!value) throw cliError("api_base_url_required", "hwpod requires --api-base-url or HWLAB_DEVICE_POD_API_URL/HWLAB_CLOUD_API_URL"); return value.replace(/\/+$/u, ""); } function authHeaders(parsed: ParsedArgs, env: EnvLike) { const cookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE); const sessionToken = text(parsed.sessionToken ?? env.HWLAB_DEVICE_POD_SESSION_TOKEN ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN); const bearer = text(parsed.bearerToken ?? env.HWLAB_BEARER_TOKEN); return clean({ ...(cookie ? { cookie: cookie.includes("=") ? cookie : `hwlab_session=${encodeURIComponent(cookie)}` } : {}), ...(sessionToken ? { "x-hwlab-session-token": sessionToken } : {}), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }); } -function responsePayload(action: string, response: any, extra: Record = {}) { const success = response.status >= 200 && response.status < 300 && response.body?.ok !== false; return { ok: success, action, status: success ? "succeeded" : "failed", httpStatus: response.status, ...extra, body: response.body }; } +function responsePayload(action: string, response: any, extra: Record = {}, parsed: ParsedArgs = { _: [] }) { + const success = response.status >= 200 && response.status < 300 && response.body?.ok !== false; + const full = parsed.full === true; + const compactKind = text(extra.compactKind); + const cleanExtra = { ...extra }; + delete cleanExtra.compactKind; + const body = !full && compactKind === "device-job-output" ? compactJobOutputBody(response.body) : response.body; + return { ok: success, action, status: success ? "succeeded" : "failed", httpStatus: response.status, ...cleanExtra, body, ...(full ? { full: true } : {}) }; +} function withMeta(payload: any, now: () => string) { return { generatedAt: now(), cli: CLI_NAME, version: VERSION, ...payload }; } function ok(action: string, data: Record = {}, status = "succeeded") { return { ok: true, action, status, ...data }; } function failure(action: string, error: any) { @@ -603,3 +613,41 @@ function patchHintNext(message: string) { } function parseJson(value: string) { if (!value) return null; try { return JSON.parse(value); } catch { return { rawText: value.slice(0, 2000), parseError: true }; } } function compactBody(body: any) { if (!body || typeof body !== "object") return body; return { ok: body.ok, status: body.status, authenticated: body.authenticated, actor: body.actor, contractVersion: body.contractVersion, devicePodCount: Array.isArray(body.devicePods) ? body.devicePods.length : undefined, selectedDevicePodId: body.selectedDevicePodId, error: body.error, summary: body.summary }; } +function compactJobOutputBody(body: any) { + if (!body || typeof body !== "object") return body; + const output = body.output && typeof body.output === "object" ? body.output : {}; + const textValue = typeof body.text === "string" ? body.text : typeof output.text === "string" ? output.text : ""; + const maxTextBytes = 4000; + const textBuffer = Buffer.from(textValue, "utf8"); + const clippedText = textBuffer.length > maxTextBytes ? textBuffer.subarray(0, maxTextBytes).toString("utf8") : textValue; + return clean({ + serviceId: body.serviceId, + contractVersion: body.contractVersion, + accepted: body.accepted, + status: body.status, + devicePodId: body.devicePodId, + targetId: body.targetId, + profileHash: body.profileHash, + traceId: body.traceId, + operationId: body.operationId, + job: body.job, + blocker: body.blocker, + freshness: body.freshness, + outputUrl: body.outputUrl, + cancelUrl: body.cancelUrl, + text: clippedText, + bytes: body.bytes ?? output.bytes, + truncation: body.truncation ?? output.truncation, + outputSummary: output.summary, + gateway: output.gateway, + evidenceId: output.evidenceId, + httpStatus: output.httpStatus, + compacted: true, + compactNote: "Use --full to print the complete device job output payload.", + textTruncation: { + maxBytes: maxTextBytes, + originalBytes: textBuffer.length, + truncated: textBuffer.length > maxTextBytes + } + }); +}