diff --git a/AGENTS.md b/AGENTS.md index a44471b2..eb0728dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`。 diff --git a/cmd/hwlab-device-pod/main.test.ts b/cmd/hwlab-device-pod/main.test.ts index b8a7cbcc..228c70b4 100644 --- a/cmd/hwlab-device-pod/main.test.ts +++ b/cmd/hwlab-device-pod/main.test.ts @@ -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"], { diff --git a/cmd/hwlab-device-pod/main.ts b/cmd/hwlab-device-pod/main.ts index c9315a2b..9d4488c6 100644 --- a/cmd/hwlab-device-pod/main.ts +++ b/cmd/hwlab-device-pod/main.ts @@ -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) { diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index abf7ce01..8ecc8a44 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.md @@ -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。 | diff --git a/docs/reference/spec-v02-cicd.md b/docs/reference/spec-v02-cicd.md index 4c3fad35..533acff0 100644 --- a/docs/reference/spec-v02-cicd.md +++ b/docs/reference/spec-v02-cicd.md @@ -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-` 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,不得静默丢弃。 ## 性能预算与回归判定 diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 6ac106cc..e3cdbbc4 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -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:` 是 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 ` 和 `client harness audit `。确认它们全部是短连接 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 变更不走常驻服务发布流程。 | diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index b147c519..43e1a17a 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -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; diff --git a/internal/cloud/server-live-builds.test.ts b/internal/cloud/server-live-builds.test.ts index 7f4350ea..8b0174bf 100644 --- a/internal/cloud/server-live-builds.test.ts +++ b/internal/cloud/server-live-builds.test.ts @@ -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()))); - } -}); diff --git a/internal/cloud/server-rest-payloads.ts b/internal/cloud/server-rest-payloads.ts index cc9a0f71..3aaeadee 100644 --- a/internal/cloud/server-rest-payloads.ts +++ b/internal/cloud/server-rest-payloads.ts @@ -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}`; } diff --git a/package.json b/package.json index 6827b314..6b4a52c6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/src/check-plan.mjs b/scripts/src/check-plan.mjs index d217367a..e44c58dc 100644 --- a/scripts/src/check-plan.mjs +++ b/scripts/src/check-plan.mjs @@ -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"] }, diff --git a/skills/device-pod-cli/SKILL.md b/skills/device-pod-cli/SKILL.md index d5a6068e..ed3137c1 100644 --- a/skills/device-pod-cli/SKILL.md +++ b/skills/device-pod-cli/SKILL.md @@ -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 --api-base-url --session-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 --api-base-url --session-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 --api-base-url --session-token -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe reset --reason "reset smoke" --lease-token --api-base-url --session-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 --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \ + device-pod-71-freq:workspace:/ apply-patch \ + --reason "DEV edit" --lease-token \ + --api-base-url --session-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 \ + --api-base-url --session-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 \ + --api-base-url --session-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 \ + --api-base-url --session-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 \ + --api-base-url --session-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 \ + --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \ + device-pod-71-freq:workspace:/ build start \ + --reason "build smoke" --lease-token \ + --api-base-url --session-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 \ + --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs \ + device-pod-71-freq:debug-probe reset \ + --reason "reset smoke" --lease-token \ + --api-base-url --session-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 \ + --api-base-url --session-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 \ + --api-base-url --session-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 `,确认返回 `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` 不以原文泄露。 diff --git a/skills/device-pod-cli/assets/device-host-cli.mjs b/skills/device-pod-cli/assets/device-host-cli.mjs index a42f3c4c..8be040c6 100644 --- a/skills/device-pod-cli/assets/device-host-cli.mjs +++ b/skills/device-pod-cli/assets/device-host-cli.mjs @@ -1,13 +1,12 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; -import { randomBytes } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const VERSION = '0.1.0-mvp'; -const DEFAULT_POD_ID = 'device-pod-71-freq'; const GLOBAL_CLI = parseGlobalCli(process.argv.slice(2)); function parseGlobalCli(argv) { @@ -48,8 +47,61 @@ function cleanArg(value) { return text; } +function decodeBase64Utf8(value, label = 'base64 value') { + if (typeof value !== 'string' || !value) throw new Error(`${label} requires a non-empty base64 value`); + return Buffer.from(value, 'base64').toString('utf8'); +} + +function optionValue(options, ...keys) { + for (const key of keys) { + if (options[key] !== undefined) return options[key]; + } + return undefined; +} + +function optionList(options, ...keys) { + const values = []; + for (const key of keys) { + const value = options[key]; + if (value === undefined || value === false) continue; + values.push(...(Array.isArray(value) ? value : [value])); + } + return values.map((value) => String(value)).filter(Boolean); +} + +function booleanOption(options, ...keys) { + return keys.some((key) => options[key] === true); +} + +function optionString(options, ...keys) { + const value = optionValue(options, ...keys); + if (value === undefined || value === null || value === false || value === true) return undefined; + return String(Array.isArray(value) ? value[0] : value); +} + +function normalizeTextEncoding(value) { + const normalized = String(value || 'utf8').trim().toLowerCase().replace(/[-_]/gu, ''); + if (normalized === 'utf8') return 'utf8'; + if (normalized === 'latin1' || normalized === 'binary') return 'latin1'; + throw new Error(`unsupported text encoding: ${value}. Use utf8 or latin1`); +} + function selectedPodId() { - return GLOBAL_CLI.options.podId || process.env.DEVICE_POD_ID || DEFAULT_POD_ID; + const explicitPodId = GLOBAL_CLI.options.podId || process.env.DEVICE_POD_ID || ''; + if (explicitPodId) return explicitPodId; + const requestedProfile = GLOBAL_CLI.options.profile || process.env.DEVICE_POD_PROFILE || ''; + if (requestedProfile) { + const root = workspaceRoot(); + const file = path.isAbsolute(requestedProfile) ? requestedProfile : path.resolve(root, requestedProfile); + if (fs.existsSync(file)) { + const profile = readJson(file); + const profilePodId = profile.devicePodId || profile.podId; + if (profilePodId) return profilePodId; + } + const basenamePodId = path.basename(file, path.extname(file)); + if (basenamePodId) return basenamePodId; + } + throw new Error('device-pod profile requires --pod-id, DEVICE_POD_ID, or an explicit profile containing podId/devicePodId; no default pod is assumed'); } function workspaceRoot() { @@ -72,13 +124,57 @@ function ok(action, data = {}) { console.log(JSON.stringify({ ok: true, action, generatedAt: nowIso(), data }, null, 2)); } +function emit(action, data = {}, okValue = true, exitCode = 0) { + console.log(JSON.stringify({ ok: okValue, action, generatedAt: nowIso(), data }, null, 2)); + process.exitCode = exitCode; +} + function fail(action, error, details = {}, exitCode = 1) { const message = error instanceof Error ? error.message : String(error); const stack = error instanceof Error && error.stack ? error.stack.split('\n').slice(0, 8) : undefined; - console.log(JSON.stringify({ ok: false, action, generatedAt: nowIso(), error: message, stack, details }, null, 2)); + console.log(JSON.stringify({ ok: false, action, generatedAt: nowIso(), error: message, stack, details, ...hintForHostFailure(action, message) }, null, 2)); process.exitCode = exitCode; } +function hintForHostFailure(action, message) { + if (action === 'device-host-cli' && /patch|hunk|\*\*\* Begin Patch|\*\*\* End Patch|Update File|Add File|Delete File/iu.test(String(message || ''))) { + return { patchHint: patchFailureHint(message) }; + } + return {}; +} + +function patchFailureHint(message = '') { + const text = String(message || ''); + const next = ['Re-read the target file with `hwpod :workspace:/... cat ` or `rg` before retrying.']; + if (/raw file content|Codex patch envelope|patch must start with|patch must end with|one line|stdin heredoc/iu.test(text)) { + next.unshift('For `apply-patch --add-file `, send raw file content through a real multi-line stdin heredoc: `hwpod :workspace:/ apply-patch --add-file User/new.c <<\'EOF\'` then file lines, then `EOF` on its own line. Do not include `*** Begin Patch` with --add-file.'); + } + if (/end with \*\*\* End Patch/iu.test(text)) { + next.unshift('Add a final `*** End Patch` line. The marker is required and must be exactly capitalized.'); + } else if (/start with \*\*\* Begin Patch/iu.test(text)) { + 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|cannot use ellipsis/iu.test(text)) { + next.unshift('Retry with a smaller ordinary hunk using exact current context lines; do not use ellipsis or line-number-only hunks.'); + } else if (/unsupported patch header|capitalization|New File/iu.test(text)) { + next.unshift('Use exactly `*** Update File:`, `*** Add File:`, or `*** Delete File:` headers.'); + } else if (/add file line must start with \+/iu.test(text)) { + next.unshift('For direct `*** Add File:` patches, prefix every content line with `+`; for raw new-file content prefer `apply-patch --add-file < file`.'); + } + next.push('Do not switch to `--replace-file` or `workspace put` for an existing source file unless a smaller hunk cannot safely express the edit.'); + return { + standardForm: [ + '*** Begin Patch', + '*** Update File: path/to/file.c', + '@@', + ' exact unchanged context line', + '-old line', + '+new line', + '*** End Patch' + ], + next + }; +} + function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } @@ -112,11 +208,11 @@ function loadProfile() { } function loadInlineProfile(root) { - const profile = JSON.parse(Buffer.from(GLOBAL_CLI.options.profileJsonB64, 'base64').toString('utf8')); + const profile = JSON.parse(decodeBase64Utf8(GLOBAL_CLI.options.profileJsonB64, '--profile-json-b64')); const actualPodId = profile.devicePodId || profile.podId; const requestedPodId = GLOBAL_CLI.options.podId || process.env.DEVICE_POD_ID || ''; if (requestedPodId && actualPodId && actualPodId !== requestedPodId) throw new Error(`device-pod profile mismatch: requested ${requestedPodId}, profile ${actualPodId}`); - const runtimePath = path.join(devicePodDir(root), '.runtime', `${actualPodId || requestedPodId || DEFAULT_POD_ID}.json`); + const runtimePath = path.join(devicePodDir(root), '.runtime', `${actualPodId || requestedPodId || 'device-pod'}.json`); writeJson(runtimePath, profile); profile.__profilePath = runtimePath; profile.__workspaceRoot = path.resolve(profile.workspaceRoot || root); @@ -244,6 +340,12 @@ function setOption(out, key, value) { else out[key] = [out[key], value]; } +function splitOptionalWorkspacePath(args, fallback = '.') { + const first = args[0]; + if (first === undefined || String(first).startsWith('-')) return { rel: fallback, optionArgs: args }; + return { rel: first, optionArgs: args.slice(1) }; +} + function findFile(candidates) { for (const candidate of candidates.filter(Boolean)) { if (fs.existsSync(candidate)) return candidate; @@ -299,29 +401,203 @@ async function health() { } function listWorkspace(rel) { - const profile = loadProfile(); - const dir = resolveWorkspacePath(profile, rel || '.'); - const entries = fs.readdirSync(dir, { withFileTypes: true }).map((entry) => { - const full = path.join(dir, entry.name); - const stat = fs.statSync(full); - return { name: entry.name, type: entry.isDirectory() ? 'dir' : entry.isFile() ? 'file' : 'other', bytes: stat.size, mtime: stat.mtime.toISOString() }; - }); - ok('workspace.ls', { path: path.relative(profile.__workspaceRoot, dir) || '.', entries }); + return listWorkspaceWithOptions(rel, {}); } -function catWorkspace(rel, limitText) { +function parsePositiveInt(value, fallback, label, min = 0, max = 10) { + if (value === undefined || value === false || value === true || value === '') return fallback; + const number = Number(value); + if (!Number.isInteger(number) || number < min || number > max) throw new Error(`invalid ${label}: ${value}. Use ${min}..${max}`); + return number; +} + +function listWorkspaceWithOptions(rel, options = {}) { + const profile = loadProfile(); + const dir = resolveWorkspacePath(profile, rel || '.'); + const depth = parsePositiveInt(optionValue(options, 'depth'), booleanOption(options, 'tree', 'recursive') ? 2 : 0, 'ls depth', 0, 6); + const limit = parsePositiveInt(optionValue(options, 'limit'), 300, 'ls limit', 1, 2000); + const entries = readWorkspaceEntries(dir, { root: profile.__workspaceRoot }); + const tree = depth > 0 || booleanOption(options, 'tree', 'recursive') + ? buildWorkspaceTree(dir, { root: profile.__workspaceRoot, maxDepth: depth, limit }) + : undefined; + const workspaceRelativePath = path.relative(profile.__workspaceRoot, dir) || '.'; + ok('workspace.ls', { + path: workspaceRelativePath, + workspaceRoot: profile.__workspaceRoot, + selectorPath: normalizeSlashPath(workspaceRelativePath), + entries, + tree, + guidance: workspaceNavigationGuidance(selectedPodId(), workspaceRelativePath) + }); +} + +function readWorkspaceEntries(dir, context) { + return fs.readdirSync(dir, { withFileTypes: true }).map((entry) => { + const full = path.join(dir, entry.name); + const stat = fs.statSync(full); + return { + name: entry.name, + type: entry.isDirectory() ? 'dir' : entry.isFile() ? 'file' : 'other', + path: normalizeSlashPath(path.relative(context.root, full)), + bytes: stat.size, + mtime: stat.mtime.toISOString() + }; + }).sort(sortDirFirstByName); +} + +function sortDirFirstByName(a, b) { + const rank = (entry) => entry.type === 'dir' ? 0 : entry.type === 'file' ? 1 : 2; + return rank(a) - rank(b) || a.name.localeCompare(b.name); +} + +function buildWorkspaceTree(dir, context) { + const out = []; + const maxDepth = context.maxDepth; + const limit = context.limit; + let truncated = false; + function visit(current, depth) { + if (out.length >= limit) { truncated = true; return; } + for (const entry of readWorkspaceEntries(current, context)) { + if (out.length >= limit) { truncated = true; return; } + const full = path.join(current, entry.name); + out.push({ ...entry, depth }); + if (entry.type === 'dir' && depth < maxDepth) visit(full, depth + 1); + } + } + visit(dir, 0); + return { root: normalizeSlashPath(path.relative(context.root, dir)) || '.', maxDepth, limit, truncated, entries: out }; +} + +function workspaceNavigationGuidance(podId, workspaceRelativePath) { + const base = workspaceRelativePath && workspaceRelativePath !== '.' ? `/${normalizeSlashPath(workspaceRelativePath)}` : '/'; + return { + startOfTask: `Run hwpod bootsharp --pod-id ${podId} at the start of a device-pod task and after context compaction/resume.`, + explore: [ + `hwpod ${podId}:workspace:${base} ls --tree --depth 2`, + `hwpod ${podId}:workspace:${base} rg [path]`, + `hwpod ${podId}:workspace:${base} cat --offset 0 --limit 4096` + ], + edit: [ + `hwpod ${podId}:workspace:${base} apply-patch <<'PATCH'`, + `hwpod ${podId}:workspace:${base} apply-patch --add-file <<'EOF'` + ], + note: 'Prefer subdir selectors such as :workspace:/projects/01_baseline so patch paths stay short and relative to the selected base.' + }; +} + +function readAgentsMd(profile, baseDir, options = {}) { + const limit = parsePositiveInt(optionValue(options, 'agents-limit', 'agentsLimit'), 200000, 'AGENTS.md limit', 0, 1000000); + const candidates = [path.join(baseDir, 'AGENTS.md'), path.join(profile.__workspaceRoot, 'AGENTS.md')]; + const found = candidates.find((candidate, index) => fs.existsSync(candidate) && (index === 0 || candidate !== candidates[0])); + if (!found) return { found: false, path: null, content: '', bytes: 0, truncated: false }; + const buffer = fs.readFileSync(found); + const truncated = limit > 0 && buffer.length > limit; + const content = buffer.subarray(0, limit || buffer.length).toString('utf8'); + return { found: true, path: normalizeSlashPath(path.relative(profile.__workspaceRoot, found)) || 'AGENTS.md', bytes: buffer.length, truncated, content }; +} + +function bootsharpWorkspace(rel = '.', options = {}) { + const profile = loadProfile(); + const dir = resolveWorkspacePath(profile, rel || '.'); + const depth = parsePositiveInt(optionValue(options, 'depth'), 2, 'bootsharp depth', 0, 6); + const limit = parsePositiveInt(optionValue(options, 'limit'), 500, 'bootsharp tree limit', 1, 3000); + const workspaceRelativePath = path.relative(profile.__workspaceRoot, dir) || '.'; + ok('workspace.bootsharp', { + podId: selectedPodId(), + path: workspaceRelativePath, + workspaceRoot: profile.__workspaceRoot, + tree: buildWorkspaceTree(dir, { root: profile.__workspaceRoot, maxDepth: depth, limit }), + agentsMd: readAgentsMd(profile, dir, options), + guidance: { + requiredWhen: [ + 'Run this command before the first edit in a device-pod task.', + 'Run this command again after context compaction, session resume, or when the active device-pod/workspace is unclear.' + ], + pathDiscipline: 'Use ls/rg/cat from the selected workspace base before guessing paths. For nested projects, prefer a selector like :workspace:/projects/01_baseline.', + patchDiscipline: 'Use apply-patch through a real multi-line stdin heredoc. Do not create temporary patch files solely to pass content to hwpod. Use apply-patch --add-file for new files; it creates missing parent directories and expects raw file content, not *** Begin Patch envelopes. Do not use put/replace-file for existing source unless ordinary hunks cannot safely express the edit.', + examples: workspaceNavigationGuidance(selectedPodId(), workspaceRelativePath) + } + }); +} + +function catWorkspace(rel, options = {}) { const profile = loadProfile(); const file = resolveWorkspacePath(profile, rel); - const limit = Number(limitText || 40000); - const content = fs.readFileSync(file, 'utf8'); + const encoding = normalizeTextEncoding(options.encoding || options.charset || 'utf8'); + const offset = Number(options.offset || 0); + const limit = Number(options.limit || 40000); + if (!Number.isFinite(offset) || offset < 0) throw new Error(`invalid cat offset: ${options.offset}`); + if (!Number.isFinite(limit) || limit < 0) throw new Error(`invalid cat limit: ${options.limit}`); + const buffer = fs.readFileSync(file); + const chunk = buffer.subarray(offset, Math.min(buffer.length, offset + limit)); + const content = chunk.toString(encoding); ok('workspace.cat', { path: path.relative(profile.__workspaceRoot, file), - bytes: Buffer.byteLength(content), - truncated: Buffer.byteLength(content) > limit, - content: content.length > limit ? content.slice(0, limit) : content, + bytes: buffer.length, + offset, + returnedBytes: chunk.length, + truncated: offset + chunk.length < buffer.length, + encoding, + base64: Boolean(options.base64), + content: options.base64 ? chunk.toString('base64') : content, }); } +function workspacePut(rel, options = {}) { + if (!rel || String(rel).startsWith('-')) throw new Error('workspace put requires a target path'); + const profile = loadProfile(); + const file = resolveWorkspacePath(profile, rel); + const encoding = normalizeTextEncoding(options.encoding || options.charset || 'utf8'); + const parent = path.dirname(file); + if ((options['create-only'] === true || options.createOnly === true) && fs.existsSync(file)) throw new Error(`put target already exists: ${rel}`); + if ((options['update-only'] === true || options.updateOnly === true) && !fs.existsSync(file)) throw new Error(`put target does not exist: ${rel}`); + const content = decodePutBuffer(options, encoding); + if (!fs.existsSync(parent)) { + if (options['create-dirs'] === true || options.createDirs === true) ensureDir(parent); + else throw new Error(`put parent directory not found for ${rel}: ${path.relative(profile.__workspaceRoot, parent) || '.'}. Check the workspace-relative path or pass --create-dirs when creating a new directory intentionally.`); + } + fs.writeFileSync(file, content); + ok('workspace.put', { + path: path.relative(profile.__workspaceRoot, file), + bytes: content.length, + sha256: createHash('sha256').update(content).digest('hex'), + encoding, + createOnly: Boolean(options['create-only'] || options.createOnly), + updateOnly: Boolean(options['update-only'] || options.updateOnly), + createDirs: Boolean(options['create-dirs'] || options.createDirs), + }); +} + +function workspaceRmdir(rel) { + if (!rel || String(rel).startsWith('-')) throw new Error('workspace rmdir requires an empty directory path'); + const profile = loadProfile(); + const dir = resolveWorkspacePath(profile, rel); + if (!fs.existsSync(dir)) throw new Error(`rmdir target not found: ${rel}`); + if (!fs.statSync(dir).isDirectory()) throw new Error(`rmdir target is not a directory: ${rel}`); + const entries = fs.readdirSync(dir); + if (entries.length > 0) throw new Error(`rmdir target is not empty: ${rel}`); + fs.rmdirSync(dir); + ok('workspace.rmdir', { path: path.relative(profile.__workspaceRoot, dir) || '.', removed: true }); +} + +function workspaceRm(rel, options = {}) { + if (!rel || String(rel).startsWith('-')) throw new Error('workspace rm requires a file path'); + const profile = loadProfile(); + const file = resolveWorkspacePath(profile, rel); + const missingOk = Boolean(options['missing-ok'] || options.missingOk); + if (!fs.existsSync(file)) { + if (missingOk) { + ok('workspace.rm', { path: normalizeWorkspaceRel(rel), removed: false, reason: 'missing' }); + return; + } + throw new Error(`rm target not found: ${rel}`); + } + if (!fs.statSync(file).isFile()) throw new Error(`rm target is not a file: ${rel}. Use workspace rmdir for empty directories.`); + const content = fs.readFileSync(file); + fs.rmSync(file, { force: true }); + ok('workspace.rm', { path: path.relative(profile.__workspaceRoot, file), removed: true, bytes: content.length, sha256: createHash('sha256').update(content).digest('hex') }); +} + function walkFiles(root, rel, results, limit = 5000) { if (results.length >= limit) return; const dir = path.join(root, rel); @@ -372,6 +648,23 @@ function rgWorkspace(pattern, rel, options = {}) { ok('workspace.rg', { pattern, root: path.relative(profile.__workspaceRoot, root) || '.', count: filesWithMatches ? fileMatches.length : matches.length, files: filesWithMatches ? fileMatches : undefined, matches: filesWithMatches ? undefined : matches, options: { globs, types, filesWithMatches } }); } +function resolveRgWorkspaceRequest(rest) { + const parsed = parseArgs(rest); + const encodedPattern = parsed['pattern-b64'] || parsed.patternB64; + if (encodedPattern !== undefined) { + return { + pattern: decodeBase64Utf8(encodedPattern, '--pattern-b64'), + rel: parsed._[0] || '.', + options: parsed, + }; + } + return { + pattern: rest[0] || '', + rel: rest[1] || '.', + options: parseArgs(rest.slice(2)), + }; +} + function normalizeList(value) { if (value === undefined || value === false) return []; return (Array.isArray(value) ? value : [value]).map((item) => String(item)).filter(Boolean); @@ -414,7 +707,8 @@ function globToRegExp(glob) { function workspaceApplyPatch(baseRel, options = {}) { const profile = loadProfile(); - const patchText = decodePatchText(options); + const patchText = decodePatchText(profile, options); + const encoding = normalizeTextEncoding(options.encoding || options.charset || 'utf8'); const basePath = resolveWorkspacePath(profile, baseRel || '.'); const baseDir = fs.existsSync(basePath) && fs.statSync(basePath).isFile() ? path.dirname(basePath) : basePath; const operations = parseApplyPatch(patchText); @@ -423,8 +717,11 @@ function workspaceApplyPatch(baseRel, options = {}) { const target = resolvePatchPath(profile, baseDir, operation.path); if (operation.kind === 'add') { if (fs.existsSync(target)) throw new Error(`add target already exists: ${operation.path}`); - ensureDir(path.dirname(target)); - fs.writeFileSync(target, operation.content, 'utf8'); + const parent = path.dirname(target); + if (!fs.existsSync(parent)) { + ensureDir(parent); + } + fs.writeFileSync(target, operation.content, encoding); changed.push(path.relative(profile.__workspaceRoot, target)); continue; } @@ -436,51 +733,90 @@ function workspaceApplyPatch(baseRel, options = {}) { } if (operation.kind === 'update') { if (!fs.existsSync(target)) throw new Error(`update target not found: ${operation.path}`); - const original = fs.readFileSync(target, 'utf8'); + const original = fs.readFileSync(target, encoding); const updated = applyUpdateChunks(original, operation.chunks, operation.path); const destination = operation.movePath ? resolvePatchPath(profile, baseDir, operation.movePath) : target; - ensureDir(path.dirname(destination)); - fs.writeFileSync(destination, updated, 'utf8'); + const parent = path.dirname(destination); + if (!fs.existsSync(parent)) { + if (options['create-dirs'] === true || options.createDirs === true) ensureDir(parent); + else throw new Error(`update destination parent directory not found for ${operation.movePath || operation.path}: ${path.relative(profile.__workspaceRoot, parent) || '.'}. Check the patch path or pass --create-dirs when creating a new directory intentionally.`); + } + fs.writeFileSync(destination, updated, encoding); if (destination !== target) fs.rmSync(target, { force: true }); changed.push(destination === target ? path.relative(profile.__workspaceRoot, target) : `${path.relative(profile.__workspaceRoot, target)} -> ${path.relative(profile.__workspaceRoot, destination)}`); continue; } } - ok('workspace.apply-patch', { base: path.relative(profile.__workspaceRoot, baseDir) || '.', changed, operationCount: operations.length }); + ok('workspace.apply-patch', { base: path.relative(profile.__workspaceRoot, baseDir) || '.', changed, operationCount: operations.length, encoding }); } -function decodePatchText(options) { +function decodePatchText(profile, options) { + const patchFile = optionValue(options, 'patch-file', 'patchFile'); + if (typeof patchFile === 'string') { + const file = resolveWorkspacePath(profile, patchFile); + return fs.readFileSync(file, 'utf8'); + } if (typeof options.patch === 'string') return options.patch; if (typeof options.patchB64 === 'string') return Buffer.from(options.patchB64, 'base64').toString('utf8'); if (typeof options['patch-b64'] === 'string') return Buffer.from(options['patch-b64'], 'base64').toString('utf8'); - throw new Error('workspace apply-patch requires --patch-b64 or --patch'); + throw new Error('workspace apply-patch requires --patch-file, --patch-b64, or --patch'); +} + +function decodePutBuffer(options, encoding) { + const contentB64 = optionValue(options, 'content-b64', 'contentB64'); + if (typeof contentB64 === 'string') return Buffer.from(contentB64, 'base64'); + const textB64 = optionValue(options, 'text-b64', 'textB64'); + if (typeof textB64 === 'string') return Buffer.from(decodeBase64Utf8(textB64, '--text-b64'), encoding); + const text = optionValue(options, 'text'); + if (typeof text === 'string') return Buffer.from(text, encoding); + throw new Error('workspace put requires --content-b64, --text-b64, or --text'); } function resolvePatchPath(profile, baseDir, patchPath) { const raw = String(patchPath || '').replace(/^\/+/, ''); if (!raw || raw.includes('\0')) throw new Error(`invalid patch path: ${patchPath}`); - const resolved = path.resolve(baseDir, raw); const root = profile.__workspaceRoot; + const baseRel = normalizeSlashPath(path.relative(root, baseDir)); + const patchRel = normalizeSlashPath(raw); + const resolved = baseRel && (patchRel === baseRel || patchRel.startsWith(`${baseRel}/`)) + ? path.resolve(root, raw) + : path.resolve(baseDir, raw); const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`; if (resolved !== root && !resolved.startsWith(rootWithSep)) throw new Error(`patch path escapes workspace: ${patchPath}`); return resolved; } +function normalizeSlashPath(value) { + return String(value || '').replace(/\\/gu, '/').replace(/^\/+|\/+$/gu, '').replace(/\/+/gu, '/'); +} + function parseApplyPatch(patchText) { const lines = String(patchText).replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); if (lines[lines.length - 1] === '') lines.pop(); + if (lines[0]?.startsWith('--- ') || lines.some((line) => line.startsWith('+++ '))) { + throw new Error('workspace apply-patch expects Codex patch format, not unified diff. Start with *** Begin Patch and use *** Update File:/*** Add File:/*** Delete File: hunks. Use workspace put only for intentional whole-file replacement.'); + } if (lines[0] !== '*** Begin Patch') throw new Error('patch must start with *** Begin Patch'); if (lines[lines.length - 1] !== '*** End Patch') throw new Error('patch must end with *** End Patch'); const operations = []; let index = 1; while (index < lines.length - 1) { const header = lines[index++]; + if (/^\*\*\*\s+(add|delete|update)\s+file:/iu.test(header) && !/^\*\*\*\s+(Add|Delete|Update) File: /u.test(header)) { + throw new Error(`unsupported patch header capitalization: ${header}. Use exactly *** Update File:, *** Add File:, or *** Delete File:.`); + } + if (header.startsWith('*** New File: ')) { + throw new Error('unsupported patch header: *** New File. Use *** Add File: for patch-created files, or workspace put only for intentional whole-file replacement.'); + } if (header.startsWith('*** Add File: ')) { const filePath = header.slice('*** Add File: '.length).trim(); const content = []; while (index < lines.length - 1 && !lines[index].startsWith('*** ')) { const line = lines[index++]; - if (!line.startsWith('+')) throw new Error(`add file line must start with + for ${filePath}`); + if (line.startsWith('@@')) continue; + if (!line.startsWith('+')) { + throw new Error(`add file line must start with + for ${filePath}. For whole-file creation, prefer workspace apply-patch --add-file ${filePath} < file; if writing a Codex patch directly, omit bare hunk text and prefix every content line with +.`); + } content.push(line.slice(1)); } operations.push({ kind: 'add', path: filePath, content: `${content.join('\n')}\n` }); @@ -501,6 +837,7 @@ function parseApplyPatch(patchText) { continue; } if (!line.startsWith('@@')) throw new Error(`update hunk for ${filePath} must start with @@`); + if (line.includes('...')) throw new Error(`update hunk for ${filePath} cannot use ellipsis in ${line}. Include exact context lines from workspace cat/rg, then retry workspace apply-patch.`); const changes = []; while (index < lines.length - 1 && !lines[index].startsWith('@@') && !lines[index].startsWith('*** ')) { const change = lines[index++]; @@ -527,7 +864,7 @@ function applyUpdateChunks(original, chunks, filePath) { const oldLines = chunk.filter((line) => line.prefix !== '+').map((line) => line.text); const newLines = chunk.filter((line) => line.prefix !== '-').map((line) => line.text); const at = oldLines.length === 0 ? cursor : findSubsequence(current, oldLines, cursor); - if (at < 0) throw new Error(`patch hunk did not match ${filePath}`); + if (at < 0) throw new Error(`patch hunk did not match ${filePath}. Re-read the target with workspace cat/rg and retry with exact context; do not use abbreviated context or ellipsis. Use workspace put only for intentional whole-file replacement.`); current.splice(at, oldLines.length, ...newLines); cursor = at + newLines.length; } @@ -590,7 +927,7 @@ function readJobStatus(kindPrefix, requestedId) { if (!id) throw new Error(`no job found for ${kindPrefix}`); const job = readJson(jobFile(profile, id)); if (job.logFile && fs.existsSync(job.logFile)) job.logTail = shortTail(fs.readFileSync(job.logFile, 'utf8')); - ok(`${kindPrefix}.status`, job); + emit(`${kindPrefix}.status`, job, job.status !== 'failed', job.status === 'failed' ? 1 : 0); } async function runJob(jobId) { @@ -617,26 +954,364 @@ function safeTarget(value) { } function keilLogHasErrors(logText) { + return keilLogSummary(logText).hasErrors; +} + +function keilLogSummary(logText) { const text = String(logText || ''); - const summary = text.match(/(\d+)\s+Error\(s\)/i); - if (summary) return Number(summary[1]) > 0; - return /\b(fatal error|error:|failed)\b/i.test(text); + const summaryMatches = [...text.matchAll(/(\d+)\s+Error\(s\)\s*,\s*(\d+)\s+Warning\(s\)/giu)]; + if (summaryMatches.length) { + const last = summaryMatches[summaryMatches.length - 1]; + const errors = Number(last[1]); + const warnings = Number(last[2]); + return { hasSummary: true, errors, warnings, hasErrors: errors > 0 }; + } + const errorLines = text.split(/\r?\n/u).filter((line) => { + const trimmed = line.trim(); + if (!trimmed) return false; + if (/\bwarning\b/iu.test(trimmed)) return false; + return /\b(fatal error|error:|failed)\b/iu.test(trimmed); + }); + return { hasSummary: false, errors: errorLines.length, warnings: null, hasErrors: errorLines.length > 0, errorLines: errorLines.slice(0, 8) }; +} + +function xmlPosition(text, index) { + const prefix = text.slice(0, Math.max(0, index)); + const lines = prefix.split(/\n/u); + return { line: lines.length, column: lines[lines.length - 1].length + 1 }; +} + +function validateXmlFile(file) { + const text = fs.readFileSync(file, 'utf8'); + const stack = []; + const tagRe = /<[^>]+>/gu; + for (const match of text.matchAll(tagRe)) { + const tag = match[0]; + if (/^<\?/.test(tag) || /^