From 0cf79ecc9d8a784b7712b0b3a58d5e39025ba0dc Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 30 May 2026 14:05:28 +0800 Subject: [PATCH] feat: expose hwlab client pod routes --- docs/reference/spec-v02-hwlab-cli.md | 20 +- internal/build-metadata.mjs | 25 +- internal/cloud/server-live-builds.test.ts | 6 + internal/cloud/server-rest-payloads.ts | 60 +++++ internal/cloud/server.ts | 2 +- tools/hwlab-cli/client.test.ts | 153 +++++++++++- tools/src/hwlab-cli-lib.ts | 291 +++++++++++++++++++++- 7 files changed, 540 insertions(+), 17 deletions(-) diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 7aa065b8..6ac106cc 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -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:` 是 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 变更不走常驻服务发布流程。 | diff --git a/internal/build-metadata.mjs b/internal/build-metadata.mjs index e06e425b..987f05ed 100644 --- a/internal/build-metadata.mjs +++ b/internal/build-metadata.mjs @@ -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 !== "")); +} diff --git a/internal/cloud/server-live-builds.test.ts b/internal/cloud/server-live-builds.test.ts index b9580878..7f4350ea 100644 --- a/internal/cloud/server-live-builds.test.ts +++ b/internal/cloud/server-live-builds.test.ts @@ -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); diff --git a/internal/cloud/server-rest-payloads.ts b/internal/cloud/server-rest-payloads.ts index 7421b692..cc9a0f71 100644 --- a/internal/cloud/server-rest-payloads.ts +++ b/internal/cloud/server-rest-payloads.ts @@ -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, diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index b1eedb39..9bc56966 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -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, diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 64776556..849a5aa5 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -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"); +}); diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index ec157e39..864fd389 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -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>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== false && item !== null)) as T; } +function pruneUndefined>(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((resolve) => setTimeout(resolve, ms)); }