feat: sync g14 harness and device-pod cli
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 probe;cloud-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/reason;CLI 只转发 `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` executor;executor 已实现内部 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。 |
|
||||
|
||||
|
||||
@@ -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 本地 registry;reused 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 remote;flush 不在 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 key;GitHub 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,不得静默丢弃。
|
||||
|
||||
## 性能预算与回归判定
|
||||
|
||||
@@ -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 变更不走常驻服务发布流程。 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user