fix: compact device pod job output for code agent

This commit is contained in:
Codex
2026-05-31 20:13:57 +08:00
parent 72535cb32c
commit 153aabf534
4 changed files with 110 additions and 3 deletions
+6
View File
@@ -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 <traceId>``client harness audit <traceId>`。确认它们全部是短连接 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 <jobId> --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 变更不走常驻服务发布流程。 |
+12 -1
View File
@@ -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);
+42
View File
@@ -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"], {
+50 -2
View File
@@ -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<string, string | undefined>;
@@ -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<string, unknown> = {}) { 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<string, unknown> = {}, 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<string, unknown> = {}, 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
}
});
}