feat: sync g14 harness and device-pod cli

This commit is contained in:
Codex
2026-05-31 05:07:16 +08:00
parent 1ebbfe2bfe
commit 5bf4bf0f47
17 changed files with 2086 additions and 210 deletions
-1
View File
@@ -125,7 +125,6 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥
- DEV 依赖 runtime base 构建:`npm run dev-runtime-base:build`
- Legacy D601 DEV CD:旧脚本入口已删除;事故回放只读历史 issue/commit,不恢复旧命令。
- v0.2 WEB 等价短连接 CLI`node scripts/run-bun.mjs tools/hwlab-cli/bin/hwlab-cli.ts client ...`,只在 `G14:/root/hwlab-v02` 固定 repo 直接调用 Cloud Web 同源 API。
- runner GitHub 可见性预检:`npm run runner:issue-visibility:preflight`
- D601 k3s 只读观测:legacy 回溯入口,仅在确认需要 D601 事故复盘时使用;当前 G14 运行面观察使用 UniDesk route `G14:k3s`
- DEV runtime hotfix 只读审计计划:`npm run dev-runtime:hotfix-audit`
- Gateway 主动出站本地 smoke`npm run gateway:demo:smoke`;经本地 edge-proxy 验证用 `npm run gateway:demo:edge-smoke`
+82
View File
@@ -336,6 +336,88 @@ test("device-pod executor maps v0.1 CLI options to device-host-cli argv", async
}
});
test("device-pod executor maps absorbed G14 device-pod operations to host CLI argv", async () => {
const commands = [];
const cloudApi = createServer(async (request, response) => {
const body = await requestJson(request);
commands.push(body.params.input.command);
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify({
jsonrpc: "2.0",
id: body.id,
result: { status: "completed", dispatch: { dispatchStatus: "succeeded", stdout: "ok", exitCode: 0 } }
}));
});
await new Promise((resolve) => cloudApi.listen(0, "127.0.0.1", resolve));
const cloudPort = cloudApi.address().port;
const service = await startDevicePod("device-pod-test", { HWLAB_CLOUD_API_INTERNAL_URL: `http://127.0.0.1:${cloudPort}` });
const baseBody = {
profile: { devicePodId: "device-pod-test", target: { id: "target-test" }, route: { gatewaySessionId: "gws_test", hostCli: "node tools/device-host-cli.mjs" } }
};
try {
await submitAndWait(service.port, "workspace.put", {
path: "User/new.c",
contentB64: Buffer.from("int x;\n").toString("base64"),
createDirs: true
}, "job_map_put", baseBody);
await submitAndWait(service.port, "workspace.keil", {
action: "add-source",
base: "projects/app",
path: "User/new.c",
group: "User"
}, "job_map_keil", baseBody);
await submitAndWait(service.port, "io.uart.jsonrpc", {
uartId: "uart/1",
method: "gpio.read",
params: "{\"pin\":\"PB5\"}",
requireJsonrpcResult: true,
expectResultField: ["pin", "value"]
}, "job_map_jsonrpc", baseBody);
assert.match(commands[0], / workspace put User\/new\.c --content-b64 /u);
assert.match(commands[0], / --create-dirs$/u);
assert.match(commands[1], / workspace keil add-source User\/new\.c --base projects\/app --group User$/u);
assert.match(commands[2], / io-probe uart\/1 jsonrpc gpio\.read /u);
assert.match(commands[2], / --params "\{\\"pin\\":\\"PB5\\"\}" /u);
assert.match(commands[2], / --require-jsonrpc-result --expect-result-field pin --expect-result-field value$/u);
} finally {
await service.stop();
await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve())));
}
});
test("device-pod executor fails mismatched gateway JSON-RPC response ids", async () => {
const cloudApi = createServer(async (_request, response) => {
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify({ jsonrpc: "2.0", id: "wrong-id", result: { status: "completed", dispatch: { dispatchStatus: "succeeded", stdout: "ok", exitCode: 0 } } }));
});
await new Promise((resolve) => cloudApi.listen(0, "127.0.0.1", resolve));
const cloudPort = cloudApi.address().port;
const service = await startDevicePod("device-pod-test", { HWLAB_CLOUD_API_INTERNAL_URL: `http://127.0.0.1:${cloudPort}` });
try {
const output = await submitAndWait(service.port, "workspace.ls", { path: "." }, "job_bad_id", {
profile: { devicePodId: "device-pod-test", target: { id: "target-test" }, route: { gatewaySessionId: "gws_test", hostCli: "node tools/device-host-cli.mjs" } }
}, "failed");
assert.equal(output.status, "failed");
assert.match(output.blocker.summary, /JSON-RPC id mismatch/u);
} finally {
await service.stop();
await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve())));
}
});
async function submitAndWait(port, intent, args, jobId, extraBody = {}, expectedStatus = "completed") {
const response = await fetch(`http://127.0.0.1:${port}/v1/device-pods/device-pod-test/jobs`, {
method: "POST",
headers: internalHeaders({ "content-type": "application/json" }),
body: JSON.stringify({ jobId, intent, args, traceId: `trc_${jobId}`, operationId: `op_${jobId}`, ...extraBody })
});
assert.equal(response.status, 202);
return await waitForJobStatus(port, "device-pod-test", jobId, expectedStatus);
}
async function startDevicePod(devicePodId, extraEnv = {}) {
const port = await freePort();
const child = spawn(bunCommand, ["run", "cmd/hwlab-device-pod/main.ts"], {
+164 -10
View File
@@ -252,6 +252,7 @@ function handleCancelInternalJob(request, response, id, subpath) {
async function dispatchGatewayJob({ job, route }) {
const target = `${cloudApiInternalUrl}/v1/internal/device-pod/gateway-dispatch`;
const requestId = `req_${job.id}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), dispatchTimeoutMs + 10000);
try {
@@ -264,10 +265,10 @@ async function dispatchGatewayJob({ job, route }) {
"x-hwlab-internal-service": SERVICE_ID,
"x-hwlab-internal-token": internalToken,
"x-trace-id": job.traceId,
"x-request-id": `req_${job.id}`
"x-request-id": requestId
},
body: JSON.stringify({
id: `req_${job.id}`,
id: requestId,
actorId: job.ownerUserId || "svc_hwlab-device-pod",
params: {
projectId: "prj_v02_device_pod",
@@ -383,6 +384,12 @@ function gatewayAdapterState() {
}
function jobFromGatewayDispatch(job, payload, httpStatus) {
const expectedId = `req_${job.id}`;
if (payload && Object.hasOwn(payload, "id") && String(payload.id) !== expectedId) {
const blocker = gatewayDispatchFailedBlocker(`gateway JSON-RPC id mismatch: expected ${expectedId}, got ${String(payload.id)}`);
const now = new Date().toISOString();
return { ...job, status: "failed", updatedAt: now, completedAt: now, output: boundedOutput({ text: "", httpStatus, summary: blocker.summary }), blocker };
}
const result = normalizeObject(payload.result);
const dispatch = normalizeObject(result.dispatch);
const blocker = payload.blocker ?? result.blocker ?? (payload.error ? gatewayDispatchFailedBlocker(payload.error.message ?? "gateway dispatch failed") : null);
@@ -470,19 +477,151 @@ function deviceHostArgs(intent, args = {}) {
const pattern = textOr(args.pattern ?? args.query, "");
return pattern ? ["workspace", "rg", pattern, textOr(args.path, "."), ...hostOptionArgs(args, ["glob", "g", "filesWithMatches", "l", "ignoreCase", "i", "maxCount"])] : null;
}
if (intent === "workspace.put") {
const path = textOr(args.path, "");
return path
? ["workspace", "put", path, ...hostOptionArgs(args, [
"contentB64",
"textB64",
"text",
"encoding",
"charset",
"createOnly",
"createDirs",
"updateOnly"
])]
: null;
}
if (intent === "workspace.rm") {
const path = textOr(args.path, "");
return path ? ["workspace", "rm", path, ...hostOptionArgs(args, ["missingOk"])] : null;
}
if (intent === "workspace.rmdir") {
const path = textOr(args.path, "");
return path ? ["workspace", "rmdir", path] : null;
}
if (intent === "workspace.keil") {
const action = textOr(args.action, "");
return action
? [
"workspace",
"keil",
action,
...optionalValue(args.path),
...hostOptionArgs(args, ["base", "group", "groupName", "target", "timeoutMs"])
]
: null;
}
if (intent === "workspace.apply-patch") return ["workspace", "apply-patch", textOr(args.base, "."), "--patch-b64", patchBase64(args)];
if (intent === "workspace.build") return ["workspace", "build", textOr(args.action, "start"), ...hostOptionArgs(args, ["target", "timeoutMs"]), ...jobIdArgs(args)];
if (intent === "workspace.build") {
return [
"workspace",
"build",
textOr(args.action, "start"),
...hostOptionArgs(args, ["target", "timeoutMs", "clean", "dryRun"]),
...jobIdArgs(args)
];
}
if (intent === "debug.status") return ["debug-probe", "status"];
if (intent === "debug.chip-id") return ["debug-probe", "chip-id"];
if (intent === "debug.download") return ["debug-probe", "download", textOr(args.action, "start"), ...hostOptionArgs(args, ["target", "timeoutMs", "captureUart", "captureDurationMs", "durationMs", "port", "baudRate"]), ...jobIdArgs(args)];
if (intent === "debug.download") {
return [
"debug-probe",
"download",
textOr(args.action, "start"),
...hostOptionArgs(args, [
"target",
"timeoutMs",
"captureUart",
"captureDurationMs",
"durationMs",
"port",
"baudRate"
]),
...jobIdArgs(args)
];
}
if (intent === "debug.reset") return ["debug-probe", "reset"];
if (intent === "io.ports") return ["io-probe", textOr(args.uartId, "uart/1"), "ports"];
if (intent === "io.uart.read") return ["io-probe", textOr(args.uartId, "uart/1"), "read", "--duration-ms", String(numberOr(args.durationMs ?? args["duration-ms"], 1000)), ...hostOptionArgs(args, ["port", "baudRate"])] ;
if (intent === "io.uart.read-after-launch-flash") return ["io-probe", textOr(args.uartId, "uart/1"), "read-after-launch-flash", ...hostOptionArgs(args, ["durationMs", "port", "baudRate", "flashBase", "skipHardwareReset", "connectMode", "timeoutMs"])] ;
if (intent === "io.uart.write") return ["io-probe", textOr(args.uartId, "uart/1"), "write", ...(args.hex ? ["--hex"] : []), ...hostOptionArgs(args, ["port", "baudRate"]), textOr(args.message ?? args.text ?? args.data, "")];
if (intent === "io.uart.read") {
return [
"io-probe",
textOr(args.uartId, "uart/1"),
"read",
"--duration-ms",
String(numberOr(args.durationMs ?? args["duration-ms"], 1000)),
...hostOptionArgs(args, ["port", "baudRate"])
];
}
if (intent === "io.uart.read-after-launch-flash") {
return [
"io-probe",
textOr(args.uartId, "uart/1"),
"read-after-launch-flash",
...hostOptionArgs(args, uartLaunchFlashOptionKeys)
];
}
if (intent === "io.uart.write") {
return [
"io-probe",
textOr(args.uartId, "uart/1"),
"write",
...(args.hex ? ["--hex"] : []),
...hostOptionArgs(args, ["port", "baudRate"]),
textOr(args.message ?? args.text ?? args.data, "")
];
}
if (intent === "io.uart.jsonrpc") {
return [
"io-probe",
textOr(args.uartId, "uart/1"),
"jsonrpc",
...optionalValue(args.method),
...hostOptionArgs(args, uartJsonRpcOptionKeys)
];
}
return null;
}
const uartLaunchFlashOptionKeys = [
"durationMs",
"port",
"baudRate",
"flashBase",
"skipHardwareReset",
"connectMode",
"timeoutMs"
];
const uartJsonRpcOptionKeys = [
"id",
"request",
"requestB64",
"params",
"paramsB64",
"responseTimeoutMs",
"durationMs",
"retry",
"retries",
"retryDelayMs",
"lineEnding",
"noNewline",
"lineDelimited",
"discardBefore",
"requireResponse",
"requireJson",
"requireJsonrpc",
"requireJsonrpcResult",
"allowIdMismatch",
"expectResultField",
"port",
"baudRate"
];
function optionalValue(value) {
const result = textOr(value, "");
return result ? [result] : [];
}
function jobIdArgs(args = {}) {
const jobId = textOr(args.jobId, "");
return jobId ? [jobId] : [];
@@ -494,8 +633,12 @@ function hostOptionArgs(args = {}, keys = []) {
const value = args[key];
if (value === undefined || value === null || value === "" || value === false) continue;
const name = `--${key.replace(/[A-Z]/gu, (match) => `-${match.toLowerCase()}`)}`;
if (value === true) out.push(name);
else out.push(name, String(value));
const values = Array.isArray(value) ? value : [value];
for (const item of values) {
if (item === undefined || item === null || item === "" || item === false) continue;
if (item === true) out.push(name);
else out.push(name, String(item));
}
}
return out;
}
@@ -551,7 +694,18 @@ function jobOutputPayload(job) {
}
function publicJob(job) {
return { id: job.id, devicePodId: job.devicePodId, status: job.status, intent: job.intent, reason: job.reason, traceId: job.traceId, operationId: job.operationId, createdAt: job.createdAt, updatedAt: job.updatedAt, completedAt: job.completedAt };
return {
id: job.id,
devicePodId: job.devicePodId,
status: job.status,
intent: job.intent,
reason: job.reason,
traceId: job.traceId,
operationId: job.operationId,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
completedAt: job.completedAt
};
}
function boundedOutput(output, maxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES) {
+14
View File
@@ -193,6 +193,10 @@ DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId}
- `workspace.cat`
- `workspace.rg`
- `workspace.apply-patch`
- `workspace.put`
- `workspace.rm`
- `workspace.rmdir`
- `workspace.keil`
- `workspace.build`
- `debug.status`
- `debug.chip-id`
@@ -202,6 +206,7 @@ DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId}
- `io.uart.read`
- `io.uart.read-after-launch-flash`
- `io.uart.write`
- `io.uart.jsonrpc`
`GET /debug-probe/chip-id``GET /io-probe/uart/1``GET /io-probe/uart/1/tail` 是用户态便捷 REST surface,但不能停留在静态面板或 fake probecloud-api 必须在完成 authenticate/grant 后创建对应只读 job,再经 `hwlab-device-pod` executor/gateway/device-host-cli 执行或返回同一套 blocker。
@@ -258,6 +263,10 @@ manages: many devicePodId
- `profile list/show` 调用 cloud-api `/v1/device-pods``/status`,只显示服务端脱敏 profile 摘要和 `profileHash`
- `setup first-admin``admin device-pod upsert/grant` 只作为 cloud-api REST wrapper,用于首次空库 seed 或 admin profile/grant 管理;它们接受显式 `--profile-json``--device-pod-json`,不得读取本地 `.device-pod/*.json` 作为权威 profile。
- `devicePodId:workspace|debug-probe|io-probe...` selector 转换为 `POST /v1/device-pods/{devicePodId}/jobs` 或 job status/output/cancel REST,不直接调用 `/v1/rpc/hardware.invoke.shell`
- workspace 写操作覆盖 `apply-patch``put``rm``rmdir``build``keil add-source/remove-source` 等 Keil 工程维护动作。
- 源码局部编辑优先使用 `apply-patch``put` 只用于明确的整文件写入或新文件创建。
- UART 业务覆盖 `read``write``read-after-launch-flash``jsonrpc`JSON-RPC 请求必须由 device-host-cli 校验 response id,除非显式传入允许 id mismatch 的业务参数。
- apply-patch 类失败必须返回可定位的 patch hint,例如缺少 `*** End Patch`、hunk 上下文不匹配或 header 错误;调用方应先重新读取目标文件再重试小 hunk,不应默认绕到整文件覆盖。
- mutating job 继续由 cloud-api 侧强制 lease/reasonCLI 只转发 `reason``leaseToken`,不在本地绕过。
- `profile create` 这类本地 profile bootstrap 在正式默认路径中返回 `legacy_profile_create_removed`;管理员应使用 cloud-api admin API 管理服务端 profile/grant。
@@ -275,6 +284,10 @@ manages: many devicePodId
阅读 docs/reference/spec-device-pod.md,然后用 cli 手动测试以下内容:提交一个强副作用 job,例如 download/reset,缺少 reason 或 lease 时必须被拒绝;获得 lease 后响应必须包含 devicePodId、profileHash、traceId、operationId、freshness、blocker 和 bounded output metadata。
## T4
阅读 docs/reference/spec-device-pod.md,然后用 cli 手动测试以下内容:对授权 Device Pod 运行 `workspace put``workspace rm``workspace rmdir``workspace keil add-source/remove-source``io-probe jsonrpc``--dry-run` 与一次真实小闭环。确认请求只经过 cloud-api job REST,输出 intent、reason、lease、traceId 和 bounded output,不读取本地 profile 或直连 gateway。
## 规格的实现情况
| 规格项 | 状态 | 说明 |
@@ -283,5 +296,6 @@ manages: many devicePodId
| profile server authority | 部分实现 | cloud-api 保存正式 DB profile 并向用户返回脱敏摘要;device-pod executor 不接受用户上传 profile。 |
| 用户 grant + lease | 部分实现 | cloud-api 已实现 admin grant、可见性过滤、lease acquire/current/release、强副作用 job lease 校验和撤销授权释放 lease。 |
| REST/job API | 部分实现 | cloud-api 已实现 list/status/events/probe/job/output/cancel 和 lease API,并可把已授权 job 转发给内部 `hwlab-device-pod` executorexecutor 已实现内部 job create/get/output/cancel lifecycle 和 gateway/device-host-cli dispatch adapter,无在线 gateway/device-host-cli 时返回 blocker。 |
| G14 device-host 功能吸收 | 部分实现 | v0.2 job intent 已覆盖 workspace put/rm/rmdir、Keil 工程维护和 UART JSON-RPC,保持 cloud-api grant/lease/profile authority。 |
| 禁止 fake 作为 DEV-LIVE | 已实现/持续约束 | 规格和服务 payload 要求显式标记 fake/source。 |
+4 -2
View File
@@ -100,7 +100,7 @@ CI/CD 内部由 UniDesk 手动触发入口、PipelineRun、component planner、B
1. UniDesk CLI `hwlab g14 control-plane trigger-current --lane v02 --confirm` 解析当前 `origin/v0.2` 完整 source commit SHA,先复核 `devops-infra` mirror 的 `localV02` ref,必要时自动执行一次 bounded manual `git-mirror sync` Job,再创建 commit-pinned `hwlab-v02-ci-poll-<short12>` PipelineRun;默认 `--dry-run` 只返回将要创建的 manifest 和 mirror pre-sync 计划。
2. `prepare-source` 通过 `devops-infra` mirror checkout `v0.2` source,并从 mirror 中的 `v0.2-gitops` 读取上一版 `deploy/artifact-catalog.v02.json`
3. 原语校验 task 只覆盖 repo 报告护栏、GitOps render 合同和必要的代码语法/单元检查;旧 DEV/D601/main gate 不进入 lane。
3. CI/CD 校验只保留最小构建、语法、打包和冒烟检查;旧 DEV/D601/main gate、运行时内部证明型校验、健康诊断重断言和历史预检不进入 lane。
4. planner 根据 component input 判断 affected/reused services。
5. affected service 通过 BuildKit 发布到 G14 本地 registryreused service 复用 catalog digest。
6. promotion 刷新 `deploy/artifact-catalog.v02.json`render `deploy/gitops/g14/runtime-v02/**`,只推送到 `devops-infra` mirror/relay 的 `v0.2-gitops`
@@ -108,7 +108,7 @@ CI/CD 内部由 UniDesk 手动触发入口、PipelineRun、component planner、B
8. UniDesk CLI 或 mirror/relay flush 操作把本地 `v0.2-gitops` 推送到 GitHub canonical remoteflush 不在 CI runtime-ready 的关键路径内,但必须可查询 pending、lastFlushed 和 failure。
9. 验收只观察 `hwlab-v02` runtime 和 `19666/19667`
`v0.2` 可以复用 G14 的 registry、proxy、BuildKit、工具镜像和脚本库;不得复用 `hwlab-g14-ci-image-publish``hwlab-g14-branch-poller``hwlab-g14-control-plane-reconciler``G14-gitops` runtime path 或 DEV/PROD Argo Application 作为 `v0.2` 发布入口。
`v0.2` 可以复用 G14 的 registry、proxy、BuildKit、工具镜像和脚本库;不得复用 `hwlab-g14-ci-image-publish``hwlab-g14-branch-poller``hwlab-g14-control-plane-reconciler``G14-gitops` runtime path 或 DEV/PROD Argo Application 作为 `v0.2` 发布入口。运行时不得为每个版本硬编码 namespace、catalog、runtime path 或健康判断;版本差异只通过 `deploy.json.lanes[profile]`、GitOps render 输入和实际 runtime 对象表达,新增版本不得新增运行时代码分支。
`v0.2` 不设自动轮询发布。历史上若存在 `hwlab-v02-branch-poller``hwlab-v02-control-plane-reconciler` 或等价 CronJob,均视为迁移残留,应由 UniDesk control-plane apply 清理;后续发布、重跑、暂停和恢复都通过手动 CLI 触发或停止创建新的 PipelineRun 完成,不新增替代 CronJob。
@@ -116,6 +116,8 @@ CI/CD 内部由 UniDesk 手动触发入口、PipelineRun、component planner、B
`hwlab-cli` 不属于 v0.2 CI/CD service matrix。它是 `G14:/root/hwlab-v02` 固定 repo 内的短连接源码 client,只用 Bun 直接调用 Cloud Web 同源 API;不得加入 PipelineRun `services` 参数、artifact catalog、BuildKit publish、runtime desired state、Deployment、Service、Job 或 image build。若出现 `build-hwlab-cli` TaskRun、`hwlab-cli` artifact service、CLI 镜像或 CLI 常驻服务,均视为旧门禁/旧断言残留,直接删除并回到 `docs/reference/spec-v02-hwlab-cli.md` 的短连接 client 口径。
`rpt004:mvp:e2e``runner:issue-visibility:preflight``dev-base-image:preflight` 不属于 v0.2 最小 CI/CD 校验入口;它们代表旧验收、旧 runner 可见性预检或旧镜像基础预检口径。v0.2 `check/validate` 不再引用这些任务,若它们重新进入默认 check plan、package script 或 PipelineRun,应直接删除该入口,而不是为其补兼容逻辑。
写 mirror 的一致性模型是 local-first、manual-flush。promotion task 只能持有 mirror/relay 写凭证,不持有 GitHub deploy keyGitHub deploy key 只存在于 `devops-infra` mirror/relay sync/flush 边界。mirror/relay 必须在本地 receive 期间完成 object closure、目标 branch allowlist、non-fast-forward 拒绝和 changed-path 最小校验;receive 成功后本地 ref 即为 Argo 可消费事实。flush 失败不得回滚已经 rollout 的本地 GitOps revision,但必须保留 pending/outbox 状态,下一次手动 flush 可重试并输出 last error,不得静默丢弃。
## 性能预算与回归判定
+12
View File
@@ -10,9 +10,13 @@
- 只走 Cloud Web 同源 API surface;默认 base URL 是 `http://74.48.78.17:19666`,也可通过 `--base-url``HWLAB_CLIENT_BASE_URL` 指向其他 Cloud Web 入口。
- 不直连 Postgres、Kubernetes Service、Secret、device-pod 内部 Service、gateway RPC 或本地 fixture;需要鉴权的请求使用 `/auth/*` 返回的 cookie 或显式 `--cookie`
- Pod 内透传执行不放进 `hwlab-cli`;需要进入正在工作的 Code Agent/Cloud API pod 时,`hwlab-cli` 只查询并输出 UniDesk 标准 route,实际透传由 UniDesk `bun scripts/cli.ts ssh 'G14:k3s:hwlab-v02:pod:<pod>:<container>' ...` 完成。`pod:` 是 route 语法,`/` 只用于 pod 内文件系统路径。
- `client runtime routes` 必须按当前运行 profile/lane 的数据生成 UniDesk `pod:` route;实现不得硬编码 `dev``v0.2``v0.3`、namespace 或 catalog path。新增版本只允许通过 `deploy.json.lanes[profile]` 声明 namespace、artifact catalog 和 service overrides,不为每个版本新增代码分支。
- 运行时不做内部证明型校验、旧健康诊断或重断言;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`
- 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 的运行面写路径。
- 旧 `hwlab-cli cicd`、fixture MVP gate 和 CLI 镜像/Job 口径属于废弃路径;开发中遇到这些旧门禁、旧测试或旧预检时直接删除,不再维护兼容。
## 内部架构
@@ -38,6 +42,9 @@
| `hwlab-cli client agent send` | `POST /v1/agent/chat` + `GET /result/{trace}` | 以 short connection 提交 Code Agent 消息并轮询结果,默认输出 assistant 回复文本摘要。 |
| `hwlab-cli client agent trace TRACE` | `GET /v1/agent/chat/trace/{trace}` | 回放 trace,默认输出状态、事件摘要和 assistant stream 文本。 |
| `hwlab-cli client agent cancel TRACE` | `POST /v1/agent/chat/cancel` | 取消当前 Code Agent 请求。 |
| `hwlab-cli client harness submit` | `POST /v1/agent/chat` | G14 harness-ops 的短连接提交入口,默认 provider profile 为 `deepseek`,返回 trace/result URL`harness-ops``harness-opt` 是同义别名。 |
| `hwlab-cli client harness wait/result/trace` | `GET /v1/agent/chat/result/{trace}``GET /trace/{trace}` | 轮询或读取一次 Code Agent 结果和 trace;单次 wait 最长 60 秒。 |
| `hwlab-cli client harness audit` | `GET /v1/agent/chat/trace/{trace}` 或本地 trace file | 只输出工具摩擦信号,辅助发现应补的主 CLI/device-pod 操作;不是运行时 gate。 |
| `hwlab-cli client workbench summary` | `/health/live``/v1``/v1/live-builds``/v1/device-pods*` | 汇总 Cloud Workbench 非视觉功能面。 |
| `hwlab-cli client rpc METHOD [--full]` | `POST /json-rpc` | 像 Web `callRpc` 一样自动生成 `id``traceId``meta`,覆盖 `system.health``cloud.adapter.describe` 等 JSON-RPC 非视觉能力。 |
| `hwlab-cli client request METHOD /path [--full]` | Cloud Web 同源相对路径 | 覆盖 `/v1/access/status``/v1/setup/status``/v1/diagnostics/gate``/v1/m3/status``/v1/m3/io` 等低频或新增 WEB API`/json-rpc` 优先使用 `client rpc`。 |
@@ -62,6 +69,10 @@
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `client request GET /v1/access/status``client request GET /v1/diagnostics/gate``client rpc system.health`,确认它们全部走 `19666` Cloud Web 同源 API,返回 JSON route/httpStatus/body,且 `client request` 传入绝对 URL 会被拒绝。
## T5
阅读 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 写路径。
## 规格的实现情况
| 规格项 | 状态 | 说明 |
@@ -70,6 +81,7 @@
| WEB 等价 API client | 目标状态 | `client` 子命令覆盖 Cloud Web 非视觉业务面。 |
| 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。 |
| 本地 cookie session | 目标状态 | `.state/hwlab-cli/session.json` 只保存 cookie/session 摘要。 |
| 镜像/Service/Job template | 已废弃 | 相关 deploy、GitOps、artifact 和 Tekton 口径必须删除。 |
| PR/CI/CD/worktree 流程 | 已废弃 | CLI 变更不走常驻服务发布流程。 |
+12 -2
View File
@@ -11,6 +11,10 @@ const DEVICE_JOB_INTENTS = new Set([
"workspace.cat",
"workspace.rg",
"workspace.apply-patch",
"workspace.put",
"workspace.rm",
"workspace.rmdir",
"workspace.keil",
"workspace.build",
"debug.status",
"debug.chip-id",
@@ -19,7 +23,8 @@ const DEVICE_JOB_INTENTS = new Set([
"io.ports",
"io.uart.read",
"io.uart.read-after-launch-flash",
"io.uart.write"
"io.uart.write",
"io.uart.jsonrpc"
]);
const ACCESS_SCHEMA_STATEMENTS = Object.freeze([
`CREATE TABLE IF NOT EXISTS users (
@@ -109,10 +114,15 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([
const MUTATING_INTENTS = new Set([
"workspace.apply-patch",
"workspace.build",
"workspace.put",
"workspace.rm",
"workspace.rmdir",
"workspace.keil",
"debug.download",
"debug.reset",
"io.uart.read-after-launch-flash",
"io.uart.write"
"io.uart.write",
"io.uart.jsonrpc"
]);
const DEFAULT_DEVICE_LEASE_TTL_SECONDS = 15 * 60;
const MAX_DEVICE_LEASE_TTL_SECONDS = 60 * 60;
-57
View File
@@ -247,7 +247,6 @@ test("cloud api aggregates live HWLAB build times from health and controlled dep
});
}
});
test("cloud api ignores old repository reports and keeps missing buildCreatedAt unavailable", async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "hwlab-live-builds-"));
const deployDir = path.join(tempRoot, "deploy");
@@ -369,9 +368,6 @@ test("cloud api ignores old repository reports and keeps missing buildCreatedAt
const manager = withOldReport.services.find((service) => service.serviceId === "hwlab-agent-mgr");
assert.equal(withOldReport.latest, null);
assert.equal(manager.build.createdAt, null);
assert.equal(manager.build.metadataSource, "live-health:matched-controlled-metadata-without-build-time");
assert.equal(manager.build.unavailableReason, "构建时间不可用:当前 live 镜像未提供 buildCreatedAt");
assert.match(manager.build.unavailableDetail, /catalog metadata/u);
assert.equal(Object.hasOwn(withOldReport.source, "artifactReportSource"), false);
assert.equal(JSON.stringify(withOldReport).includes("2099-01-01T00:00:00.000Z"), false);
assert.equal(JSON.stringify(withOldReport).includes("artifact-report"), false);
@@ -379,56 +375,3 @@ test("cloud api ignores old repository reports and keeps missing buildCreatedAt
await rm(tempRoot, { recursive: true, force: true });
}
});
test("cloud api v02 live builds omit removed simulator and tunnel services from default inventory", async () => {
const server = createCloudApiServer({
env: {
PATH: "",
HWLAB_ENVIRONMENT: "v02",
HWLAB_GITOPS_PROFILE: "v02"
},
liveBuildMetadata: {
deployManifest: {
services: [
{ serviceId: "hwlab-cloud-api", profile: "v02", replicas: 1 },
{ serviceId: "hwlab-gateway-simu", profile: "v02", replicas: 1 },
{ serviceId: "hwlab-box-simu", profile: "v02", replicas: 1 },
{ serviceId: "hwlab-patch-panel", profile: "v02", replicas: 1 },
{ serviceId: "hwlab-router", profile: "v02", replicas: 1 },
{ serviceId: "hwlab-tunnel-client", profile: "v02", replicas: 1 }
]
},
artifactCatalog: {
services: [
{ serviceId: "hwlab-gateway-simu" },
{ serviceId: "hwlab-cloud-web" }
]
}
},
fetchImpl: async () => ({
ok: false,
status: 503,
async text() {
return JSON.stringify({ error: "offline" });
}
})
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}/v1/live-builds`);
assert.equal(response.status, 200);
const payload = await response.json();
const serviceIds = payload.services.map((service) => service.serviceId);
assert.equal(serviceIds.includes("hwlab-cloud-api"), true);
assert.equal(serviceIds.includes("hwlab-cloud-web"), true);
assert.equal(serviceIds.includes("hwlab-gateway-simu"), false);
assert.equal(serviceIds.includes("hwlab-box-simu"), false);
assert.equal(serviceIds.includes("hwlab-patch-panel"), false);
assert.equal(serviceIds.includes("hwlab-router"), false);
assert.equal(serviceIds.includes("hwlab-tunnel-client"), false);
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
+108 -47
View File
@@ -9,19 +9,16 @@ import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const LIVE_BUILD_TIMEOUT_MS = 900;
const LIVE_BUILD_METADATA_PATHS = Object.freeze({
deployManifest: "deploy/deploy.json",
artifactCatalog: "deploy/artifact-catalog.dev.json"
});
const LIVE_BUILD_DEPLOY_MANIFEST_PATH = "deploy/deploy.json";
const LIVE_BUILD_SERVICE_DEFAULTS = Object.freeze({
"hwlab-cloud-api": Object.freeze({ urlEnv: null, defaultUrl: "http://127.0.0.1:6667" }),
"hwlab-cloud-web": Object.freeze({ urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL", defaultUrl: "http://hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080" }),
"hwlab-agent-mgr": Object.freeze({ urlEnv: "HWLAB_AGENT_MGR_URL", defaultUrl: "http://hwlab-agent-mgr.hwlab-dev.svc.cluster.local:7410" }),
"hwlab-agent-worker": Object.freeze({ urlEnv: "HWLAB_AGENT_WORKER_URL", defaultUrl: "http://hwlab-agent-worker.hwlab-dev.svc.cluster.local:7411" }),
"hwlab-device-pod": Object.freeze({ urlEnv: "HWLAB_DEVICE_POD_URL", defaultUrl: "http://hwlab-device-pod.hwlab-dev.svc.cluster.local:7601" }),
"hwlab-gateway": Object.freeze({ urlEnv: "HWLAB_GATEWAY_URL", defaultUrl: "http://hwlab-gateway.hwlab-dev.svc.cluster.local:7001" }),
"hwlab-edge-proxy": Object.freeze({ urlEnv: "HWLAB_EDGE_PROXY_URL", defaultUrl: "http://hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667", healthPath: "/health" }),
"hwlab-agent-skills": Object.freeze({ urlEnv: "HWLAB_AGENT_SKILLS_URL", defaultUrl: "http://hwlab-agent-skills.hwlab-dev.svc.cluster.local:7430" })
"hwlab-cloud-web": Object.freeze({ urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL" }),
"hwlab-agent-mgr": Object.freeze({ urlEnv: "HWLAB_AGENT_MGR_URL" }),
"hwlab-agent-worker": Object.freeze({ urlEnv: "HWLAB_AGENT_WORKER_URL" }),
"hwlab-device-pod": Object.freeze({ urlEnv: "HWLAB_DEVICE_POD_URL" }),
"hwlab-gateway": Object.freeze({ urlEnv: "HWLAB_GATEWAY_URL" }),
"hwlab-edge-proxy": Object.freeze({ urlEnv: "HWLAB_EDGE_PROXY_URL", healthPath: "/health" }),
"hwlab-agent-skills": Object.freeze({ urlEnv: "HWLAB_AGENT_SKILLS_URL" })
});
const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([
Object.freeze({
@@ -33,22 +30,18 @@ const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([
externalReason: "外部镜像或非 HWLAB 构建产物"
})
]);
const V02_REMOVED_SERVICE_IDS = Object.freeze([
"hwlab-gateway-simu",
"hwlab-box-simu",
"hwlab-patch-panel",
"hwlab-router",
"hwlab-tunnel-client"
]);
function defaultRuntimeEnvironment(env = process.env) {
const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV;
return typeof value === "string" && value.trim() ? value.trim() : ENVIRONMENT_DEV;
}
function liveBuildProfile(env = process.env) {
return defaultRuntimeEnvironment(env);
}
export async function buildLiveBuildsPayload(options = {}, dependencies = {}) {
const env = options.env ?? process.env;
const metadata = await loadLiveBuildMetadata(options);
const metadata = await loadLiveBuildMetadata(options, env);
const inventory = liveBuildServiceInventory(metadata, env);
const services = await Promise.all(
inventory.map((service) => observeLiveBuildService(service, { ...options, env, buildHealthPayload: dependencies.buildHealthPayload }))
@@ -68,9 +61,9 @@ export async function buildLiveBuildsPayload(options = {}, dependencies = {}) {
kind: "live-health+deploy-artifact-catalog",
route: "/v1/live-builds",
healthPath: "/health/live",
metadataPaths: LIVE_BUILD_METADATA_PATHS,
desiredStateSource: metadata.deployManifest.status === "ok" ? LIVE_BUILD_METADATA_PATHS.deployManifest : "unavailable",
artifactCatalogSource: metadata.artifactCatalog.status === "ok" ? LIVE_BUILD_METADATA_PATHS.artifactCatalog : "unavailable",
metadataPaths: metadata.paths,
desiredStateSource: metadata.deployManifest.status === "ok" ? metadata.paths.deployManifest : "unavailable",
artifactCatalogSource: metadata.artifactCatalog.status === "ok" ? metadata.paths.artifactCatalog : "unavailable",
note: "Each row prefers current live service health build metadata and falls back only to deploy/artifact catalog metadata when the live tag/revision matches; observedAt is not used as build time."
},
latest,
@@ -425,6 +418,7 @@ function liveBuildRuntimeSummary(service, payload = {}) {
payload?.namespace,
runtime?.namespace,
pod?.namespace,
service.runtimeNamespace,
service.deploy?.namespace,
service.artifact?.namespace
);
@@ -438,7 +432,7 @@ function liveBuildRuntimeSummary(service, payload = {}) {
defaultRuntimeContainer(service.serviceId)
);
const unideskRoute = podName
? `G14:k3s:${namespace ?? "hwlab-v02"}:pod:${podName}${container ? `:${container}` : ""}`
? namespace ? `G14:k3s:${namespace}:pod:${podName}${container ? `:${container}` : ""}` : null
: null;
return pruneUndefined({
pod: pruneUndefined({
@@ -616,11 +610,11 @@ function liveBuildRecordFromMetadataSource(service, sourceKind) {
function desiredStateSummary(service) {
return {
namespace: service.deploy?.namespace ?? service.artifact?.namespace ?? "hwlab-dev",
profile: service.deploy?.profile ?? service.artifact?.profile ?? ENVIRONMENT_DEV,
namespace: service.runtimeNamespace ?? service.deploy?.namespace ?? service.artifact?.namespace ?? null,
profile: service.runtimeProfile ?? service.deploy?.profile ?? service.artifact?.profile ?? ENVIRONMENT_DEV,
replicas: Number.isFinite(Number(service.deploy?.replicas)) ? Number(service.deploy.replicas) : null,
healthPath: service.healthPath ?? service.deploy?.healthPath ?? service.artifact?.healthPath ?? "/health/live",
source: service.deploy ? LIVE_BUILD_METADATA_PATHS.deployManifest : "service-defaults"
source: service.deploy ? service.metadataPaths?.deployManifest ?? LIVE_BUILD_DEPLOY_MANIFEST_PATH : "service-defaults"
};
}
@@ -705,7 +699,8 @@ function externalLiveBuildRecord(service, {
function healthUnavailableReason(service, reason) {
if (Number(service.deploy?.replicas) === 0) {
return `${service.serviceId} 当前 hwlab-dev desired replicas=0,无可读取的常驻 /health/live${reason}`;
const namespace = desiredStateSummary(service).namespace;
return `${service.serviceId} 当前 ${namespace} desired replicas=0,无可读取的常驻 /health/live${reason}`;
}
if (service.activation === "manual") {
return `${service.serviceId} 是手动激活服务,当前 live health 不可用:${reason}`;
@@ -723,23 +718,43 @@ function normalizeHealthBuildTime(payload) {
);
}
async function loadLiveBuildMetadata(options = {}) {
if (options.liveBuildMetadata) return normalizeLiveBuildMetadata(options.liveBuildMetadata);
async function loadLiveBuildMetadata(options = {}, env = process.env) {
if (options.liveBuildMetadata) return normalizeLiveBuildMetadata(options.liveBuildMetadata, env);
const root = options.repoRoot ?? process.env.HWLAB_REPO_ROOT ?? repoRoot;
const [deployManifest, artifactCatalog] = await Promise.all([
readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.deployManifest),
readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.artifactCatalog)
]);
return normalizeLiveBuildMetadata({ deployManifest, artifactCatalog });
const deployManifest = await readJsonMetadata(root, LIVE_BUILD_DEPLOY_MANIFEST_PATH);
const paths = metadataPathsFromDeployManifest(deployManifest.payload, liveBuildProfile(env));
const artifactCatalog = await readJsonMetadata(root, paths.artifactCatalog);
return normalizeLiveBuildMetadata({ deployManifest, artifactCatalog }, paths);
}
function normalizeLiveBuildMetadata(metadata) {
function normalizeLiveBuildMetadata(metadata, envOrPaths = process.env) {
const deployManifest = normalizeMetadataFile(metadata.deployManifest);
const paths = isMetadataPaths(envOrPaths)
? envOrPaths
: metadataPathsFromDeployManifest(deployManifest.payload, liveBuildProfile(envOrPaths));
return {
deployManifest: normalizeMetadataFile(metadata.deployManifest),
paths,
deployManifest,
artifactCatalog: normalizeMetadataFile(metadata.artifactCatalog)
};
}
function isMetadataPaths(value) {
return Boolean(value?.deployManifest && value?.artifactCatalog);
}
function metadataPathsFromDeployManifest(deployManifest, profile) {
const lane = deployLaneForProfile(deployManifest, profile);
const artifactCatalog = firstNonEmpty(
lane?.artifactCatalog,
deployManifest?.artifactCatalog
);
return {
deployManifest: LIVE_BUILD_DEPLOY_MANIFEST_PATH,
artifactCatalog
};
}
async function readJsonMetadata(root, relativePath) {
try {
const payload = JSON.parse(await readFile(path.join(root, relativePath), "utf8"));
@@ -772,32 +787,40 @@ function normalizeMetadataFile(file) {
}
function liveBuildServiceInventory(metadata, env = process.env) {
const deployServices = Array.isArray(metadata.deployManifest.payload?.services)
? metadata.deployManifest.payload.services
: [];
const profile = liveBuildProfile(env);
const lane = deployLaneForProfile(metadata.deployManifest.payload, profile);
const runtimeNamespace = firstNonEmpty(lane?.namespace, metadata.deployManifest.payload?.namespace);
const deployServices = deployServicesForProfile(metadata.deployManifest.payload, profile, lane, runtimeNamespace);
const deployByServiceId = new Map(deployServices.map((service) => [service.serviceId, service]));
const artifactByServiceId = new Map(
(Array.isArray(metadata.artifactCatalog.payload?.services) ? metadata.artifactCatalog.payload.services : [])
.map((service) => normalizeServiceRuntimeTarget(service, profile, runtimeNamespace))
.map((service) => [service.serviceId, service])
);
const serviceIds = uniqueStrings([
...SERVICE_IDS,
...deployServices.map((service) => service.serviceId),
...artifactByServiceId.keys()
]).filter((serviceId) => String(serviceId).startsWith("hwlab-") && !serviceRemovedForRuntimeProfile(serviceId, env));
]).filter((serviceId) => String(serviceId).startsWith("hwlab-") && !serviceRemovedForRuntimeProfile(serviceId, lane));
return [
...serviceIds.map((serviceId) => {
const deploy = deployByServiceId.get(serviceId) ?? null;
const defaults = LIVE_BUILD_SERVICE_DEFAULTS[serviceId] ?? {};
const healthPath = deploy?.healthPath ?? defaults.healthPath ?? "/health/live";
const defaultUrl = serviceId === CLOUD_API_SERVICE_ID
? defaults.defaultUrl ?? serviceDefaultUrl(serviceId, runtimeNamespace)
: serviceDefaultUrl(serviceId, runtimeNamespace);
return Object.freeze({
serviceId,
serviceName: deploy?.name ?? serviceId,
urlEnv: defaults.urlEnv ?? serviceUrlEnvName(serviceId),
defaultUrl: defaults.defaultUrl ?? serviceDefaultUrl(serviceId),
defaultUrl,
healthPath,
activation: deploy?.replicas === 0 ? "zero-replica" : defaults.activation,
runtimeProfile: profile,
runtimeNamespace,
metadataPaths: metadata.paths,
deploy,
artifact: artifactByServiceId.get(serviceId) ?? null
});
@@ -806,9 +829,46 @@ function liveBuildServiceInventory(metadata, env = process.env) {
];
}
function serviceRemovedForRuntimeProfile(serviceId, env = process.env) {
const profile = String(env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || "").trim().toLowerCase();
return profile === "v02" && V02_REMOVED_SERVICE_IDS.includes(serviceId);
function deployLaneForProfile(deployManifest, profile) {
const lanes = deployManifest?.lanes && typeof deployManifest.lanes === "object" ? deployManifest.lanes : {};
return lanes[profile] ?? null;
}
function deployServicesForProfile(deployManifest, profile, lane, runtimeNamespace) {
const baseServices = Array.isArray(deployManifest?.services) ? deployManifest.services : [];
if (!lane) return baseServices;
const servicesById = new Map(baseServices.map((service) => [service.serviceId, { ...service, env: { ...(service.env ?? {}) } }]));
const laneServices = Array.isArray(lane.services) ? lane.services : [];
for (const service of laneServices) {
const current = servicesById.get(service.serviceId) ?? { serviceId: service.serviceId };
servicesById.set(service.serviceId, {
...current,
...service,
env: {
...(current.env ?? {}),
...(service.env ?? {})
}
});
}
return [...servicesById.values()].map((service) => normalizeServiceRuntimeTarget(service, profile, runtimeNamespace));
}
function normalizeServiceRuntimeTarget(service, profile, runtimeNamespace) {
if (!service) return service;
return {
...service,
namespace: runtimeNamespace,
profile
};
}
function serviceRemovedForRuntimeProfile(serviceId, lane) {
const removed = uniqueStrings([
...(Array.isArray(lane?.removedServices) ? lane.removedServices : []),
...(Array.isArray(lane?.disabledServices) ? lane.disabledServices : []),
...(Array.isArray(lane?.omitServices) ? lane.omitServices : [])
]);
return removed.includes(serviceId);
}
function uniqueStrings(values) {
@@ -819,7 +879,8 @@ function serviceUrlEnvName(serviceId) {
return `${serviceId.replace(/^hwlab-/u, "HWLAB_").replace(/-/gu, "_").toUpperCase()}_URL`;
}
function serviceDefaultUrl(serviceId) {
function serviceDefaultUrl(serviceId, namespace = null) {
if (!namespace) return null;
const port = {
"hwlab-cloud-api": 6667,
"hwlab-cloud-web": 8080,
@@ -830,5 +891,5 @@ function serviceDefaultUrl(serviceId) {
"hwlab-edge-proxy": 6667,
"hwlab-agent-skills": 7430
}[serviceId] ?? 8080;
return `http://${serviceId}.hwlab-dev.svc.cluster.local:${port}`;
return `http://${serviceId}.${namespace}.svc.cluster.local:${port}`;
}
-4
View File
@@ -9,7 +9,6 @@
"check:plan": "node scripts/check-runner.mjs --profile check --list",
"check:cloud-api": "node scripts/check-runner.mjs --profile check --group cloud-api",
"check:cloud-web": "node scripts/check-runner.mjs --profile check --group cloud-web",
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
"dev-runtime-base:build": "node scripts/dev-runtime-base-image.mjs",
"cloud-api:smoke": "node scripts/run-bun.mjs scripts/cloud-api-runtime-smoke.mjs",
"m1:smoke": "node scripts/run-bun.mjs scripts/m1-contract-smoke.mjs",
@@ -29,14 +28,11 @@
"g14:gitops:render": "node scripts/g14-gitops-render.mjs",
"docs:validate:m3-rollout": "node scripts/validate-m3-rollout-runbook.mjs",
"d601:k3s:readonly": "node scripts/d601-k3s-readonly-observability.mjs",
"runner:issue-visibility:preflight": "node scripts/runner-issue-visibility-preflight.mjs",
"report:lifecycle:test": "node scripts/report-lifecycle.test.mjs",
"dev-runtime:provisioning": "node scripts/run-bun.mjs scripts/dev-runtime-provisioning.mjs",
"dev-runtime:migration": "node scripts/run-bun.mjs scripts/dev-runtime-migration.mjs",
"dev-runtime:postflight": "node scripts/run-bun.mjs scripts/dev-runtime-postflight.mjs",
"dev-runtime:hotfix-audit": "node scripts/dev-runtime-hotfix-audit.mjs",
"rpt004:mvp:e2e": "node scripts/run-bun.mjs scripts/rpt004-mvp-e2e-harness.mjs",
"rpt004:mvp:e2e:live": "node scripts/run-bun.mjs scripts/rpt004-mvp-e2e-harness.mjs --live --url http://74.48.78.17:16666/ --api-url http://74.48.78.17:16667/",
"m3:io:e2e": "node scripts/run-bun.mjs scripts/m3-io-control-e2e.mjs",
"web:layout": "node scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json",
"web:layout:build": "node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json",
-7
View File
@@ -13,7 +13,6 @@ export const checkProfiles = Object.freeze({
{ id: "validate-006-dev-runtime-run-bun", group: "dev-runtime", command: ["node","scripts/run-bun.mjs","scripts/dev-runtime-migration.mjs","--check"] },
{ id: "validate-007-dev-runtime-run-bun", group: "dev-runtime", command: ["node","scripts/run-bun.mjs","scripts/dev-runtime-postflight.mjs","--check"] },
{ id: "validate-008-dev-runtime-dev-runtime-hotfix-audit", group: "dev-runtime", command: ["node","scripts/dev-runtime-hotfix-audit.mjs"] },
{ id: "validate-009-smoke-run-bun", group: "smoke", command: ["node","scripts/run-bun.mjs","scripts/rpt004-mvp-e2e-harness.mjs","--check","--no-write"] },
{ id: "validate-010-artifact-artifact-runtime-readiness-guard", group: "artifact", command: ["node","--check","scripts/artifact-runtime-readiness-guard.mjs"] },
{ id: "validate-011-artifact-artifact-runtime-readiness-guard", group: "artifact", command: ["node","--check","scripts/src/artifact-runtime-readiness-guard.mjs"] },
{ id: "validate-012-dev-runtime-dev-runtime-hotfix-audit", group: "dev-runtime", command: ["node","--check","scripts/dev-runtime-hotfix-audit.mjs"] },
@@ -100,9 +99,6 @@ export const checkProfiles = Object.freeze({
{ id: "check-081-smoke-m3-io-control-e2e-test", group: "smoke", command: ["node","--check","scripts/m3-io-control-e2e.test.mjs"] },
{ id: "check-082-cloud-web-dev-cloud-workbench-smoke-test", group: "cloud-web", command: ["node","--check","scripts/dev-cloud-workbench-smoke.test.mjs"] },
{ id: "check-083-cloud-web-dev-cloud-workbench-layout-smoke", group: "cloud-web", command: ["node","--check","scripts/dev-cloud-workbench-layout-smoke.mjs"] },
{ id: "check-084-smoke-rpt004-mvp-e2e-harness", group: "smoke", command: ["node","--check","scripts/rpt004-mvp-e2e-harness.mjs"] },
{ id: "check-085-smoke-run-bun", group: "smoke", command: ["node","scripts/run-bun.mjs","build","scripts/src/rpt004-mvp-e2e-harness.mjs","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
{ id: "check-086-smoke-rpt004-mvp-e2e-harness-test", group: "smoke", command: ["node","--check","scripts/src/rpt004-mvp-e2e-harness.test.mjs"] },
{ id: "check-089-deploy-validate-artifact-catalog", group: "deploy", command: ["node","--check","scripts/validate-artifact-catalog.mjs"] },
{ id: "check-090-deploy-refresh-artifact-catalog", group: "deploy", command: ["node","--check","scripts/refresh-artifact-catalog.mjs"] },
{ id: "check-091-deploy-refresh-artifact-catalog-test", group: "deploy", command: ["node","--check","scripts/refresh-artifact-catalog.test.mjs"] },
@@ -112,8 +108,6 @@ export const checkProfiles = Object.freeze({
{ id: "check-094-artifact-dev-runtime-base-image", group: "artifact", command: ["node","--check","scripts/dev-runtime-base-image.mjs"] },
{ id: "check-095-artifact-dev-artifact-services", group: "artifact", command: ["node","--check","scripts/src/dev-artifact-services.mjs"] },
{ id: "check-096-artifact-registry-capabilities", group: "artifact", command: ["node","--check","scripts/src/registry-capabilities.mjs"] },
{ id: "check-097-artifact-preflight-dev-base-image", group: "artifact", command: ["node","--check","scripts/preflight-dev-base-image.mjs"] },
{ id: "check-098-artifact-dev-base-image-preflight", group: "artifact", command: ["node","--check","scripts/src/dev-base-image-preflight.mjs"] },
{ id: "check-099-smoke-dev-evidence-blocker-aggregator", group: "smoke", command: ["node","--check","scripts/dev-evidence-blocker-aggregator.mjs"] },
{ id: "check-100-smoke-dev-evidence-blocker-aggregator", group: "smoke", command: ["node","--check","scripts/src/dev-evidence-blocker-aggregator.mjs"] },
{ id: "check-101-smoke-dev-evidence-blocker-aggregator-test", group: "smoke", command: ["node","--check","scripts/src/dev-evidence-blocker-aggregator.test.mjs"] },
@@ -148,7 +142,6 @@ export const checkProfiles = Object.freeze({
{ id: "check-135-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","cmd/hwlab-cloud-api/provision.ts","--check"] },
{ id: "check-136-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","cmd/hwlab-cloud-api/migrate.ts","--check"] },
{ id: "check-137-dev-runtime-run-bun", group: "dev-runtime", command: ["node","scripts/run-bun.mjs","scripts/dev-runtime-postflight.mjs","--check"] },
{ id: "check-138-smoke-run-bun", group: "smoke", command: ["node","scripts/run-bun.mjs","scripts/rpt004-mvp-e2e-harness.mjs","--check","--no-write"] },
{ id: "check-139-smoke-dev-evidence-blocker-aggregator", group: "smoke", command: ["node","scripts/dev-evidence-blocker-aggregator.mjs","--check"] },
{ id: "check-140-smoke-l2-runtime-contract-smoke", group: "smoke", command: ["node","scripts/l2-runtime-contract-smoke.mjs"] },
{ id: "check-142-cloud-web-check", group: "cloud-web", cwd: "web/hwlab-cloud-web", command: ["bun","run","check"] },
+51 -5
View File
@@ -94,11 +94,51 @@ node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io
Run mutating jobs only with reason and lease token when required by cloud-api:
```sh
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ apply-patch --reason "DEV edit" --lease-token <lease-token> --api-base-url <url> --session-token <token> < patch.diff
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ build start --reason "build smoke" --lease-token <lease-token> --api-base-url <url> --session-token <token>
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download start --reason "DEV smoke" --lease-token <lease-token> --api-base-url <url> --session-token <token>
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe reset --reason "reset smoke" --lease-token <lease-token> --api-base-url <url> --session-token <token>
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 write "AT" --reason "UART write smoke" --lease-token <lease-token> --api-base-url <url> --session-token <token>
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \
device-pod-71-freq:workspace:/ apply-patch \
--reason "DEV edit" --lease-token <lease-token> \
--api-base-url <url> --session-token <token> < patch.diff
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \
device-pod-71-freq:workspace:/ put User/new.c \
--reason "DEV edit" --lease-token <lease-token> \
--api-base-url <url> --session-token <token> < file
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \
device-pod-71-freq:workspace:/ rm User/old.c --missing-ok \
--reason "remove obsolete source" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \
device-pod-71-freq:workspace:/ rmdir User/empty \
--reason "remove empty folder" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \
device-pod-71-freq:workspace:/ keil add-source User/new.c --group User \
--reason "sync Keil project" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \
device-pod-71-freq:workspace:/ keil remove-source User/old.c \
--reason "sync Keil project" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \
device-pod-71-freq:workspace:/ build start \
--reason "build smoke" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \
device-pod-71-freq:debug-probe download start \
--reason "DEV smoke" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \
device-pod-71-freq:debug-probe reset \
--reason "reset smoke" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \
device-pod-71-freq:io-probe:/uart/1 write "AT" \
--reason "UART write smoke" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \
device-pod-71-freq:io-probe:/uart/1 jsonrpc gpio.read \
--params-json '{"pin":"PB5"}' --expect-result-field value \
--reason "UART JSON-RPC smoke" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
```
Inspect or cancel jobs through cloud-api:
@@ -125,6 +165,8 @@ Do not treat local files such as `.device-pod/device-pod-71-freq.json` or `DEVIC
## Operation Rules
- Use `device-pod-cli` first for workspace, build, download, reset, UART, and debug-probe tasks.
- 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.
- 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.
- The server-side profile already carries `hostWorkspaceRoot`, `projectWorkspace`, `debugInterface.uv4Path`, probe UID, UART port, and gateway route. Let `cloud-api -> hwlab-device-pod -> device-host-cli` apply those values.
@@ -160,3 +202,7 @@ Do not upload or recreate runner-side `.device-pod/*.json` as the formal profile
## T4
阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:运行 `profile create --pod-id <devicePodId>`,确认返回 `legacy_profile_create_removed`,并提示使用 cloud-api admin API 管理服务端 profile/grant。
## T5
阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:运行 `workspace put/rm/rmdir/keil``io-probe jsonrpc``--dry-run`,确认 JSON plan 中 intent 分别为 `workspace.put``workspace.rm``workspace.rmdir``workspace.keil``io.uart.jsonrpc`,且 `contentB64``text``patch` 不以原文泄露。
File diff suppressed because it is too large Load Diff
+56
View File
@@ -149,6 +149,62 @@ test("device-pod-cli preserves common v0.1 hardware options in REST job args", a
});
});
test("device-pod-cli exposes workspace writes, keil source edits, and UART JSON-RPC as REST jobs", async () => {
const seen: any[] = [];
const 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_${seen.length}`, status: "running" } });
};
const put = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
"--reason", "write file",
"--lease-token", "lease-a",
"device-pod-71-freq:workspace:/projects/app",
"put",
"User/new.c",
"--create-dirs"
], { fetchImpl, stdinText: "int main(void) { return 0; }\n", now: () => "2026-05-29T00:00:00.000Z" });
assert.equal(put.exitCode, 0);
assert.equal(seen[0].body.intent, "workspace.put");
assert.equal(seen[0].body.args.path, "projects/app/User/new.c");
assert.equal(seen[0].body.args.createDirs, true);
assert.equal(Buffer.from(seen[0].body.args.contentB64, "base64").toString("utf8"), "int main(void) { return 0; }\n");
const keil = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
"--reason", "add source",
"--lease-token", "lease-a",
"device-pod-71-freq:workspace:/projects/app",
"keil",
"add-source",
"User/new.c",
"--group", "User"
], { fetchImpl, now: () => "2026-05-29T00:00:00.000Z" });
assert.equal(keil.exitCode, 0);
assert.deepEqual(seen[1].body.args, { action: "add-source", base: "projects/app", path: "User/new.c", group: "User" });
const jsonrpc = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
"--reason", "gpio read",
"--lease-token", "lease-a",
"device-pod-71-freq:io-probe:/uart/1",
"jsonrpc",
"gpio.read",
"--params-json", "{\"pin\":\"PB5\"}",
"--require-jsonrpc-result",
"--expect-result-field", "pin",
"--expect-result-field", "value"
], { fetchImpl, now: () => "2026-05-29T00:00:00.000Z" });
assert.equal(jsonrpc.exitCode, 0);
assert.equal(seen[2].body.intent, "io.uart.jsonrpc");
assert.deepEqual(seen[2].body.args.expectResultField, ["pin", "value"]);
assert.equal(seen[2].body.args.params, "{\"pin\":\"PB5\"}");
});
test("device-pod-cli rejects legacy local profile create in formal mode", async () => {
const result = await runDevicePodCli(["profile", "create", "--pod-id", "device-pod-71-freq"], { now: () => "2026-05-29T00:00:00.000Z" });
assert.equal(result.exitCode, 1);
+87
View File
@@ -163,6 +163,93 @@ test("hwlab-cli client agent trace shows assistant stream text in compact output
assert.equal(result.payload.body.eventsCount, 1);
});
test("hwlab-cli client harness submits waits and audits trace friction", async () => {
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"harness",
"submit",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a",
"--message",
"hello",
"--trace-id",
"trc_harness",
"--conversation-id",
"cnv_harness"
], {
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
return new Response(JSON.stringify({
accepted: true,
status: "running",
resultUrl: "/v1/agent/chat/result/trc_harness",
traceUrl: "/v1/agent/chat/trace/trc_harness"
}), { status: 202 });
}
});
assert.equal(result.exitCode, 0);
assert.equal(result.payload.action, "client.harness.submit");
assert.equal(calls[0].url, "http://web.test/v1/agent/chat");
assert.equal(calls[0].init.headers.prefer, "respond-async");
assert.equal(calls[0].body.shortConnection, true);
let polls = 0;
const wait = await runHwlabCli([
"client",
"harness-opt",
"wait",
"trc_harness",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a",
"--poll-interval-ms",
"1",
"--timeout-ms",
"1000"
], {
fetchImpl: async () => {
polls += 1;
return new Response(JSON.stringify(polls === 1
? { status: "running", traceId: "trc_harness" }
: { status: "completed", traceId: "trc_harness", reply: { content: "done" } }), { status: 200 });
},
sleep: async () => {}
});
assert.equal(wait.exitCode, 0);
assert.equal(wait.payload.timedOut, false);
assert.equal(wait.payload.body.assistantText, "done");
const audit = await runHwlabCli([
"client",
"harness",
"audit",
"trc_harness",
"--require-bootsharp",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a"
], {
fetchImpl: async () => new Response(JSON.stringify({
traceId: "trc_harness",
events: [
{
type: "tool_call",
command: "node /app/tools/device-pod-cli.mjs device-pod-a:workspace:/ put User/main.c"
}
]
}), { status: 200 })
});
assert.equal(audit.exitCode, 0);
assert.equal(audit.payload.status, "friction_detected");
assert.ok(audit.payload.frictionSignals.some((signal) => signal.kind === "workspace_put_text_edit"));
assert.ok(audit.payload.frictionSignals.some((signal) => signal.kind === "missing_bootsharp"));
});
test("hwlab-cli client workbench summary probes Cloud Web non-visual surfaces", async () => {
const seen: string[] = [];
const result = await runHwlabCli(["client", "workbench", "summary", "--base-url", "http://web.test", "--cookie", "session-a", "--pod-id", "device-pod-71-freq"], {
+249 -15
View File
@@ -61,7 +61,11 @@ function help() {
"device-pod-cli profile list --api-base-url URL --session-token TOKEN",
"device-pod-cli profile show --pod-id device-pod-71-freq --api-base-url URL --session-token TOKEN",
"device-pod-cli device-pod-71-freq:workspace:/ ls --api-base-url URL --session-token TOKEN",
"device-pod-cli device-pod-71-freq:workspace:/ put User/new.c --reason TEXT --lease-token TOKEN < file",
"device-pod-cli device-pod-71-freq:workspace:/ rmdir User/empty --reason TEXT --lease-token TOKEN",
"device-pod-cli device-pod-71-freq:workspace:/ keil add-source User/new.c --group User --reason TEXT --lease-token TOKEN",
"device-pod-cli device-pod-71-freq:workspace:/ build start --reason TEXT --lease-token TOKEN",
"device-pod-cli device-pod-71-freq:io-probe:/uart/1 jsonrpc gpio.read --params-json '{\"pin\":\"PB5\'} --reason TEXT --lease-token TOKEN",
"device-pod-cli job output --pod-id device-pod-71-freq <jobId> --api-base-url URL --session-token TOKEN",
"device-pod-cli lease acquire --pod-id device-pod-71-freq --reason TEXT --api-base-url URL --session-token TOKEN"
]
@@ -207,23 +211,117 @@ async function buildJob(selector: any, operation: string, rest: string[], parsed
let intent = "";
if (selector.surface === "workspace") {
const basePath = selector.path || ".";
if (operation === "ls") { intent = "workspace.ls"; args.path = joinPath(basePath, rest[1] || ""); }
else if (operation === "cat") { intent = "workspace.cat"; args.path = joinPath(basePath, rest[1] || ""); args.maxBytes = numberOption(parsed.maxBytes ?? parsed.limit); }
else if (operation === "rg") { intent = "workspace.rg"; args.pattern = requiredText(rest[1], "pattern"); args.path = joinPath(basePath, rest[2] || ""); Object.assign(args, passthroughOptions(parsed, ["glob", "g", "filesWithMatches", "l", "ignoreCase", "i", "maxCount"])); }
else if (operation === "apply-patch") { intent = "workspace.apply-patch"; args.path = joinPath(basePath, rest[1] || ""); args.patch = await patchText(parsed, stdinText); }
else if (operation === "build") { intent = "workspace.build"; args.action = rest[1] || "start"; Object.assign(args, passthroughOptions(parsed, ["target", "timeoutMs"])); }
if (operation === "ls") {
intent = "workspace.ls";
args.path = joinPath(basePath, rest[1] || "");
}
else if (operation === "cat") {
intent = "workspace.cat";
args.path = joinPath(basePath, rest[1] || "");
args.maxBytes = numberOption(parsed.maxBytes ?? parsed.limit);
}
else if (operation === "rg") {
intent = "workspace.rg";
args.pattern = requiredText(rest[1], "pattern");
args.path = joinPath(basePath, rest[2] || "");
Object.assign(args, passthroughOptions(parsed, [
"glob",
"g",
"filesWithMatches",
"l",
"ignoreCase",
"i",
"maxCount"
]));
}
else if (operation === "put") {
intent = "workspace.put";
args.path = joinPath(basePath, requiredText(rest[1] ?? parsed.path, "path"));
Object.assign(
args,
await contentPayload(parsed, stdinText),
passthroughOptions(parsed, ["encoding", "charset", "createOnly", "createDirs", "updateOnly"])
);
}
else if (operation === "rm") {
intent = "workspace.rm";
args.path = joinPath(basePath, requiredText(rest[1] ?? parsed.path, "path"));
Object.assign(args, passthroughOptions(parsed, ["missingOk"]));
}
else if (operation === "rmdir") {
intent = "workspace.rmdir";
args.path = joinPath(basePath, requiredText(rest[1] ?? parsed.path, "path"));
}
else if (operation === "keil") {
intent = "workspace.keil";
args.action = requiredText(rest[1], "keil action");
args.base = basePath;
args.path = text(rest[2] ?? parsed.path ?? parsed.source ?? parsed.file ?? parsed.src);
Object.assign(args, passthroughOptions(parsed, ["group", "groupName", "target", "timeoutMs"]));
}
else if (operation === "apply-patch") {
intent = "workspace.apply-patch";
args.base = joinPath(basePath, rest[1] || "");
args.patch = await patchText(parsed, stdinText);
Object.assign(args, passthroughOptions(parsed, ["encoding", "charset", "createDirs"]));
}
else if (operation === "build") {
intent = "workspace.build";
args.action = rest[1] || "start";
Object.assign(args, passthroughOptions(parsed, ["target", "timeoutMs", "clean", "dryRun"]));
}
else throw cliError("unsupported_workspace_operation", `unsupported workspace operation: ${operation}`);
} else if (selector.surface === "debug-probe") {
if (operation === "chip-id") intent = "debug.chip-id";
else if (operation === "download") { intent = "debug.download"; args.action = rest[1] || "start"; Object.assign(args, passthroughOptions(parsed, ["target", "timeoutMs", "captureUart", "captureDurationMs", "durationMs", "port", "baudRate"])); }
else if (operation === "download") {
intent = "debug.download";
args.action = rest[1] || "start";
Object.assign(args, passthroughOptions(parsed, [
"target",
"timeoutMs",
"captureUart",
"captureDurationMs",
"durationMs",
"port",
"baudRate"
]));
}
else if (operation === "reset") intent = "debug.reset";
else throw cliError("unsupported_debug_operation", `unsupported debug-probe operation: ${operation}`);
} else if (selector.surface === "io-probe") {
const uartId = normalizeProbePath(selector.path || "uart/1");
if (operation === "ports") { intent = "io.ports"; args.uartId = uartId; }
else if (operation === "read") { intent = "io.uart.read"; args.uartId = uartId; Object.assign(args, passthroughOptions(parsed, ["durationMs", "port", "baudRate"])); }
else if (operation === "read-after-launch-flash") { intent = "io.uart.read-after-launch-flash"; args.uartId = uartId; Object.assign(args, passthroughOptions(parsed, ["durationMs", "port", "baudRate", "flashBase", "skipHardwareReset", "connectMode", "timeoutMs"])); }
else if (operation === "write") { intent = "io.uart.write"; args.uartId = uartId; args.message = text(rest[1] ?? parsed.message ?? parsed.text ?? parsed.data); args.hex = parsed.hex === true; Object.assign(args, passthroughOptions(parsed, ["port", "baudRate"])); }
else if (operation === "read") {
intent = "io.uart.read";
args.uartId = uartId;
Object.assign(args, passthroughOptions(parsed, ["durationMs", "port", "baudRate"]));
}
else if (operation === "read-after-launch-flash") {
intent = "io.uart.read-after-launch-flash";
args.uartId = uartId;
Object.assign(args, passthroughOptions(parsed, [
"durationMs",
"port",
"baudRate",
"flashBase",
"skipHardwareReset",
"connectMode",
"timeoutMs"
]));
}
else if (operation === "write") {
intent = "io.uart.write";
args.uartId = uartId;
args.message = text(rest[1] ?? parsed.message ?? parsed.text ?? parsed.data);
args.hex = parsed.hex === true;
Object.assign(args, passthroughOptions(parsed, ["port", "baudRate"]));
}
else if (operation === "jsonrpc") {
intent = "io.uart.jsonrpc";
args.uartId = uartId;
args.method = text(rest[1] ?? parsed.method);
Object.assign(args, jsonRpcArgs(parsed), passthroughOptions(parsed, uartJsonRpcOptionKeys));
}
else throw cliError("unsupported_io_operation", `unsupported io-probe operation: ${operation}`);
}
return clean({ intent, args: clean(args), reason: text(parsed.reason), leaseToken: text(parsed.leaseToken) });
@@ -259,18 +357,31 @@ function parseOptions(argv: string[]): ParsedArgs {
const out: ParsedArgs = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const item = argv[i] ?? "";
if (/^-[A-Za-z]$/u.test(item)) {
const key = item.slice(1);
const next = argv[i + 1];
if (next && !next.startsWith("-")) { setOption(out, key, next); i += 1; }
else setOption(out, key, true);
continue;
}
if (!item.startsWith("--")) { out._.push(item); continue; }
const eq = item.indexOf("=");
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) { out[key] = item.slice(eq + 1); continue; }
if (eq >= 0) { setOption(out, key, item.slice(eq + 1)); continue; }
const next = argv[i + 1];
if (next && !next.startsWith("--")) { out[key] = next; i += 1; }
else out[key] = true;
if (next && !next.startsWith("--")) { setOption(out, key, next); i += 1; }
else setOption(out, key, true);
}
return out;
}
function setOption(out: ParsedArgs, key: string, value: unknown) {
if (out[key] === undefined) out[key] = value;
else if (Array.isArray(out[key])) (out[key] as unknown[]).push(value);
else out[key] = [out[key], value];
}
function parseSelector(raw: string) {
const match = raw.match(/^([^:]+):(workspace|debug-probe|io-probe)(?::(.*))?$/u);
return match ? { podId: match[1], surface: match[2], path: match[2] === "io-probe" ? normalizeProbePath(match[3] || "") : normalizePath(match[3] || ".") } : null;
@@ -286,14 +397,59 @@ function passthroughOptions(parsed: ParsedArgs, keys: string[]) {
return out;
}
const MUTATING_INTENTS = new Set(["workspace.apply-patch", "workspace.build", "debug.download", "debug.reset", "io.uart.read-after-launch-flash", "io.uart.write"]);
const MUTATING_INTENTS = new Set([
"workspace.apply-patch",
"workspace.build",
"workspace.put",
"workspace.rm",
"workspace.rmdir",
"workspace.keil",
"debug.download",
"debug.reset",
"io.uart.read-after-launch-flash",
"io.uart.write",
"io.uart.jsonrpc"
]);
const uartJsonRpcOptionKeys = [
"id",
"request",
"requestB64",
"params",
"paramsB64",
"responseTimeoutMs",
"durationMs",
"retry",
"retries",
"retryDelayMs",
"lineEnding",
"noNewline",
"lineDelimited",
"discardBefore",
"requireResponse",
"requireJson",
"requireJsonrpc",
"requireJsonrpcResult",
"allowIdMismatch",
"port",
"baudRate"
];
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", "device-pod-cli 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 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) { return { ok: false, action, status: "failed", error: errorSummary(error), ...(error?.details ? { details: error.details } : {}) }; }
function failure(action: string, error: any) {
const summary = errorSummary(error);
return {
ok: false,
action,
status: "failed",
error: summary,
...patchHintForText(summary.message),
...(error?.details ? { details: error.details } : {})
};
}
function cliError(code: string, message: string, details: Record<string, unknown> = {}) { return Object.assign(new Error(message), { code, details }); }
function errorSummary(error: any) { return { code: error?.code ?? "device_pod_cli_error", message: error?.message ?? String(error), ...(error?.details ? { details: error.details } : {}) }; }
function requiredOption(parsed: ParsedArgs, env: EnvLike, key: string, envKey: string) { return requiredText(parsed[key] ?? env[envKey], key); }
@@ -301,6 +457,7 @@ function requiredText(value: unknown, field: string) { const valueText = text(va
function text(value: unknown) { return String(value ?? "").trim(); }
function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; }
function clean<T extends Record<string, any>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== false)) as T; }
function arrayOption(value: unknown) { return (Array.isArray(value) ? value : value === undefined ? [] : [value]).map((item) => text(item)).filter(Boolean); }
function devicePodSeedFromOptions(parsed: ParsedArgs, { required = false } = {}) {
const devicePodText = text(parsed.devicePodJson ?? parsed.devicePod);
if (devicePodText) return normalizeDevicePodSeed(parseJsonObjectOption(devicePodText, "devicePodJson"), "devicePodJson");
@@ -336,6 +493,83 @@ function normalizePath(value: string) { return String(value || ".").trim().repla
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}`); }
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; }
function redactJobRequest(job: any) { return { ...job, args: job.args?.patch ? { ...job.args, patch: undefined, patchBytes: Buffer.byteLength(job.args.patch, "utf8") } : job.args }; }
async function contentPayload(parsed: ParsedArgs, stdinText?: string) {
if (typeof parsed.contentB64 === "string") return { contentB64: parsed.contentB64 };
if (typeof parsed.textB64 === "string") return { textB64: parsed.textB64 };
if (typeof parsed.text === "string" || typeof parsed.content === "string") {
return { text: String(parsed.text ?? parsed.content) };
}
const content = stdinText !== undefined ? stdinText : await readStdinText();
if (!content) {
throw cliError(
"content_required",
"workspace put requires stdin text or --content-b64/--text/--text-b64"
);
}
return { contentB64: Buffer.from(content, "utf8").toString("base64") };
}
async function readStdinText() { const chunks = []; for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); return Buffer.concat(chunks).toString("utf8"); }
function jsonRpcArgs(parsed: ParsedArgs) {
return clean({
request: parsed.requestJson,
params: parsed.paramsJson ?? parsed.params,
expectResultField: arrayOption(parsed.expectResultField ?? parsed.resultField)
});
}
function redactJobRequest(job: any) { return { ...job, args: redactArgs(job.args) }; }
function redactArgs(args: any = {}) {
if (!args || typeof args !== "object") return args;
const next = { ...args };
if (typeof next.patch === "string") {
next.patch = undefined;
next.patchBytes = Buffer.byteLength(args.patch, "utf8");
}
if (typeof next.contentB64 === "string") {
next.contentB64 = undefined;
next.contentBytes = Buffer.from(args.contentB64, "base64").length;
}
if (typeof next.text === "string") {
next.text = undefined;
next.textBytes = Buffer.byteLength(args.text, "utf8");
}
return next;
}
function patchHintForText(message: unknown) {
const textValue = String(message ?? "");
const patchLike = /apply-patch|patch|hunk|\*\*\* Begin Patch|\*\*\* End Patch|Update File|Add File|Delete File/iu;
if (!patchLike.test(textValue)) return {};
return {
patchHint: {
standardForm: [
"*** Begin Patch",
"*** Update File: path/to/file.c",
"@@",
" exact unchanged context line",
"-old line",
"+new line",
"*** End Patch"
],
next: patchHintNext(textValue)
}
};
}
function patchHintNext(message: string) {
const next = [
"Re-read the current target file with workspace cat/rg, then retry a smaller exact-context hunk patch."
];
if (/end with \*\*\* End Patch/iu.test(message)) {
next.unshift("Add a final exact `*** End Patch` line.");
} else if (/start with \*\*\* Begin Patch/iu.test(message)) {
next.unshift("Start the patch with an exact `*** Begin Patch` line.");
} else if (/hunk did not match|must start with @@|invalid update line prefix|ellipsis/iu.test(message)) {
next.unshift("Use exact current context; do not use ellipsis or line-number-only hunks.");
} else if (/unsupported patch header|capitalization|New File/iu.test(message)) {
next.unshift("Use exactly `*** Update File:`, `*** Add File:`, or `*** Delete File:` headers.");
}
next.push("Do not switch to workspace put for existing source unless a smaller hunk cannot safely express the edit.");
return next;
}
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 }; }
+298 -2
View File
@@ -57,6 +57,7 @@ async function clientCommand(context: any) {
if (group === "device-pods" || group === "device-pod") return devicePodsCommand(next);
if (group === "runtime") return runtimeCommand(next);
if (group === "agent") return agentCommand(next);
if (["harness", "harness-ops", "harness-opt"].includes(group)) return harnessCommand(next);
if (group === "workbench") return workbenchCommand(next);
if (group === "rpc") return rpcCommand(next);
if (group === "request") return requestCommand(next);
@@ -84,7 +85,10 @@ function help() {
"hwlab-cli client rpc system.health [--full]",
"hwlab-cli client agent send --message TEXT --provider-profile deepseek --timeout-ms 120000",
"hwlab-cli client agent trace TRACE_ID",
"hwlab-cli client agent cancel 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 audit TRACE_ID --require-bootsharp"
]
});
}
@@ -277,6 +281,172 @@ async function agentCommand(context: any) {
throw cliError("unsupported_agent_command", `unsupported agent command: ${subcommand}`, { subcommand });
}
async function harnessCommand(context: any) {
const subcommand = context.rest[0] || "help";
if (["help", "--help", "-h"].includes(subcommand)) return harnessHelp();
if (subcommand === "health") {
const response = await requestJson({ ...context, method: "GET", path: "/health/live", auth: false });
return responsePayload("client.harness.health", response, context, {
route: route("GET", "/health/live"),
body: responseBodyForCli(response.body, context.parsed)
});
}
if (subcommand === "submit") return harnessSubmit({ ...context, rest: context.rest.slice(1) });
if (subcommand === "result") return harnessResult({ ...context, rest: context.rest.slice(1) });
if (subcommand === "trace") return harnessTrace({ ...context, rest: context.rest.slice(1) });
if (subcommand === "wait") return harnessWait({ ...context, rest: context.rest.slice(1) });
if (subcommand === "audit") return harnessAudit({ ...context, rest: context.rest.slice(1) });
throw cliError("unsupported_harness_command", `unsupported harness command: ${subcommand}`, { subcommand });
}
function harnessHelp() {
return ok("client.harness.help", {
serviceRuntime: false,
imagePublished: false,
aliases: ["harness", "harness-ops", "harness-opt"],
commands: [
"health [--base-url URL]",
"submit --message TEXT|--message-file PATH [--conversation-id ID] [--trace-id ID] [--provider-profile deepseek|codex-api]",
"result TRACE_ID [--full]",
"trace TRACE_ID [--limit N] [--full]",
"wait TRACE_ID [--timeout-ms N] [--poll-interval-ms N]",
"audit TRACE_ID|--trace-file PATH [--strict] [--require-bootsharp]"
]
});
}
async function harnessSubmit(context: any) {
const message = await messageValue(context);
if (!message.trim()) {
throw cliError(
"message_required",
"client harness submit requires --message, --message-file, or stdin text"
);
}
const traceId = text(context.parsed.traceId) || makeId("trc");
const conversationId = text(context.parsed.conversationId) || makeId("cnv");
const body = clean({
message,
conversationId,
sessionId: text(context.parsed.sessionId),
threadId: text(context.parsed.threadId),
traceId,
providerProfile: text(context.parsed.providerProfile) || "deepseek",
timeoutMs: numberOption(context.parsed.agentTimeoutMs ?? context.parsed.timeoutMs),
hardTimeoutMs: numberOption(context.parsed.hardTimeoutMs),
gatewayShellTimeoutMs: numberOption(context.parsed.gatewayShellTimeoutMs),
shortConnection: true,
projectId: text(context.parsed.projectId) || "prj_harness_ops"
});
const response = await requestJson({
...context,
method: "POST",
path: "/v1/agent/chat",
body,
timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS,
extraHeaders: {
"x-trace-id": traceId,
prefer: "respond-async",
"x-hwlab-short-connection": "1"
}
});
return responsePayload("client.harness.submit", response, context, {
route: route("POST", "/v1/agent/chat"),
traceId,
conversationId,
accepted: response.body?.accepted ?? false,
resultUrl: response.body?.resultUrl ?? `/v1/agent/chat/result/${encodeURIComponent(traceId)}`,
traceUrl: response.body?.traceUrl ?? `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`,
body: responseBodyForCli(response.body, context.parsed)
});
}
async function harnessResult(context: any) {
const traceId = requiredTraceId(context.rest[0] ?? context.parsed.traceId);
const pathName = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
const response = await requestJson({ ...context, method: "GET", path: pathName });
return responsePayload("client.harness.result", response, context, {
route: route("GET", pathName),
traceId,
body: responseBodyForCli(response.body, context.parsed)
});
}
async function harnessTrace(context: any) {
const traceId = requiredTraceId(context.rest[0] ?? context.parsed.traceId);
const pathName = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`;
const response = await requestJson({ ...context, method: "GET", path: pathName });
const body = context.parsed.full === true
? response.body
: compactTraceBody(response.body, numberOption(context.parsed.limit) ?? 24);
return responsePayload("client.harness.trace", response, context, {
route: route("GET", pathName),
traceId,
body
});
}
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 pollIntervalMs = Math.max(numberOption(context.parsed.pollIntervalMs) ?? 1000, 250);
const deadline = Date.now() + timeoutMs;
const pathName = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
let polls = 0;
let lastResponse: any = null;
while (Date.now() < deadline) {
polls += 1;
lastResponse = await requestJson({
...context,
method: "GET",
path: pathName,
timeoutMs: Math.min(DEFAULT_TIMEOUT_MS, pollIntervalMs + 2000)
});
if (lastResponse.status === 200 && lastResponse.body?.status && lastResponse.body.status !== "running") {
return responsePayload("client.harness.wait", lastResponse, context, {
route: route("GET", pathName),
traceId,
polls,
timedOut: false,
body: responseBodyForCli(lastResponse.body, context.parsed)
});
}
await context.sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
}
return ok("client.harness.wait", {
status: "timeout",
traceId,
route: route("GET", pathName),
polls,
timedOut: true,
timeoutMs,
body: lastResponse ? responseBodyForCli(lastResponse.body, context.parsed) : null
}, "timeout");
}
async function harnessAudit(context: any) {
let traceObject: any;
if (typeof context.parsed.traceFile === "string") {
traceObject = JSON.parse(await readFile(path.resolve(context.cwd, context.parsed.traceFile), "utf8"));
} else {
const traceId = requiredTraceId(context.rest[0] ?? context.parsed.traceId);
const response = await requestJson({
...context,
method: "GET",
path: `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`
});
traceObject = response.body;
}
const report = auditTrace(traceObject, { requireBootsharp: context.parsed.requireBootsharp === true });
const status = context.parsed.strict === true && report.frictionSignals.length > 0
? "failed"
: "succeeded";
return ok("client.harness.audit", {
traceId: traceObject?.traceId ?? context.rest[0] ?? null,
...report
}, status);
}
async function agentSend(context: any) {
const { parsed } = context;
const message = text(parsed.message ?? parsed.text) || text(context.stdinText);
@@ -444,6 +614,13 @@ async function rpcParamsValue(context: any) {
}
}
async function messageValue(context: any) {
if (typeof context.parsed.message === "string") return context.parsed.message;
if (typeof context.parsed.text === "string") return context.parsed.text;
if (typeof context.parsed.messageFile === "string") return await readFile(path.resolve(context.cwd, context.parsed.messageFile), "utf8");
return text(context.stdinText);
}
async function requestBodyValue(context: any) {
const { parsed } = context;
const raw = typeof parsed.bodyJson === "string"
@@ -661,6 +838,110 @@ function compactAssistantStream(value: any) {
return pruneUndefined({ status: value.status, chunkCount: value.chunkCount, text: value.text, lastChunk: value.lastChunk, updatedAt: value.updatedAt });
}
function compactTraceBody(traceObject: any, limit: number) {
const events = Array.isArray(traceObject?.events) ? traceObject.events : [];
const tail = events.slice(-limit).map((event: any) => ({
at: event.createdAt ?? event.timestamp ?? null,
label: event.label ?? null,
status: event.status ?? null,
waitingFor: event.waitingFor ?? null,
message: preview(event.message ?? event.command ?? event.error ?? null, 240)
}));
return pruneUndefined({
traceId: traceObject?.traceId ?? null,
status: traceObject?.status ?? null,
waitingFor: traceObject?.waitingFor ?? traceObject?.lastEvent?.waitingFor ?? null,
eventCount: traceObject?.eventCount ?? events.length,
lastEvent: traceObject?.lastEvent ?? events.at(-1) ?? null,
eventTail: tail,
fullBodyAvailable: true
});
}
function auditTrace(traceObject: any, { requireBootsharp = false } = {}) {
const events = Array.isArray(traceObject?.events) ? traceObject.events : [];
if (traceObject?.status === "missing" && events.length === 0) {
return {
status: "trace_missing",
summary: { hasHwpod: false, hasBootsharp: false, hasApplyPatch: false, frictionCount: 0 },
frictionSignals: [],
recommendation: "Trace is missing. Check the traceId and poll result/trace after submit."
};
}
const textValue = commandTextFromEvents(events);
const signals = [
signal(
"low_level_tran",
/\/app\/tools\/(?:hwlab-gateway-)?tran\.mjs/u,
textValue,
"Use named device-pod operations through the CLI instead of low-level transport."
),
signal(
"long_device_pod_cli_path",
/node\s+\/app\/(?:tools\/device-pod-cli\.mjs|skills\/device-pod-cli\/scripts\/device-pod-cli\.mjs)/u,
textValue,
"Prefer the main hwlab-cli client entry or short documented device-pod command."
),
signal(
"temporary_script_workaround",
temporaryScriptPattern,
textValue,
"Add or use a named device-pod operation instead of staging temporary scripts."
),
signal(
"workspace_put_text_edit",
/(?:\S*workspace:\/\S*\s+put\s+(?!status\b)\S+|\bworkspace\s+put\s+(?!status\b)\S+)/u,
textValue,
"Workspace put is available, but source text edits should prefer apply-patch unless whole-file write is intentional."
),
signal(
"status_shell_parsing",
statusShellParsingPattern,
textValue,
"Use compact JSON fields returned by harness result/trace instead of shell parsing."
),
requireBootsharp && !/\bbootsharp\b/u.test(textValue)
? {
kind: "missing_bootsharp",
count: 1,
examples: [],
hint: "No bootsharp/bootstrap device-pod context call was found in retained trace events."
}
: null
].filter(Boolean);
return {
status: signals.length > 0 ? "friction_detected" : "clean",
summary: {
hasHwpod: /\bhwpod\b/u.test(textValue),
hasBootsharp: /\bbootsharp\b/u.test(textValue),
hasApplyPatch: /\bapply-patch\b/u.test(textValue),
frictionCount: signals.length
},
frictionSignals: signals,
recommendation: signals.length > 0
? "Treat these as tool-friction signals and improve named CLI/device-pod operations; this is not a runtime gate."
: "No obvious device-pod transport friction in retained trace events."
};
}
const temporaryScriptPattern = /(?:cat\s*>\s*\S+\.(?:py|ps1|js|mjs|bat)\b|upload\b[^\n]*\.(?:py|ps1|js|mjs|bat)\b|(?:python|powershell|pwsh)\s+\S+\.(?:py|ps1)\b)/iu;
const statusShellParsingPattern = /(?:device-pod-cli|hwpod|hwlab-cli)\b[^\n]*(?:\bstatus\b|\btrace\b)[^\n]*(?:\|\s*(?:grep|head|python3?\s+-c|jq)\b|;\s*done)/iu;
function commandTextFromEvents(events: any[]) {
return events.map((event: any) => {
if (typeof event?.command === "string") return event.command;
const label = String(event?.label ?? "");
const isToolEvent = event?.type === "tool_call" || event?.stage === "tool_call" || label.includes("commandExecution");
return isToolEvent && typeof event?.message === "string" ? event.message : null;
}).filter(Boolean).join("\n");
}
function signal(kind: string, pattern: RegExp, textValue: string, hint: string) {
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
const matches = [...textValue.matchAll(new RegExp(pattern.source, flags))];
return matches.length > 0 ? { kind, count: matches.length, examples: matches.slice(0, 3).map((match) => preview(match[0], 220)), hint } : null;
}
function compactRpcBody(body: any) {
return pruneUndefined({
jsonrpc: body.jsonrpc,
@@ -714,7 +995,9 @@ function safeUser(value: any) {
}
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: status !== "timeout", action, status, ...data }; }
function ok(action: string, data: Record<string, unknown> = {}, status = "succeeded") {
return { ok: !["failed", "timeout"].includes(status), action, status, ...data };
}
function failure(action: string, error: any) { return { ok: false, action, status: "failed", error: errorSummary(error), ...(error?.details ? { details: error.details } : {}) }; }
function errorSummary(error: any) { return { code: error?.code ?? "hwlab_cli_error", message: error?.message ?? String(error), ...(error?.details ? { details: error.details } : {}) }; }
function cliError(code: string, message: string, details: Record<string, unknown> = {}) { return Object.assign(new Error(message), { code, details }); }
@@ -729,3 +1012,16 @@ function pruneUndefined<T extends Record<string, any>>(value: T): T { return Obj
function parseJson(value: string) { if (!value) return null; try { return JSON.parse(value); } catch { return { rawText: value.slice(0, 2000), parseError: true }; } }
function makeId(prefix: string) { return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; }
function wait(ms: number) { return new Promise<void>((resolve) => setTimeout(resolve, ms)); }
function requiredTraceId(value: unknown) {
const result = requiredText(value, "traceId");
if (!/^trc_[A-Za-z0-9_.:-]+$/u.test(result)) {
throw cliError("invalid_trace_id", "traceId must start with trc_", { traceId: result });
}
return result;
}
function preview(value: unknown, max = 500) {
if (value === undefined || value === null) return null;
const result = String(value).replace(/\s+/gu, " ").trim();
return result.length > max ? `${result.slice(0, max)}...` : result;
}