fix: compact device pod job output for code agent
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
- 运行时不做内部证明型校验、旧健康诊断或重断言;CI/CD 只保留能证明代码可构建、语法正确和最小冒烟可用的校验。功能正确性通过 `hwlab-cli client` 短连接真实业务 E2E 暴露和修复。
|
- 运行时不做内部证明型校验、旧健康诊断或重断言;CI/CD 只保留能证明代码可构建、语法正确和最小冒烟可用的校验。功能正确性通过 `hwlab-cli client` 短连接真实业务 E2E 暴露和修复。
|
||||||
- 专用子命令覆盖高频用户工作台;`client request METHOD /path` 覆盖 WEB 同源代理允许的其他非视觉 API。`client request` 只接受以 `/` 开头的 Cloud Web 相对路径,禁止绝对 URL,避免绕过 Cloud Web 直接打内部服务。
|
- 专用子命令覆盖高频用户工作台;`client request METHOD /path` 覆盖 WEB 同源代理允许的其他非视觉 API。`client request` 只接受以 `/` 开头的 Cloud Web 相对路径,禁止绝对 URL,避免绕过 Cloud Web 直接打内部服务。
|
||||||
- 输出默认是 JSON;任何失败都要有 `ok:false`、`action`、`status`、HTTP 状态、route 和可定位错误,不允许无 stdout 成功。可能返回大对象的 `client` 子命令默认返回紧凑摘要,避免高频排障输出爆炸;需要完整响应体时显式加 `--full`。
|
- 输出默认是 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 再手工查找回复。
|
- Code Agent 交互必须默认暴露 `traceId`、`resultUrl`、终态和 assistant 回复文本摘要;不能要求用户先拉全量 trace 再手工查找回复。
|
||||||
- `client harness`、`client harness-ops` 和 `client harness-opt` 吸收 G14 harness-ops 的短连接业务能力:health、submit、result、trace、wait 和 audit。
|
- `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 的运行面写路径。
|
- 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 写路径。
|
阅读 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` 字段。 |
|
| JSON-RPC 同源 API | 目标状态 | `client rpc` 自动补齐 Web JSON-RPC envelope 的 `meta` 字段。 |
|
||||||
| 通用同源 API request | 目标状态 | `client request` 用于追平低频和新增 WEB API,禁止绝对 URL。 |
|
| 通用同源 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。 |
|
| 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 摘要。 |
|
| 本地 cookie session | 目标状态 | `.state/hwlab-cli/session.json` 只保存 cookie/session 摘要。 |
|
||||||
| 镜像/Service/Job template | 已废弃 | 相关 deploy、GitOps、artifact 和 Tekton 口径必须删除。 |
|
| 镜像/Service/Job template | 已废弃 | 相关 deploy、GitOps、artifact 和 Tekton 口径必须删除。 |
|
||||||
| PR/CI/CD/worktree 流程 | 已废弃 | CLI 变更不走常驻服务发布流程。 |
|
| PR/CI/CD/worktree 流程 | 已废弃 | CLI 变更不走常驻服务发布流程。 |
|
||||||
|
|||||||
+12
-1
@@ -46,7 +46,18 @@ child.on("error", (error) => {
|
|||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
child.on("exit", (code, signal) => {
|
||||||
if (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;
|
return;
|
||||||
}
|
}
|
||||||
process.exit(code ?? 0);
|
process.exit(code ?? 0);
|
||||||
|
|||||||
@@ -121,6 +121,48 @@ test("device-pod-cli bootsharp creates a formal workspace bootsharp REST job", a
|
|||||||
assert.equal(result.payload.localProfileAuthority, false);
|
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 () => {
|
test("device-pod-cli sends reason and lease token for mutating jobs", async () => {
|
||||||
const seen: any[] = [];
|
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"], {
|
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"], {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const VERSION = "0.2.0-rest";
|
const VERSION = "0.2.0-rest";
|
||||||
const CLI_NAME = "device-pod-cli";
|
const CLI_NAME = "device-pod-cli";
|
||||||
const DEFAULT_TIMEOUT_MS = 30000;
|
const DEFAULT_TIMEOUT_MS = 30000;
|
||||||
|
const BOOLEAN_OPTIONS = new Set(["dryRun", "full", "help", "h"]);
|
||||||
|
|
||||||
type FetchLike = typeof fetch;
|
type FetchLike = typeof fetch;
|
||||||
type EnvLike = Record<string, string | undefined>;
|
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 method = subcommand === "cancel" ? "POST" : "GET";
|
||||||
const path = `/v1/device-pods/${encodeURIComponent(podId)}/jobs/${encodeURIComponent(jobId)}${suffix}`;
|
const path = `/v1/device-pods/${encodeURIComponent(podId)}/jobs/${encodeURIComponent(jobId)}${suffix}`;
|
||||||
const response = await requestJson({ parsed, env, fetchImpl, method, path });
|
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) {
|
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 rawKey = eq >= 0 ? item.slice(2, eq) : item.slice(2);
|
||||||
const key = rawKey.replace(/-([a-z])/gu, (_, c) => String(c).toUpperCase());
|
const key = rawKey.replace(/-([a-z])/gu, (_, c) => String(c).toUpperCase());
|
||||||
if (eq >= 0) { setOption(out, key, item.slice(eq + 1)); continue; }
|
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];
|
const next = argv[i + 1];
|
||||||
if (next && !next.startsWith("--")) { setOption(out, key, next); i += 1; }
|
if (next && !next.startsWith("--")) { setOption(out, key, next); i += 1; }
|
||||||
else setOption(out, key, true);
|
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 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 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 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 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 ok(action: string, data: Record<string, unknown> = {}, status = "succeeded") { return { ok: true, action, status, ...data }; }
|
||||||
function failure(action: string, error: any) {
|
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 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 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user