feat: expose hwlab client pod routes

This commit is contained in:
Codex
2026-05-30 14:05:28 +08:00
parent 395216122a
commit 0cf79ecc9d
7 changed files with 540 additions and 17 deletions
+16 -4
View File
@@ -9,7 +9,10 @@
- 提供 WEB 等价的非视觉业务入口:登录鉴权、session 恢复、Device Pod 看板、Code Agent 对话、trace/result 轮询、logout 和工作台 live summary。
- 只走 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`
- 输出默认是 JSON;任何失败都要有 `ok:false``action``status`、HTTP 状态、route 和可定位错误,不允许无 stdout 成功
- 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 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 再手工查找回复。
-`hwlab-cli cicd`、fixture MVP gate 和 CLI 镜像/Job 口径属于废弃路径;开发中遇到这些旧门禁、旧测试或旧预检时直接删除,不再维护兼容。
## 内部架构
@@ -31,10 +34,13 @@
| `hwlab-cli client device-pods status POD` | `GET /v1/device-pods/{pod}/status` | 对应 Device Pod summary/status。 |
| `hwlab-cli client device-pods events POD` | `GET /v1/device-pods/{pod}/events` | 对应纯文本事件流。 |
| `hwlab-cli client device-pods probe POD` | `/debug-probe/chip-id``/io-probe/uart/1``/tail` | 对应 Target/Debug/IO 摘要。 |
| `hwlab-cli client agent send` | `POST /v1/agent/chat` + `GET /result/{trace}` | 以 short connection 提交 Code Agent 消息并轮询结果。 |
| `hwlab-cli client agent trace TRACE` | `GET /v1/agent/chat/trace/{trace}` | 回放 trace。 |
| `hwlab-cli client runtime routes` | `GET /v1/live-builds` | 查询当前工作面 pod,并输出 UniDesk 标准 `pod:` route;不执行透传、不调用 kubectl、不内嵌 UniDesk。 |
| `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 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`。 |
`client` 之外的历史命令不作为 v0.2 验收入口。保留旧命令时只能返回废弃说明或转向 `client`,不得把 fixture 或 dry-run 结果当作 WEB 功能通过证据。
@@ -50,7 +56,11 @@
## T3
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `client agent send --message "你好" --provider-profile deepseek --timeout-ms 120000`,确认响应包含 accepted/result/trace 信息;若后端失败,输出必须包含 traceId、resultUrl 或 runnerTrace 摘要,不能无输出或只给旧 gate blocker。
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `client agent send --message "你好" --provider-profile deepseek --timeout-ms 120000`,确认响应包含 accepted/result/trace 信息和 assistant 回复文本;若后端失败,输出必须包含 traceId、resultUrl 或 runnerTrace 摘要,不能无输出或只给旧 gate blocker。
## T4
阅读 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 会被拒绝。
## 规格的实现情况
@@ -58,6 +68,8 @@
| --- | --- | --- |
| 固定 repo 短连接 client | 目标状态 | `hwlab-cli` 只在 `G14:/root/hwlab-v02` 直接运行。 |
| WEB 等价 API client | 目标状态 | `client` 子命令覆盖 Cloud Web 非视觉业务面。 |
| JSON-RPC 同源 API | 目标状态 | `client rpc` 自动补齐 Web JSON-RPC envelope 的 `meta` 字段。 |
| 通用同源 API request | 目标状态 | `client request` 用于追平低频和新增 WEB API,禁止绝对 URL。 |
| 本地 cookie session | 目标状态 | `.state/hwlab-cli/session.json` 只保存 cookie/session 摘要。 |
| 镜像/Service/Job template | 已废弃 | 相关 deploy、GitOps、artifact 和 Tekton 口径必须删除。 |
| PR/CI/CD/worktree 流程 | 已废弃 | CLI 变更不走常驻服务发布流程。 |
+20 -5
View File
@@ -69,19 +69,30 @@ export function runtimeIdentityFromEnv(env = process.env) {
const environmentDigest = firstNonEmpty(env.HWLAB_ENVIRONMENT_DIGEST, env.HWLAB_IMAGE_DIGEST, null);
const environmentInputHash = firstNonEmpty(env.HWLAB_ENVIRONMENT_INPUT_HASH, null);
const codeInputHash = firstNonEmpty(env.HWLAB_CODE_INPUT_HASH, null);
if (!runtimeMode && !bootRepo && !bootCommit && !bootSh && !environmentDigest) return null;
return {
const pod = runtimePodIdentityFromEnv(env);
if (!runtimeMode && !bootRepo && !bootCommit && !bootSh && !environmentDigest && !pod) return null;
return pruneUndefined({
mode: runtimeMode,
environmentImage,
environmentDigest,
environmentInputHash,
codeInputHash,
boot: {
pod,
boot: pruneUndefined({
repo: bootRepo,
commit: bootCommit,
sh: bootSh
}
};
})
});
}
function runtimePodIdentityFromEnv(env = process.env) {
const name = firstNonEmpty(env.HWLAB_RUNTIME_POD_NAME, env.POD_NAME, env.HOSTNAME, null);
const namespace = firstNonEmpty(env.HWLAB_RUNTIME_NAMESPACE, env.POD_NAMESPACE, env.KUBERNETES_NAMESPACE, env.HWLAB_NAMESPACE, null);
const container = firstNonEmpty(env.HWLAB_RUNTIME_CONTAINER, env.HWLAB_CONTAINER_NAME, null);
const nodeName = firstNonEmpty(env.HWLAB_RUNTIME_NODE_NAME, env.NODE_NAME, null);
if (!name && !namespace && !container && !nodeName) return null;
return pruneUndefined({ name, namespace, container, nodeName });
}
function firstNonEmpty(...values) {
@@ -92,3 +103,7 @@ function firstNonEmpty(...values) {
}
return null;
}
function pruneUndefined(value) {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null && item !== ""));
}
@@ -163,6 +163,9 @@ test("cloud api aggregates live HWLAB build times from health and controlled dep
HWLAB_IMAGE: "127.0.0.1:5000/hwlab/hwlab-cloud-api:apiabcd",
HWLAB_IMAGE_TAG: "apiabcd",
HWLAB_BUILD_CREATED_AT: "2026-05-23T01:00:00.000Z",
HOSTNAME: "hwlab-cloud-api-test-pod",
POD_NAMESPACE: "hwlab-dev",
HWLAB_RUNTIME_CONTAINER: "hwlab-cloud-api",
HWLAB_CLOUD_WEB_SERVICE_URL: "http://live.test/hwlab-cloud-web",
HWLAB_AGENT_MGR_URL: "http://live.test/hwlab-agent-mgr",
HWLAB_AGENT_WORKER_URL: "http://live.test/missing-agent-worker",
@@ -214,6 +217,9 @@ test("cloud api aggregates live HWLAB build times from health and controlled dep
assert.equal(payload.latest.build.metadataSource, "deploy-env:HWLAB_BUILD_CREATED_AT");
assert.equal(payload.latest.build.liveMetadataMatch.metadata.imageTag, "skillsabcd");
assert.match(payload.latest.build.liveHealthMissingReason, / deploy metadata/u);
const api = payload.services.find((service) => service.serviceId === "hwlab-cloud-api");
assert.equal(api.runtime.pod.name, "hwlab-cloud-api-test-pod");
assert.equal(api.runtime.unideskRoute, "G14:k3s:hwlab-dev:pod:hwlab-cloud-api-test-pod:hwlab-cloud-api");
assert.equal(payload.counts.total, payload.services.length);
assert.equal(payload.counts.external, 2);
assert.equal(payload.counts.withBuildTime, 3);
+60
View File
@@ -359,6 +359,7 @@ function liveBuildHealthPayloadRecord(service, result, {
id: commitId,
source: payload.commit?.source ?? "health-payload"
},
runtime: liveBuildRuntimeSummary(service, payload),
revision: payload.revision ?? commitId
};
}
@@ -405,10 +406,69 @@ function mergeLiveHealthOntoMetadataRecord(metadataRecord, { match, liveIdentity
id: commitId !== "unknown" ? commitId : metadataRecord.commit.id,
source: payload.commit?.source ?? metadataRecord.commit.source
},
runtime: liveBuildRuntimeSummary(service, payload),
revision: payload.revision ?? (commitId !== "unknown" ? commitId : metadataRecord.revision)
};
}
function liveBuildRuntimeSummary(service, payload = {}) {
const runtimeSource = payload?.runtimeIdentity ?? payload?.runtime;
const runtime = runtimeSource && typeof runtimeSource === "object" ? runtimeSource : {};
const pod = runtime?.pod && typeof runtime.pod === "object" ? runtime.pod : {};
const podName = firstNonEmpty(
payload?.podName,
runtime?.podName,
pod?.name,
payload?.health?.podName
);
const namespace = firstNonEmpty(
payload?.namespace,
runtime?.namespace,
pod?.namespace,
service.deploy?.namespace,
service.artifact?.namespace
);
const container = firstNonEmpty(
payload?.container,
payload?.containerName,
runtime?.container,
runtime?.containerName,
pod?.container,
pod?.containerName,
defaultRuntimeContainer(service.serviceId)
);
const unideskRoute = podName
? `G14:k3s:${namespace ?? "hwlab-v02"}:pod:${podName}${container ? `:${container}` : ""}`
: null;
return pruneUndefined({
pod: pruneUndefined({
name: podName,
namespace,
container,
nodeName: firstNonEmpty(pod?.nodeName, runtime?.nodeName, payload?.nodeName)
}),
unideskRoute
});
}
function defaultRuntimeContainer(serviceId) {
if (["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod", "hwlab-agent-mgr", "hwlab-agent-skills"].includes(serviceId)) return serviceId;
return "";
}
function firstNonEmpty(...values) {
for (const value of values) {
if (value === null || value === undefined) continue;
const textValue = String(value).trim();
if (textValue) return textValue;
}
return null;
}
function pruneUndefined(value) {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null && item !== ""));
}
function liveBuildIdentityFromHealth({ payload, imageReference, commitId }) {
return {
imageReference,
+1 -1
View File
@@ -165,7 +165,7 @@ export async function buildHealthPayload(options = {}) {
commit: metadata.commit,
image: metadata.image,
build: metadata.build,
runtime: metadata.runtime,
runtimeIdentity: metadata.runtime,
endpoint: env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT,
observedAt: new Date().toISOString(),
db,
+152 -1
View File
@@ -64,6 +64,45 @@ test("hwlab-cli client device-pods uses saved cookie and same Web API paths", as
assert.equal(seen[0].url, "http://web.test/v1/device-pods/device-pod-71-freq/status");
assert.equal(seen[0].headers.cookie, "hwlab_session=session-a");
assert.equal(result.payload.route.path, "/v1/device-pods/device-pod-71-freq/status");
assert.equal(result.payload.body.devicePodId, "device-pod-71-freq");
assert.equal(result.payload.body.fullBodyAvailable, true);
});
test("hwlab-cli client device-pods events compacts event stream by default", async () => {
const result = await runHwlabCli(["client", "device-pods", "events", "pod-1", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], {
fetchImpl: async () => new Response(JSON.stringify({
ok: true,
status: "ok",
events: [{ eventId: "evt-1", level: "info", scope: "job", intent: "debug.chip-id", summary: "running", refs: { traceId: "trc_1" } }],
lines: ["line-1"]
}), { status: 200 })
});
assert.equal(result.exitCode, 0);
assert.equal(result.payload.body.eventsCount, 1);
assert.equal(result.payload.body.events[0].eventId, "evt-1");
assert.equal(result.payload.body.lines[0], "line-1");
assert.equal(result.payload.body.fullBodyAvailable, true);
});
test("hwlab-cli client runtime routes reports UniDesk pod passthrough route", async () => {
const result = await runHwlabCli(["client", "runtime", "routes", "--base-url", "http://web.test", "--service-id", "hwlab-cloud-api"], {
fetchImpl: async () => new Response(JSON.stringify({
ok: true,
status: "ok",
services: [{
serviceId: "hwlab-cloud-api",
runtime: { pod: { name: "hwlab-cloud-api-abc", namespace: "hwlab-v02", container: "hwlab-cloud-api" } }
}]
}), { status: 200 })
});
assert.equal(result.exitCode, 0);
assert.equal(result.payload.items[0].unideskRoute, "G14:k3s:hwlab-v02:pod:hwlab-cloud-api-abc:hwlab-cloud-api");
assert.match(result.payload.items[0].useWith, /bun scripts\/cli\.ts ssh/u);
assert.equal(result.payload.discovery.ok, true);
assert.equal(result.payload.discovery.source, "cloud-web:/v1/live-builds");
assert.equal(result.payload.discovery.readyRouteCount, 1);
});
test("hwlab-cli client agent send submits async and polls result", async () => {
@@ -92,7 +131,7 @@ test("hwlab-cli client agent send submits async and polls result", async () => {
if (String(url).endsWith("/v1/agent/chat")) {
return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_test", resultUrl: "/v1/agent/chat/result/trc_test" }), { status: 202 });
}
return new Response(JSON.stringify({ status: "completed", traceId: "trc_test", conversationId: "cnv_test", message: { role: "assistant", content: "hi" } }), { status: 200 });
return new Response(JSON.stringify({ status: "completed", traceId: "trc_test", conversationId: "cnv_test", reply: { role: "assistant", content: "hi" } }), { status: 200 });
},
sleep: async () => {}
});
@@ -104,6 +143,24 @@ test("hwlab-cli client agent send submits async and polls result", async () => {
assert.equal(calls[1].url, "http://web.test/v1/agent/chat/result/trc_test");
assert.equal(result.payload.traceId, "trc_test");
assert.equal(result.payload.result.body.status, "completed");
assert.equal(result.payload.result.body.assistantText, "hi");
assert.equal(result.payload.result.body.reply.content, "hi");
});
test("hwlab-cli client agent trace shows assistant stream text in compact output", async () => {
const result = await runHwlabCli(["client", "agent", "trace", "trc_test", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], {
fetchImpl: async () => new Response(JSON.stringify({
status: "completed",
traceId: "trc_test",
assistantStreams: [{ status: "streaming", text: "hello from agent", chunkCount: 3 }],
events: [{ status: "completed" }]
}), { status: 200 })
});
assert.equal(result.exitCode, 0);
assert.equal(result.payload.body.assistantText, "hello from agent");
assert.equal(result.payload.body.assistantStreams[0].text, "hello from agent");
assert.equal(result.payload.body.eventsCount, 1);
});
test("hwlab-cli client workbench summary probes Cloud Web non-visual surfaces", async () => {
@@ -122,3 +179,97 @@ test("hwlab-cli client workbench summary probes Cloud Web non-visual surfaces",
assert.equal(seen.includes("http://web.test/v1/device-pods/device-pod-71-freq/status"), true);
assert.equal(result.payload.serviceRuntime, undefined);
});
test("hwlab-cli client request covers arbitrary Cloud Web same-origin API routes", async () => {
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"request",
"GET",
"/v1/access/status",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a",
"--trace-id",
"trc_request"
], {
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return new Response(JSON.stringify({ ok: true, authenticated: true, actor: { username: "admin" }, roles: ["admin"] }), { status: 200 });
}
});
assert.equal(result.exitCode, 0);
assert.equal(calls[0].url, "http://web.test/v1/access/status");
assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-a");
assert.equal(calls[0].init.headers["x-trace-id"], "trc_request");
assert.equal(result.payload.action, "client.request");
assert.equal(result.payload.route.path, "/v1/access/status");
assert.equal(result.payload.body.actor.username, "admin");
assert.equal(result.payload.body.fullBodyAvailable, true);
});
test("hwlab-cli client request compacts array responses by default", async () => {
const result = await runHwlabCli([
"client",
"request",
"GET",
"/v1/device-pods",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a"
], {
fetchImpl: async () => new Response(JSON.stringify([{ id: "pod-1", status: "ready" }, { id: "pod-2", status: "ready" }]), { status: 200 })
});
assert.equal(result.exitCode, 0);
assert.equal(result.payload.body.itemCount, 2);
assert.equal(result.payload.body.items[0].id, "pod-1");
assert.equal(result.payload.body.fullBodyAvailable, true);
});
test("hwlab-cli client rpc mirrors Cloud Web JSON-RPC envelope metadata", async () => {
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"rpc",
"system.health",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a",
"--trace-id",
"trc_rpc",
"--id",
"req_rpc",
"--params-json",
"{}"
], {
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
return new Response(JSON.stringify({ jsonrpc: "2.0", id: "req_rpc", result: { status: "ok" }, meta: { traceId: "trc_rpc", serviceId: "hwlab-cloud-api", environment: "dev" } }), { status: 200 });
}
});
assert.equal(result.exitCode, 0);
assert.equal(calls[0].url, "http://web.test/json-rpc");
assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-a");
assert.equal(calls[0].body.method, "system.health");
assert.deepEqual(calls[0].body.params, {});
assert.deepEqual(calls[0].body.meta, { traceId: "trc_rpc", serviceId: "hwlab-cloud-web", environment: "dev" });
assert.equal(result.payload.action, "client.rpc");
assert.equal(result.payload.rpcMethod, "system.health");
assert.equal(result.payload.body.result.status, "ok");
assert.equal(result.payload.body.fullBodyAvailable, true);
});
test("hwlab-cli client request rejects absolute URLs", async () => {
const result = await runHwlabCli(["client", "request", "GET", "http://internal.test/v1", "--base-url", "http://web.test"], {
fetchImpl: async () => new Response("{}", { status: 200 })
});
assert.equal(result.exitCode, 1);
assert.equal(result.payload.error.code, "invalid_request_path");
});
+285 -6
View File
@@ -55,8 +55,11 @@ async function clientCommand(context: any) {
if (["help", "--help", "-h"].includes(group)) return help();
if (group === "auth") return authCommand(next);
if (group === "device-pods" || group === "device-pod") return devicePodsCommand(next);
if (group === "runtime") return runtimeCommand(next);
if (group === "agent") return agentCommand(next);
if (group === "workbench") return workbenchCommand(next);
if (group === "rpc") return rpcCommand(next);
if (group === "request") return requestCommand(next);
throw cliError("unsupported_client_command", `unsupported client command: ${group}`, { group });
}
@@ -75,7 +78,10 @@ function help() {
"hwlab-cli client auth session",
"hwlab-cli client device-pods list",
"hwlab-cli client device-pods status device-pod-71-freq",
"hwlab-cli client runtime routes",
"hwlab-cli client workbench summary --pod-id device-pod-71-freq",
"hwlab-cli client request GET /v1/access/status [--full]",
"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"
@@ -126,24 +132,115 @@ async function devicePodsCommand(context: any) {
const subcommand = context.rest[0] || "list";
if (subcommand === "list") {
const response = await requestJson({ ...context, method: "GET", path: "/v1/device-pods" });
return responsePayload("client.device-pods.list", response, context, { route: route("GET", "/v1/device-pods") });
return responsePayload("client.device-pods.list", response, context, { route: route("GET", "/v1/device-pods"), body: responseBodyForCli(response.body, context.parsed) });
}
const podId = requiredText(context.parsed.podId ?? context.rest[1], "podId");
if (subcommand === "status" || subcommand === "show") {
const pathName = `/v1/device-pods/${encodeURIComponent(podId)}/status`;
const response = await requestJson({ ...context, method: "GET", path: pathName });
return responsePayload("client.device-pods.status", response, context, { route: route("GET", pathName), devicePodId: podId });
return responsePayload("client.device-pods.status", response, context, { route: route("GET", pathName), devicePodId: podId, body: responseBodyForCli(response.body, context.parsed) });
}
if (subcommand === "events") {
const limit = numberOption(context.parsed.limit) ?? 120;
const pathName = `/v1/device-pods/${encodeURIComponent(podId)}/events?limit=${encodeURIComponent(String(limit))}`;
const response = await requestJson({ ...context, method: "GET", path: pathName });
return responsePayload("client.device-pods.events", response, context, { route: route("GET", pathName), devicePodId: podId });
return responsePayload("client.device-pods.events", response, context, { route: route("GET", pathName), devicePodId: podId, body: responseBodyForCli(response.body, context.parsed) });
}
if (subcommand === "probe") return devicePodProbe(context, podId);
throw cliError("unsupported_device_pods_command", `unsupported device-pods command: ${subcommand}`, { subcommand });
}
async function runtimeCommand(context: any) {
const subcommand = context.rest[0] || "routes";
if (subcommand !== "routes" && subcommand !== "pods") {
throw cliError("unsupported_runtime_command", `unsupported runtime command: ${subcommand}`, { subcommand });
}
const response = await requestJson({ ...context, method: "GET", path: "/v1/live-builds", auth: false });
const services = liveBuildServices(response.body);
const requested = text(context.parsed.serviceId);
const selected = services.filter((item: any) => {
const serviceId = text(item?.serviceId ?? item?.id ?? item?.name);
return !requested || serviceId === requested;
}).map(runtimeRouteSummary).filter(Boolean);
return responsePayload("client.runtime.routes", response, context, {
route: route("GET", "/v1/live-builds"),
discovery: {
ok: true,
source: "cloud-web:/v1/live-builds",
serviceCount: services.length,
routeCount: selected.length,
readyRouteCount: selected.filter((item: any) => Boolean(item.unideskRoute)).length
},
serviceId: requested || null,
items: selected,
body: responseBodyForCli(response.body, context.parsed)
});
}
function liveBuildServices(body: any) {
const builds = Array.isArray(body?.builds) ? body.builds : [];
if (builds.length > 0) return builds;
return Array.isArray(body?.services) ? body.services : [];
}
function runtimeRouteSummary(value: any) {
const serviceId = text(value?.serviceId ?? value?.id ?? value?.name);
const runtime = value?.runtime && typeof value.runtime === "object" ? value.runtime : {};
const pod = runtime?.pod && typeof runtime.pod === "object" ? runtime.pod : {};
const podText = typeof value?.pod === "string" ? value.pod : typeof runtime?.pod === "string" ? runtime.pod : "";
const podName = text(
value?.podName ??
runtime?.podName ??
pod?.name ??
podText ??
value?.live?.podName ??
value?.health?.podName
);
const namespace = text(
value?.namespace ??
runtime?.namespace ??
pod?.namespace ??
value?.live?.namespace ??
value?.desiredState?.namespace
) || "hwlab-v02";
const container = text(
value?.container ??
value?.containerName ??
runtime?.container ??
runtime?.containerName ??
pod?.container ??
pod?.containerName ??
value?.live?.containerName
) || defaultRuntimeContainer(serviceId);
if (!serviceId && !podName) return null;
const unideskRoute = normalizeUnideskPodRoute(
text(value?.unideskRoute ?? runtime?.unideskRoute ?? pod?.unideskRoute),
namespace,
podName,
container
);
return pruneUndefined({
serviceId,
podName,
namespace,
container,
unideskRoute,
source: "cloud-web:/v1/live-builds",
useWith: unideskRoute ? `bun scripts/cli.ts ssh '${unideskRoute}' script -- 'pwd'` : undefined
});
}
function defaultRuntimeContainer(serviceId: string) {
if (["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod", "hwlab-agent-mgr", "hwlab-agent-skills"].includes(serviceId)) return serviceId;
return "";
}
function normalizeUnideskPodRoute(routeValue: string, namespace: string, podName: string, container: string) {
if (podName) return `G14:k3s:${namespace}:pod:${podName}${container ? `:${container}` : ""}`;
if (!routeValue) return null;
return routeValue.replace(/:pod\//gu, ":pod:");
}
async function devicePodProbe(context: any, podId: string) {
const encoded = encodeURIComponent(podId);
const paths = [
@@ -170,7 +267,7 @@ async function agentCommand(context: any) {
const traceId = requiredText(context.rest[1] ?? context.parsed.traceId, "traceId");
const pathName = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`;
const response = await requestJson({ ...context, method: "GET", path: pathName });
return responsePayload("client.agent.trace", response, context, { route: route("GET", pathName), traceId });
return responsePayload("client.agent.trace", response, context, { route: route("GET", pathName), traceId, body: responseBodyForCli(response.body, context.parsed) });
}
if (subcommand === "cancel") {
const traceId = requiredText(context.rest[1] ?? context.parsed.traceId, "traceId");
@@ -276,6 +373,103 @@ async function workbenchCommand(context: any) {
return ok("client.workbench.summary", { status: failed ? "degraded" : "succeeded", baseUrl: baseUrl(context.parsed, context.env), devicePodId: podId, probes }, failed ? "degraded" : "succeeded");
}
async function requestCommand(context: any) {
const method = requiredText(context.rest[0], "method").toUpperCase();
const pathName = normalizeRequestPath(requiredText(context.rest[1], "path"));
const body = await requestBodyValue(context);
const traceId = text(context.parsed.traceId);
const response = await requestJson({
...context,
method,
path: pathName,
body,
auth: context.parsed.public === true ? false : true,
extraHeaders: clean({
...(traceId ? { "x-trace-id": traceId } : {}),
...(context.parsed.preferAsync === true ? { prefer: "respond-async" } : {})
})
});
return responsePayload("client.request", response, context, {
route: route(method, pathName),
requestedPath: pathName,
body: responseBodyForCli(response.body, context.parsed)
});
}
async function rpcCommand(context: any) {
const method = requiredText(context.rest[0] ?? context.parsed.method, "method");
const params = await rpcParamsValue(context);
const traceId = text(context.parsed.traceId) || makeId("trc");
const id = text(context.parsed.id) || makeId("req");
const environment = text(context.parsed.environment) || "dev";
const serviceId = text(context.parsed.serviceId) || "hwlab-cloud-web";
const body = {
jsonrpc: "2.0",
id,
method,
params,
meta: { traceId, serviceId, environment }
};
const response = await requestJson({
...context,
method: "POST",
path: "/json-rpc",
body,
extraHeaders: { "x-trace-id": traceId }
});
return responsePayload("client.rpc", response, context, {
route: route("POST", "/json-rpc"),
rpcMethod: method,
traceId,
requestId: id,
body: responseBodyForCli(response.body, context.parsed)
});
}
async function rpcParamsValue(context: any) {
const { parsed } = context;
const raw = typeof parsed.paramsJson === "string"
? parsed.paramsJson
: typeof parsed.params === "string"
? parsed.params
: typeof parsed.paramsFile === "string"
? await readFile(path.resolve(context.cwd, parsed.paramsFile), "utf8")
: "{}";
try {
const parsedParams = JSON.parse(raw);
if (!parsedParams || typeof parsedParams !== "object" || Array.isArray(parsedParams)) throw new Error("params must be a JSON object");
return parsedParams;
} catch (error) {
throw cliError("invalid_params_json", "rpc params must be a JSON object", { message: error instanceof Error ? error.message : String(error) });
}
}
async function requestBodyValue(context: any) {
const { parsed } = context;
const raw = typeof parsed.bodyJson === "string"
? parsed.bodyJson
: typeof parsed.json === "string"
? parsed.json
: typeof parsed.bodyFile === "string"
? await readFile(path.resolve(context.cwd, parsed.bodyFile), "utf8")
: parsed.bodyStdin === true
? (context.stdinText !== undefined ? context.stdinText : await readStdin())
: null;
if (raw === null) return undefined;
try {
return JSON.parse(raw);
} catch (error) {
throw cliError("invalid_body_json", "request body must be valid JSON", { message: error instanceof Error ? error.message : String(error) });
}
}
function normalizeRequestPath(value: string) {
const pathName = value.trim();
if (!pathName.startsWith("/")) throw cliError("invalid_request_path", "request path must be a Cloud Web relative path starting with /", { path: value });
if (pathName.startsWith("//") || /^[a-z][a-z0-9+.-]*:\/\//iu.test(pathName)) throw cliError("invalid_request_path", "request path must not be an absolute URL", { path: value });
return pathName;
}
async function requestJson({ parsed, env, fetchImpl, cwd, method, path: pathName, body, auth = true, extraHeaders = {}, timeoutMs }: any) {
const url = `${baseUrl(parsed, env)}${pathName}`;
const controller = new AbortController();
@@ -387,11 +581,95 @@ function responsePayload(action: string, response: any, context: any, extra: Rec
}
function compactProbe(method: string, pathName: string, response: any) {
return { ok: isHttpSuccess(response) && response.body?.ok !== false, httpStatus: response.status, route: route(method, pathName), body: compactBody(response.body) };
return { ok: isHttpSuccess(response) && response.body?.ok !== false, httpStatus: response.status, route: route(method, pathName), body: compactApiBody(response.body) };
}
function compactResponse(response: any) {
return { httpStatus: response.status, route: route(response.method, response.path), body: compactBody(response.body) };
return { httpStatus: response.status, route: route(response.method, response.path), body: compactApiBody(response.body) };
}
function responseBodyForCli(body: any, parsed: ParsedArgs) {
return parsed.full === true ? body : compactApiBody(body);
}
function compactApiBody(body: any): any {
if (Array.isArray(body)) return { itemCount: body.length, items: body.slice(0, 20).map(compactApiBody), fullBodyAvailable: true };
if (!body || typeof body !== "object") return body;
if (body.jsonrpc === "2.0" || Object.hasOwn(body, "result")) return compactRpcBody(body);
return pruneUndefined({
...compactBody(body),
assistantText: assistantText(body),
reply: compactReply(body.reply ?? body.message),
id: body.id,
eventId: body.eventId,
jobId: body.jobId,
name: body.name,
devicePodId: body.devicePodId,
targetId: body.targetId,
profile: body.profile,
ts: body.ts,
level: body.level,
scope: body.scope,
intent: body.intent,
route: body.route,
environment: body.environment,
adapter: body.adapter,
sourceKind: body.sourceKind,
liveBackend: body.liveBackend,
rowCount: body.rowCount,
columns: Array.isArray(body.columns) ? body.columns : undefined,
roles: Array.isArray(body.roles) ? body.roles : undefined,
routes: body.routes && typeof body.routes === "object" ? Object.keys(body.routes) : undefined,
devicePods: Array.isArray(body.devicePods) ? body.devicePods.slice(0, 20).map(compactApiBody) : undefined,
events: Array.isArray(body.events) ? body.events.slice(0, 20).map(compactApiBody) : undefined,
lines: Array.isArray(body.lines) ? body.lines.slice(0, 20) : undefined,
assistantStreams: Array.isArray(body.assistantStreams) ? body.assistantStreams.slice(-3).map(compactAssistantStream) : undefined,
refs: body.refs,
eventCount: body.eventCount,
elapsedMs: body.elapsedMs,
methodsCount: Array.isArray(body.methods) ? body.methods.length : undefined,
rowsCount: Array.isArray(body.rows) ? body.rows.length : undefined,
devicePodsCount: Array.isArray(body.devicePods) ? body.devicePods.length : undefined,
eventsCount: Array.isArray(body.events) ? body.events.length : undefined,
linesCount: Array.isArray(body.lines) ? body.lines.length : undefined,
messagesCount: Array.isArray(body.messages) ? body.messages.length : undefined,
assistantStreamsCount: Array.isArray(body.assistantStreams) ? body.assistantStreams.length : undefined,
servicesCount: Array.isArray(body.services) ? body.services.length : undefined,
fullBodyAvailable: true
});
}
function assistantText(body: any) {
const direct = text(body?.reply?.content ?? body?.message?.content ?? body?.assistantText);
if (direct) return direct;
if (Array.isArray(body?.assistantStreams)) {
for (const item of [...body.assistantStreams].reverse()) {
const value = text(item?.text ?? item?.lastChunk);
if (value) return value;
}
}
return undefined;
}
function compactReply(value: any) {
if (!value || typeof value !== "object") return undefined;
return pruneUndefined({ messageId: value.messageId, role: value.role, content: value.content, createdAt: value.createdAt });
}
function compactAssistantStream(value: any) {
if (!value || typeof value !== "object") return undefined;
return pruneUndefined({ status: value.status, chunkCount: value.chunkCount, text: value.text, lastChunk: value.lastChunk, updatedAt: value.updatedAt });
}
function compactRpcBody(body: any) {
return pruneUndefined({
jsonrpc: body.jsonrpc,
id: body.id,
error: body.error,
meta: body.meta,
result: body.result && typeof body.result === "object" ? compactApiBody(body.result) : body.result,
fullBodyAvailable: true
});
}
function compactBody(body: any) {
@@ -447,6 +725,7 @@ function textOrNull(value: unknown) { const result = text(value); return result
function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw cliError("missing_required_value", `${field} is required`, { field }); return result; }
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 && item !== null)) as T; }
function pruneUndefined<T extends Record<string, any>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T; }
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)); }