From 395216122ac3919fbf3b86428164505d3d66c376 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 30 May 2026 12:31:07 +0800 Subject: [PATCH] fix: make hwlab cli a short web client --- AGENTS.md | 12 +- deploy/artifact-catalog.dev.json | 51 +- deploy/deploy.json | 17 - deploy/deploy.schema.json | 1 - deploy/k8s/base/services.yaml | 26 - deploy/k8s/base/workloads.yaml | 39 - docs/reference/g14-gitops-cicd.md | 2 +- .../spec-v02-documentation-governance.md | 2 +- docs/reference/spec-v02-hwlab-cli.md | 66 +- docs/reference/spec-v02-services.md | 11 +- .../mvp/m5-e2e/dev-acceptance-checklist.json | 556 ------- fixtures/mvp/m5-e2e/dry-run-plan.json | 1353 ----------------- .../mvp/m5-e2e/evidence/agent-operation.txt | 6 - .../mvp/m5-e2e/evidence/direct-operation.txt | 6 - fixtures/mvp/runtime.json | 85 -- internal/cloud/server-rest-payloads.ts | 2 - internal/mvp-gate/summary.mjs | 544 ++----- internal/protocol/index.mjs | 1 - package.json | 6 +- scripts/artifact-publish.mjs | 18 +- scripts/cloud-api-runtime-smoke.mjs | 2 +- scripts/dev-m4-agent-loop-smoke.mjs | 4 +- scripts/g14-gitops-render.mjs | 5 - scripts/g14-gitops-render.test.ts | 1 - scripts/l6-cli-web-smoke.mjs | 80 - scripts/m1-contract-smoke.mjs | 45 +- scripts/m5-mvp-e2e-dry-run.mjs | 632 -------- scripts/refresh-artifact-catalog.mjs | 1 - scripts/refresh-artifact-catalog.test.mjs | 1 - scripts/src/check-plan.mjs | 6 +- scripts/src/deploy-contract-plan.mjs | 2 - scripts/src/dev-artifact-services.mjs | 11 - scripts/src/dev-cloud-workbench-smoke-lib.mjs | 21 - scripts/src/dev-edge-health-smoke-lib.mjs | 2 +- scripts/src/g14-ci-plan-lib.mjs | 3 - scripts/src/rpt004-mvp-e2e-harness.test.mjs | 1 - scripts/validate-runtime-boundary.mjs | 2 +- tools/hwlab-cli/bin/hwlab-cli.mjs | 28 +- tools/hwlab-cli/bin/hwlab-cli.ts | 4 + tools/hwlab-cli/client.test.ts | 124 ++ tools/hwlab-cli/lib/cli.mjs | 312 ---- tools/hwlab-cli/lib/cli.test.mjs | 117 -- tools/hwlab-cli/package.json | 6 +- tools/src/device-pod-cli-lib.ts | 4 +- tools/src/hwlab-cli-lib.ts | 452 ++++++ 45 files changed, 767 insertions(+), 3903 deletions(-) delete mode 100644 fixtures/mvp/m5-e2e/dev-acceptance-checklist.json delete mode 100644 fixtures/mvp/m5-e2e/dry-run-plan.json delete mode 100644 fixtures/mvp/m5-e2e/evidence/agent-operation.txt delete mode 100644 fixtures/mvp/m5-e2e/evidence/direct-operation.txt delete mode 100644 fixtures/mvp/runtime.json delete mode 100644 scripts/l6-cli-web-smoke.mjs delete mode 100644 scripts/m5-mvp-e2e-dry-run.mjs mode change 100755 => 100644 tools/hwlab-cli/bin/hwlab-cli.mjs create mode 100644 tools/hwlab-cli/bin/hwlab-cli.ts create mode 100644 tools/hwlab-cli/client.test.ts delete mode 100644 tools/hwlab-cli/lib/cli.mjs delete mode 100644 tools/hwlab-cli/lib/cli.test.mjs create mode 100644 tools/src/hwlab-cli-lib.ts diff --git a/AGENTS.md b/AGENTS.md index 0695024b..a44471b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,12 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥官和 runner 进入本仓库时的顶级索引,只放入口、一句话规则和长期参考链接;细则统一维护在 `docs/reference/`。 +## P0 最高优先级:最新要求与旧门禁清除 + +- P0: 用户最新明确要求优先于旧测试、旧门禁、旧预检、旧断言、旧 guard 和旧 gate;开发过程中遇到任何阻碍最新目标的旧测试、旧门禁、旧预检或旧断言,一律拆除,禁止继续维护、修补、叠例外或用兼容路径绕行。 +- P0: 短连接 CLI、临时工具、文档和轻量 helper 不套用常驻服务、镜像、Job、GitOps、PR、CI/CD 或重型发布流程的旧门禁;如果旧流程要求与最新架构定位冲突,以最新架构定位为准并删除旧流程入口。 +- P0: 任何测试、预检或自检只允许表达当前最新目标行为;旧历史断言不得作为回归保护保留,避免把旧路线固化成长期摩擦。 + ## P0 G14 原生 k8s/GitOps 运行面归一 - G14 是当前 HWLAB DEV/PROD 原生 k8s 与 GitOps 运行面真相;`hwlab-dev` 与 `hwlab-prod` 均由 G14 k3s、Tekton、`G14-gitops` 和 Argo CD 管理,详见 [docs/reference/g14-gitops-cicd.md](docs/reference/g14-gitops-cicd.md)。 @@ -59,7 +65,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 ## 规格 -- 规格文档是微服务、稳定外部服务、CLI/Job 模板和系统能力的权威出处;代码开发和测试代码编写必须先对齐对应 `docs/reference/spec-*.md`,再修改实现或测试。 +- 规格文档是微服务、稳定外部服务、短连接 CLI 和系统能力的权威出处;代码开发和测试代码编写必须先对齐对应 `docs/reference/spec-*.md`,再修改实现或测试。 - v0.2 服务规格总览、保留服务清单、稳定外部服务边界和废弃范围:[docs/reference/spec-v02-services.md](docs/reference/spec-v02-services.md);`hwlab-gateway-simu`、`hwlab-box-simu` 和 `hwlab-patch-panel` 已废弃,不再保留 spec。 - v0.2 用户和权限管理规格、code agent session 归属和 device pod 授权:[docs/reference/spec-user-access.md](docs/reference/spec-user-access.md)。 - Device Pod 正式规格、profile 服务端权威、REST/job 和多 devicePodId 服务口径:[docs/reference/spec-device-pod.md](docs/reference/spec-device-pod.md)。 @@ -69,7 +75,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - v0.2 `hwlab-agent-mgr` agent 管理服务规格:[docs/reference/spec-v02-hwlab-agent-mgr.md](docs/reference/spec-v02-hwlab-agent-mgr.md)。 - v0.2 `hwlab-agent-worker` session-scoped Job 模板规格:[docs/reference/spec-v02-hwlab-agent-worker.md](docs/reference/spec-v02-hwlab-agent-worker.md)。 - v0.2 `hwlab-agent-skills` 技能包服务规格:[docs/reference/spec-v02-hwlab-agent-skills.md](docs/reference/spec-v02-hwlab-agent-skills.md)。 -- v0.2 `hwlab-cli` 受控 CLI/Job 模板规格:[docs/reference/spec-v02-hwlab-cli.md](docs/reference/spec-v02-hwlab-cli.md)。 +- v0.2 `hwlab-cli` 固定 repo 短连接 client 规格:[docs/reference/spec-v02-hwlab-cli.md](docs/reference/spec-v02-hwlab-cli.md)。 - v0.2 `hwlab-device-pod` 部署服务规格:[docs/reference/spec-v02-hwlab-device-pod-service.md](docs/reference/spec-v02-hwlab-device-pod-service.md)。 - v0.2 `hwlab-gateway` 硬件 transport 边界规格:[docs/reference/spec-v02-hwlab-gateway.md](docs/reference/spec-v02-hwlab-gateway.md)。 - v0.2 `hwlab-edge-proxy` API edge proxy 规格:[docs/reference/spec-v02-hwlab-edge-proxy.md](docs/reference/spec-v02-hwlab-edge-proxy.md)。 @@ -118,7 +124,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - G14 GitOps 渲染:`npm run g14:gitops:render`;source 分支不再运行生成物 drift check,发布态由 Tekton 写入 `G14-gitops`。 - DEV 依赖 runtime base 构建:`npm run dev-runtime-base:build` - Legacy D601 DEV CD:旧脚本入口已删除;事故回放只读历史 issue/commit,不恢复旧命令。 -- CI/CD job 查询:`node tools/hwlab-cli/bin/hwlab-cli.mjs cicd status|logs|report ` +- 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` diff --git a/deploy/artifact-catalog.dev.json b/deploy/artifact-catalog.dev.json index 8e41528c..4df8ba07 100644 --- a/deploy/artifact-catalog.dev.json +++ b/deploy/artifact-catalog.dev.json @@ -306,41 +306,6 @@ }, "reusedFrom": "497d88f5f34075f2ba064cdc10d3a80a236e96e4eab4ca71ae3138a751201315" }, - { - "serviceId": "hwlab-cli", - "commitId": "278bbe1", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:278bbe1", - "imageTag": "278bbe1", - "digest": "not_published", - "publishState": "skeleton-only", - "profile": "dev", - "namespace": "hwlab-dev", - "healthPath": "/health/live", - "sourceState": "source-present", - "publishEnabled": true, - "artifactRequired": true, - "artifactScope": "required", - "notPublishedReason": "publish_not_run", - "buildCreatedAt": null, - "buildSource": null, - "componentCommitId": "a56ea7e33ef67040cc9a8748c3759b4f9c87ca46", - "componentInputHash": "bff91d0ad2c23aceacc494cd4ad84e93d163c9bcae8815ac4018f2a0f37a0617", - "dockerfileHash": "8b2595a09276479b8809e70e99516c1539a6a7cc200e73266d247176b56be962", - "baseImageReference": "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim", - "baseImageDigest": null, - "buildArgsHash": "bd9fa0f62fb873c87cf8ec47790d26c5068956c14be61eed222f78dc9f7f5c1a", - "ciAffected": false, - "ciReason": [ - "component-inputs-unchanged" - ], - "reuse": { - "status": "ready", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:af46386", - "digest": "sha256:3908855f68043119452bd40109668c7a163c33bf048ddcf0cc9c1097ef741c5a", - "reusedFrom": "bff91d0ad2c23aceacc494cd4ad84e93d163c9bcae8815ac4018f2a0f37a0617" - }, - "reusedFrom": "bff91d0ad2c23aceacc494cd4ad84e93d163c9bcae8815ac4018f2a0f37a0617" - }, { "serviceId": "hwlab-agent-skills", "commitId": "278bbe1", @@ -379,8 +344,8 @@ ], "serviceInventory": { "version": "v2", - "serviceCount": 9, - "requiredServiceCount": 9, + "serviceCount": 8, + "requiredServiceCount": 8, "disabledServiceCount": 0, "requiredServiceIds": [ "hwlab-cloud-api", @@ -390,7 +355,6 @@ "hwlab-device-pod", "hwlab-gateway", "hwlab-edge-proxy", - "hwlab-cli", "hwlab-agent-skills" ], "disabledServiceIds": [], @@ -472,17 +436,6 @@ "entrypoint": "cmd/hwlab-edge-proxy/main.ts", "disabledReason": null }, - { - "serviceId": "hwlab-cli", - "publishEnabled": true, - "artifactRequired": true, - "artifactScope": "required", - "runtimeKind": "cli", - "implementationState": "repo-entrypoint", - "sourceState": "source-present", - "entrypoint": "tools/hwlab-cli/bin/hwlab-cli.mjs", - "disabledReason": null - }, { "serviceId": "hwlab-agent-skills", "publishEnabled": true, diff --git a/deploy/deploy.json b/deploy/deploy.json index 295798c5..5283c6b1 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -75,13 +75,6 @@ "port": 7001, "targetPort": "http" }, - { - "serviceId": "hwlab-cli", - "name": "hwlab-cli", - "namespace": "hwlab-dev", - "port": 7501, - "targetPort": "http" - }, { "serviceId": "hwlab-agent-skills", "name": "hwlab-agent-skills", @@ -283,16 +276,6 @@ "HWLAB_EDGE_PROXY_TIMEOUT_MS": "1260000" } }, - { - "serviceId": "hwlab-cli", - "namespace": "hwlab-dev", - "healthPath": "/health/live", - "profile": "dev", - "replicas": 0, - "env": { - "HWLAB_CLI_ENDPOINT": "http://74.48.78.17:16667" - } - }, { "serviceId": "hwlab-agent-skills", "namespace": "hwlab-dev", diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index 74b4c097..d73f94ab 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -72,7 +72,6 @@ "hwlab-device-pod", "hwlab-gateway", "hwlab-edge-proxy", - "hwlab-cli", "hwlab-agent-skills" ] }, diff --git a/deploy/k8s/base/services.yaml b/deploy/k8s/base/services.yaml index 58735a14..1948ed43 100644 --- a/deploy/k8s/base/services.yaml +++ b/deploy/k8s/base/services.yaml @@ -78,32 +78,6 @@ ] } }, - { - "apiVersion": "v1", - "kind": "Service", - "metadata": { - "name": "hwlab-cli", - "namespace": "hwlab-dev", - "labels": { - "app.kubernetes.io/name": "hwlab-cli", - "hwlab.pikastech.local/service-id": "hwlab-cli", - "hwlab.pikastech.local/activation": "manual" - } - }, - "spec": { - "type": "ClusterIP", - "selector": { - "app.kubernetes.io/name": "hwlab-cli" - }, - "ports": [ - { - "name": "http", - "port": 7501, - "targetPort": "http" - } - ] - } - }, { "apiVersion": "v1", "kind": "Service", diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 1df83852..b6f2de50 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -753,45 +753,6 @@ } } }, - { - "apiVersion": "batch/v1", - "kind": "Job", - "metadata": { - "name": "hwlab-cli-template", - "namespace": "hwlab-dev", - "labels": { - "app.kubernetes.io/name": "hwlab-cli", - "hwlab.pikastech.local/service-id": "hwlab-cli", - "hwlab.pikastech.local/activation": "manual" - } - }, - "spec": { - "suspend": true, - "template": { - "metadata": { - "labels": { - "app.kubernetes.io/name": "hwlab-cli", - "hwlab.pikastech.local/service-id": "hwlab-cli" - } - }, - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "name": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:af46386", - "env": [ - { - "name": "HWLAB_CLI_ENDPOINT", - "value": "http://74.48.78.17:16667" - } - ] - } - ] - } - } - } - }, { "apiVersion": "apps/v1", "kind": "Deployment", diff --git a/docs/reference/g14-gitops-cicd.md b/docs/reference/g14-gitops-cicd.md index f3a981ea..fa12035e 100644 --- a/docs/reference/g14-gitops-cicd.md +++ b/docs/reference/g14-gitops-cicd.md @@ -93,7 +93,7 @@ G14 CI/CD 加速的第一判定标准不是 PipelineRun 总耗时单点变短, - 并发 fan-out:per-service TaskRun 应由 Tekton/k8s scheduler 同时调度,多个 service 的 build/reuse 窗口应接近“最慢单个服务任务耗时”,而不是所有服务串行相加。reuse-only 场景的 fan-out 窗口当前基线约为十几秒;真实组件构建场景仍需按 changed service 单独记录 BuildKit 耗时和 cache hit 情况。 - CD rollout:unchanged service 必须复用 `deploy/artifact-catalog.dev.json` 的 image/digest,pod template 不应因全局 source commit 改变而无意义滚动。手写 manifest(例如 `deepseek-proxy`、device-agent 类辅助 workload)只要复用某个已发布服务镜像,也必须走同一个 catalog image 选择逻辑,不能直接用当前 source commit tag。 - 运行态验证:GitOps promote 成功只说明 `G14-gitops` 分支更新;最终通过必须看 Argo Application revision、sync 状态、目标 namespace Deployment/StatefulSet ready、公网 health。Argo 还停在旧 revision 时,优先做 `argocd.argoproj.io/refresh=hard` 刷新;不要把已经推送的 `G14-gitops` 分支误判成运行面已滚动。 -- Argo health 判定:`spec.suspend=true` 的模板型 Job 会让 Application 总 health 显示 `Suspended`。`hwlab-agent-worker-template`、`hwlab-cli-template` 这类只作为手动启动模板的 Job 必须带 `argocd.argoproj.io/ignore-healthcheck: "true"`,避免 DEV 实际 workload 全部 ready 时仍被误判为 Suspended;真正发布验收仍以长驻 workload ready、公网 health 和失败 Pod 清单为准。 +- Argo health 判定:`spec.suspend=true` 的模板型 Job 会让 Application 总 health 显示 `Suspended`。`hwlab-agent-worker-template` 这类只作为手动启动模板的 Job 必须带 `argocd.argoproj.io/ignore-healthcheck: "true"`,避免 DEV 实际 workload 全部 ready 时仍被误判为 Suspended;`hwlab-cli` 不创建镜像、Service 或 Job template,只在固定 repo 内短连接执行。真正发布验收仍以长驻 workload ready、公网 health 和失败 Pod 清单为准。 当前仍然慢的主要位置: diff --git a/docs/reference/spec-v02-documentation-governance.md b/docs/reference/spec-v02-documentation-governance.md index ab3a80fd..be2c739b 100644 --- a/docs/reference/spec-v02-documentation-governance.md +++ b/docs/reference/spec-v02-documentation-governance.md @@ -46,7 +46,7 @@ | `README.md` | 有效入口信息已由 `AGENTS.md`、[architecture.md](architecture.md)、[dev-runtime-boundary.md](dev-runtime-boundary.md) 和 [spec-v02-services.md](spec-v02-services.md) 承接;全文归档到 #532。 | | `docs/cloud-web-workbench.md` | 稳定前端约束由 [cloud-workbench.md](cloud-workbench.md) 承接;全文归档到 #532。 | | `docs/dev-acceptance-matrix.md`、`docs/dev-gate-*`、`docs/dev-evidence-*` | 旧 DEV/D601 gate 和报告材料仅作历史归档;当前边界见 [dev-runtime-boundary.md](dev-runtime-boundary.md)、[MVP-e2e-acceptance.md](MVP-e2e-acceptance.md) 和 [spec-v02-cicd.md](spec-v02-cicd.md)。 | -| `docs/dev-acceptance-checklist.json` | 从 `docs/` 根目录移出;仍被旧 M5 dry-run 使用的机器 fixture 放到 `fixtures/mvp/m5-e2e/dev-acceptance-checklist.json`,历史全文归档到 #532。 | +| `docs/dev-acceptance-checklist.json` | 旧 M5 dry-run 机器 fixture 已退休并从源码移除;当前 v0.2 非视觉验收走 `hwlab-cli client` 真实 Cloud Web API。历史全文归档到 #532。 | | `docs/m0-*`、`docs/m1-*`、`docs/m3-*`、`docs/m4-*`、`docs/m5-*`、`docs/operator-runbook.md` | 旧里程碑和 operator 阶段材料迁入 #532;当前 M3 判定见 [m3-loop-rollout-runbook.md](m3-loop-rollout-runbook.md)。 | | `docs/topology-constraints.md` | simulator/patch-panel 旧拓扑规格与 `v0.2` 裁撤口径冲突;当前服务取舍见 [spec-v02-services.md](spec-v02-services.md)。 | | `docs/schema-drift-map.md` | 人写摘要归档到 #532;机器 source of truth 仍是 `protocol/schema-drift-map.json`。 | diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 9e9b58b4..7aa065b8 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -1,49 +1,63 @@ -# v0.2 hwlab-cli 服务规格 +# v0.2 hwlab-cli 短连接 Client 规格 -`hwlab-cli` 是 v02 的受控操作 CLI 镜像和 suspended Job template。它不是长驻业务服务;默认通过手动 Job 或本地 repo CLI 执行 JSON 命令。 +`hwlab-cli` 是 v0.2 固定开发目录里的短连接业务 client。它用于让 Codex 和人工在 `G14:/root/hwlab-v02` 直接调用与 Cloud Web 相同的 HTTP API,完成除视觉展示以外的真实业务交互和 E2E 排障。 + +`hwlab-cli` 不属于常驻服务,不发布镜像,不创建 Kubernetes Service、Deployment、CronJob 或 suspended Job template,不进入 GitOps desired state,不作为 CI/CD artifact 构建对象。CLI 变更默认直接在 `G14:/root/hwlab-v02` 固定 repo 修改、提交并推送 `origin/v0.2`;不创建 worktree,不走 PR,不启动或等待 CI/CD。 ## 在系统中的职责划分 -- 提供 HWLAB 操作和验收的 CLI 合同,包括 health、project list、M3 status/io 和 MVP e2e 计划。 -- 明确删除 legacy `hwlab-cli cicd` 路径,CI/CD 只能走 G14 Tekton/GitOps。 -- 与 `hwlab-cloud-api` 交互时必须通过 `HWLAB_CLI_ENDPOINT` 指向 `19667` 或内部 API base,不直连 gateway/patch/simu 服务。 +- 提供 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 成功。 +- 旧 `hwlab-cli cicd`、fixture MVP gate 和 CLI 镜像/Job 口径属于废弃路径;开发中遇到这些旧门禁、旧测试或旧预检时直接删除,不再维护兼容。 ## 内部架构 -- `tools/hwlab-cli/bin/hwlab-cli.mjs` 是唯一 CLI bin 入口。 -- `tools/hwlab-cli/lib/cli.mjs` 负责参数解析、JSON 输出、legacy cicd 拒绝、M3 Skill CLI 转发和 MVP e2e dry-run plan。 -- `hwlab-cli-template` 是 `suspend: true` 的 Kubernetes Job template,运行时通过 env 注入 endpoint、commit 和 image metadata。 -- Device Pod 专用 CLI 是 `tools/device-pod-cli.ts`,稳定 skill wrapper 为 `skills/device-pod-cli/scripts/device-pod-cli.mjs`;它属于 device-pod 受控 REST 客户端,不是 `hwlab-cli` 的 CICD/运维入口。 +- `tools/hwlab-cli/bin/hwlab-cli.ts` 是唯一可执行入口,使用 Bun 运行。 +- `tools/src/hwlab-cli-lib.ts` 承载参数解析、cookie jar、HTTP 请求、轮询和 JSON 输出。 +- `tools/hwlab-cli/bin/hwlab-cli.mjs` 只允许作为兼容 shim 调用 Bun TS 入口;新功能不得继续写入 `.mjs` 旧库。 +- CLI session cookie 默认写入 `.state/hwlab-cli/session.json`;状态只保存 base URL、cookie、actor 摘要和过期时间,不保存 password 或 Secret 原文。 +- 所有 `client` 子命令都是短连接;长耗时 Code Agent 只提交 async request 并短轮询 result/trace,单次命令必须有 timeout 和中间状态输出。 ## API 接口说明 -| CLI 接口 | 说明 | -| --- | --- | -| `hwlab-cli health` | 输出 MVP gate 摘要和 blocker。 | -| `hwlab-cli project list` | 输出项目/topology 摘要。 | -| `hwlab-cli m3 status --api-base-url URL` | 通过 Skill CLI 调用 HWLAB API `/v1/m3/status`。 | -| `hwlab-cli m3 io --action ... --api-base-url URL` | 通过 Skill CLI 调用 `/v1/m3/io`;写操作必须显式 approval。 | -| `hwlab-cli test e2e --env dev --mvp --dry-run` | 输出 MVP e2e dry-run plan。 | -| `hwlab-cli cicd ...` | 必须返回 `legacy-cicd-removed`。 | +| CLI 接口 | WEB 等价 API | 说明 | +| --- | --- | --- | +| `hwlab-cli client auth login` | `POST /auth/login` | 使用账号密码登录 Cloud Web,同步保存 cookie。 | +| `hwlab-cli client auth session` | `GET /auth/session` | 恢复当前 actor/session。 | +| `hwlab-cli client auth logout` | `POST /auth/logout` | 撤销 server session 并清理本地 cookie。 | +| `hwlab-cli client device-pods list` | `GET /v1/device-pods` | 对应右侧 Device Pod 列表。 | +| `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 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 非视觉功能面。 | + +`client` 之外的历史命令不作为 v0.2 验收入口。保留旧命令时只能返回废弃说明或转向 `client`,不得把 fixture 或 dry-run 结果当作 WEB 功能通过证据。 ## 测试规格 ## T1 -阅读 docs/reference/spec-v02-hwlab-cli.md,然后用 cli 手动测试以下内容:运行 `hwlab-cli health`、`project list` 和 `test e2e --env dev --mvp --dry-run`,确认全部输出 JSON 且不执行 live DEV/PROD 修改。 +阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `node scripts/run-bun.mjs tools/hwlab-cli/bin/hwlab-cli.ts client auth login --base-url http://74.48.78.17:19666 --username admin --password-env HWLAB_PASSWORD`,确认返回 JSON、HTTP 200、保存 cookie,输出不包含 password 或 Secret 原文。 ## T2 -阅读 docs/reference/spec-v02-hwlab-cli.md,然后用 cli 手动测试以下内容:运行 `hwlab-cli cicd status`,确认返回 `legacy-cicd-removed`,并给出 G14 Tekton/GitOps 替代入口。 +阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `client auth session`、`client device-pods list`、`client device-pods status device-pod-71-freq` 和 `client workbench summary --pod-id device-pod-71-freq`,确认全部走 `19666` Cloud Web 同源 API,未登录时返回认证 blocker,登录后返回真实 Device Pod payload,不读取本地 fixture。 + +## 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。 ## 规格的实现情况 | 规格项 | 状态 | 说明 | | --- | --- | --- | -| CLI JSON 输出 | 已实现 | `lib/cli.mjs` 默认 JSON。 | -| M3 Skill CLI 转发 | 已实现 | 使用 repo-owned Skill CLI 调 HWLAB API。 | -| legacy cicd 删除 | 已实现 | `cicd` 子命令返回明确错误。 | -| v02 Job template | 已实现 | `hwlab-cli-template` 存在且 `suspend: true`。 | -| live e2e 执行 | 未完全实现 | 当前 live path 在 blocker 或未实现时拒绝执行。 | -| Device Pod REST CLI | 已实现 | `device-pod-cli` 已迁到 Bun+TS 默认 REST authority;旧本地 profile/gateway RPC 路径不再作为正式默认入口。 | - +| 固定 repo 短连接 client | 目标状态 | `hwlab-cli` 只在 `G14:/root/hwlab-v02` 直接运行。 | +| WEB 等价 API client | 目标状态 | `client` 子命令覆盖 Cloud Web 非视觉业务面。 | +| 本地 cookie session | 目标状态 | `.state/hwlab-cli/session.json` 只保存 cookie/session 摘要。 | +| 镜像/Service/Job template | 已废弃 | 相关 deploy、GitOps、artifact 和 Tekton 口径必须删除。 | +| PR/CI/CD/worktree 流程 | 已废弃 | CLI 变更不走常驻服务发布流程。 | diff --git a/docs/reference/spec-v02-services.md b/docs/reference/spec-v02-services.md index 10d9cd4c..5c0dd542 100644 --- a/docs/reference/spec-v02-services.md +++ b/docs/reference/spec-v02-services.md @@ -2,7 +2,7 @@ 本文是 HWLAB `v0.2` 微服务总体规格和服务规格索引。它定义组件取舍、依赖方向、语言迁移边界、裁撤口径和单服务 spec 入口;单个服务的 API、测试规格和实现状态以对应 `docs/reference/spec-*.md` 为准。 -`docs/reference/spec-*.md` 是微服务、稳定外部服务、CLI/Job 模板和系统能力的权威出处;代码开发和测试代码编写必须先对齐对应 spec,再修改实现或测试。 +`docs/reference/spec-*.md` 是微服务、稳定外部服务、短连接 CLI 和系统能力的权威出处;代码开发和测试代码编写必须先对齐对应 spec,再修改实现或测试。 细节权威出处: @@ -11,7 +11,7 @@ - `v0.2` branch、namespace、GitOps、FRP、SecretRef 和发布验收:见 [spec-v02-cicd.md](spec-v02-cicd.md)。 - Code Agent provider 真实聊天验收:见 [code-agent-chat-readiness.md](code-agent-chat-readiness.md)。 - G14 GitOps、Tekton、Argo CD、registry 和外部稳定中间件边界:见 [g14-gitops-cicd.md](g14-gitops-cicd.md)。 -- 保留服务、稳定外部服务和 CLI/Job 模板的单服务 spec:见本文“服务总表”。 +- 保留服务、稳定外部服务和短连接 CLI 的单项 spec:见本文“服务总表”。 ## 在系统中的职责划分 @@ -23,7 +23,7 @@ - `hwlab-gateway` 是 transport,不理解用户权限、不保存 profile authority;用户端已经验证稳定,v0.2 第一阶段先不改造它。 - Code Agent provider 通道分为 `codex-api` loopback forwarder 和 `deepseek` bridge/Moon Bridge;自研 bridge/forwarder 属于 HWLAB 常驻服务,Moon Bridge 和 hyueapi/DeepSeek upstream 是稳定外部依赖。 - `hwlab-router`、`hwlab-tunnel-client`、`hwlab-gateway-simu`、`hwlab-box-simu`、`hwlab-patch-panel` 在 v0.2 裁撤;不再为这些裁撤对象保留单独规格文档。 -- CLI、一次性脚本、render/publish/smoke helper、vendored 前端库和稳定外部服务不纳入 Bun + TypeScript 迁移范围;HWLAB 自研前端浏览器代码纳入 TypeScript 化范围,因为低频 UI 分支的语法错误也必须在发布前拦截。 +- `hwlab-cli` 是固定 repo 内短连接业务 client,不是镜像、常驻服务或 Job template;一次性脚本、render/publish/smoke helper、vendored 前端库和稳定外部服务不纳入 Bun + TypeScript 常驻服务迁移范围,短连接 CLI 自身按 [spec-v02-hwlab-cli.md](spec-v02-hwlab-cli.md) 使用 Bun + TypeScript。 ## 内部架构 @@ -83,7 +83,7 @@ origin/v0.2 | Gateway transport | `cloud-api /v1/gateway/poll`、`/v1/gateway/result`、gateway `/status` | [spec-v02-hwlab-gateway.md](spec-v02-hwlab-gateway.md) | | Code Agent provider | `deepseek` 和 `codex-api` provider profile | [spec-v02-deepseek-proxy.md](spec-v02-deepseek-proxy.md)、[spec-v02-codex-api-forwarder.md](spec-v02-codex-api-forwarder.md) | | Agent runtime skeleton | manager HTTP/CLI、worker Job template、skills health | [spec-v02-hwlab-agent-mgr.md](spec-v02-hwlab-agent-mgr.md)、[spec-v02-hwlab-agent-worker.md](spec-v02-hwlab-agent-worker.md)、[spec-v02-hwlab-agent-skills.md](spec-v02-hwlab-agent-skills.md) | -| CLI/Job 操作 | `hwlab-cli` suspended Job template 和 repo CLI | [spec-v02-hwlab-cli.md](spec-v02-hwlab-cli.md) | +| 短连接 CLI | `G14:/root/hwlab-v02` 内直接运行 `hwlab-cli client ...` | [spec-v02-hwlab-cli.md](spec-v02-hwlab-cli.md) | | Durable runtime store | Postgres TCP `5432` and cloud-api DB readiness | [spec-v02-postgres.md](spec-v02-postgres.md) | | 公网 FRP | master `frps` + `hwlab-v02-frpc` TCP `19666/19667` | [spec-v02-frpc.md](spec-v02-frpc.md) | | CI/CD 控制 | render、Tekton、GitOps、Argo、runtime health | [spec-v02-cicd.md](spec-v02-cicd.md) | @@ -109,7 +109,7 @@ origin/v0.2 | `hwlab-gateway-simu` | HWLAB 自研模拟服务 | 裁撤 | 否 | 本文即裁撤权威,不保留单独 spec | | `hwlab-box-simu` | HWLAB 自研模拟服务 | 裁撤 | 否 | 本文即裁撤权威,不保留单独 spec | | `hwlab-patch-panel` | HWLAB 自研接线盘服务 | 裁撤 | 否 | 本文即裁撤权威,不保留单独 spec | -| `hwlab-cli` | CLI/Job 工具 | 保留灵活 | 否 | [spec-v02-hwlab-cli.md](spec-v02-hwlab-cli.md) | +| `hwlab-cli` | 固定 repo 短连接 client | 保留为 WEB 等价非视觉业务入口,不进 runtime service inventory | 是,CLI 自身 | [spec-v02-hwlab-cli.md](spec-v02-hwlab-cli.md) | | `device-pod-cli` | CLI 工具 | 保留并改 REST 调用 | 否 | [spec-device-pod.md](spec-device-pod.md) | | render/publish/smoke scripts | 一次性脚本 | 保留现状 | 否 | [spec-v02-cicd.md](spec-v02-cicd.md)、[g14-gitops-cicd.md](g14-gitops-cicd.md) | | browser-side Cloud Web JS | HWLAB 自研前端浏览器代码 | 保留并 TS 化 | 是,P0 | [spec-v02-hwlab-cloud-web.md](spec-v02-hwlab-cloud-web.md)、[cloud-workbench.md](cloud-workbench.md) | @@ -157,7 +157,6 @@ hwlab-agent-skills wrapper ```text hwlab-gateway -hwlab-cli device-pod-cli scripts and tools stable external services diff --git a/fixtures/mvp/m5-e2e/dev-acceptance-checklist.json b/fixtures/mvp/m5-e2e/dev-acceptance-checklist.json deleted file mode 100644 index 5e8d57fe..00000000 --- a/fixtures/mvp/m5-e2e/dev-acceptance-checklist.json +++ /dev/null @@ -1,556 +0,0 @@ -{ - "$schema": "https://hwlab.pikastech.local/fixtures/mvp/m5-e2e/dev-acceptance-checklist.schema.json", - "$id": "https://hwlab.pikastech.local/fixtures/mvp/m5-e2e/dev-acceptance-checklist.json", - "matrixVersion": "v1", - "issue": "pikasTech/HWLAB#22", - "environment": "dev", - "endpoint": "http://74.48.78.17:16667", - "publicEndpoints": { - "frontend": "http://74.48.78.17:16666", - "api": "http://74.48.78.17:16667", - "edge": "http://74.48.78.17:16667" - }, - "internalServicePorts": [ - { - "serviceId": "hwlab-cloud-api", - "scope": "k3s-service", - "port": 6667, - "note": "Internal k3s service port only; not a public DEV endpoint." - }, - { - "serviceId": "hwlab-edge-proxy", - "scope": "k3s-service", - "port": 6667, - "note": "Internal k3s service/listen port only; public API/edge ingress is 16667." - } - ], - "runtimeSubstitutePolicy": { - "allowedExternalRoles": [ - "scheduling", - "ci", - "cd" - ], - "forbiddenRuntimeSubstitutes": [ - "unidesk-backend", - "unidesk-provider-gateway", - "unidesk-microservice-proxy" - ] - }, - "manualAcceptance": { - "referenceDoc": "docs/reference/m3-loop-rollout-runbook.md", - "m3MvpPassCondition": "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 with two distinct box simulators, two distinct gateway simulators, one patch-panel participant, and linked operation, trace, audit, and evidence IDs.", - "mustHaveEvidence": [ - "two distinct box-simu identities", - "two distinct gateway-simu identities", - "patch-panel ownership of the route", - "operationId", - "traceId", - "auditId", - "evidenceId" - ], - "supportOnlySignals": [ - "SOURCE", - "LOCAL", - "DRY-RUN", - "fixture-only", - "edge-only", - "Cloud Web polish", - "generic console work", - "artifact report", - "desired-state plan" - ], - "forbiddenM3PassSubstitutes": [ - "box loopback", - "front-end direct state edit", - "UniDesk runtime substitution", - "UniDesk proxy substitution" - ], - "failureClasses": [ - "contract_blocker", - "environment_blocker", - "network_blocker", - "runtime_blocker", - "agent_blocker", - "observability_blocker", - "safety_blocker" - ] - }, - "m3EvidenceClassification": { - "p0Scope": "M3 virtual hardware trusted loop", - "requiredCardinality": { - "hwlab-box-simu": 2, - "hwlab-gateway-simu": 2, - "hwlab-patch-panel": 1 - }, - "m3Live": { - "requiredLink": "DO1 -> hwlab-patch-panel -> DI1", - "requiredIdentifiers": [ - "operationId", - "traceId", - "auditId", - "evidenceId" - ], - "classification": "DEV-LIVE" - }, - "m3Support": [ - "endpoint-freeze", - "read-only-edge-curl", - "source-contract", - "static-manifest-cardinality", - "local-smoke", - "dry-run-fixture" - ], - "nonP0": [ - "SOURCE", - "LOCAL", - "DRY-RUN", - "fixture-only", - "edge-only-diagnostic" - ], - "promotionRule": "Do not classify M3 support or non-P0 evidence as DEV-LIVE unless the traced DO1 -> hwlab-patch-panel -> DI1 path is observed." - }, - "artifactObservabilityFields": [ - "serviceId", - "commitId", - "image", - "tag", - "digest", - "buildSource", - "deployEnv", - "healthTimestamp" - ], - "artifactDigestPolicy": { - "requiredFor": [ - "container-image", - "digestable-build-output" - ], - "notApplicableValue": "not_applicable", - "reasonRequiredWhenNotApplicable": true - }, - "route": [ - { - "id": "dev-api-edge-endpoint", - "label": "DEV API/edge endpoint", - "serviceId": "hwlab-edge-proxy", - "endpoint": "http://74.48.78.17:16667", - "role": "public-api-edge-ingress" - }, - { - "id": "dev-frontend-endpoint", - "label": "DEV frontend endpoint", - "serviceId": "hwlab-cloud-web", - "endpoint": "http://74.48.78.17:16666", - "role": "public-frontend" - }, - { - "id": "master-edge-proxy", - "label": "master edge proxy", - "serviceId": "hwlab-edge-proxy", - "role": "edge-route" - }, - { - "id": "frp", - "label": "frp", - "serviceId": "hwlab-tunnel-client", - "role": "tunnel" - }, - { - "id": "d601-router", - "label": "D601 hwlab-dev/hwlab-router", - "serviceId": "hwlab-router", - "namespace": "hwlab-dev", - "role": "dev-router" - }, - { - "id": "cloud-api-web", - "label": "cloud-api/web", - "serviceIds": [ - "hwlab-cloud-api", - "hwlab-cloud-web" - ], - "role": "cloud-surface" - } - ], - "healthContracts": [ - { - "component": "DEV ingress", - "serviceId": "hwlab-edge-proxy", - "probe": { - "method": "GET", - "url": "http://74.48.78.17:16667/health" - }, - "requiredFields": [ - "serviceId", - "deployEnv", - "healthTimestamp" - ], - "success": "HTTP 2xx/3xx reaches the HWLAB DEV route and reports edge or downstream HWLAB identity.", - "failure": "Timeout, non-HWLAB response, PROD route, or missing artifact fields." - }, - { - "component": "master edge proxy", - "serviceId": "hwlab-edge-proxy", - "probe": { - "type": "route-observation", - "target": "http://74.48.78.17:16667" - }, - "requiredFields": [ - "serviceId", - "commitId", - "image", - "tag", - "digest", - "buildSource", - "deployEnv", - "healthTimestamp" - ], - "success": "DEV route forwards to frp and identifies its artifact.", - "failure": "No DEV route, wrong port, or missing route observability." - }, - { - "component": "frp", - "serviceId": "hwlab-tunnel-client", - "probe": { - "type": "tunnel-status", - "target": "D601 hwlab-dev/hwlab-router" - }, - "requiredFields": [ - "serviceId", - "commitId", - "image", - "tag", - "digest", - "buildSource", - "deployEnv", - "healthTimestamp" - ], - "success": "Tunnel is established from master edge to D601 router for DEV.", - "failure": "Tunnel down, wrong target, or ambiguous service identity." - }, - { - "component": "D601 router", - "serviceId": "hwlab-router", - "namespace": "hwlab-dev", - "probe": { - "type": "router-health", - "target": "hwlab-dev/hwlab-router" - }, - "requiredFields": [ - "serviceId", - "commitId", - "image", - "tag", - "digest", - "buildSource", - "deployEnv", - "healthTimestamp" - ], - "success": "Router is live in dev and forwards only to HWLAB DEV services.", - "failure": "Namespace mismatch, missing route, or stale health timestamp." - }, - { - "component": "Cloud API", - "serviceId": "hwlab-cloud-api", - "probe": { - "methods": [ - "GET /health", - "GET /live", - "POST /rpc" - ] - }, - "requiredFields": [ - "serviceId", - "deployEnv", - "healthTimestamp" - ], - "success": "Health is valid JSON with HWLAB cloud API identity and dev environment.", - "failure": "HTTP error, wrong service ID, wrong environment, or invalid JSON-RPC envelope." - }, - { - "component": "Cloud Web", - "serviceId": "hwlab-cloud-web", - "probe": { - "commands": [ - "npm run web:m3-readonly", - "npm run web:check", - "npm run web:build" - ] - }, - "requiredFields": [ - "serviceId", - "commitId", - "buildSource", - "deployEnv", - "healthTimestamp" - ], - "success": "Static assets exist, build succeeds, runtime uses public frontend :16666 and API/edge :16667, and Cloud Web exposes only read-only M3 diagnostics for topology, route, health, blockers, and evidence summary.", - "failure": "Endpoint drift, direct hardware write/control path, edge-only diagnostic promoted to M3 DEV-LIVE, or missing asset." - }, - { - "component": "Agent manager", - "serviceId": "hwlab-agent-mgr", - "probe": { - "type": "agent-scheduler-health" - }, - "requiredFields": [ - "serviceId", - "commitId", - "image", - "tag", - "digest", - "buildSource", - "deployEnv", - "healthTimestamp" - ], - "success": "Scheduler is live for dev and does not leave worker sessions open after smoke.", - "failure": "Scheduler unavailable, wrong environment, or unbounded worker session." - }, - { - "component": "Agent worker", - "serviceId": "hwlab-agent-worker", - "probe": { - "type": "scoped-worker-health" - }, - "requiredFields": [ - "serviceId", - "commitId", - "image", - "tag", - "digest", - "buildSource", - "deployEnv", - "healthTimestamp" - ], - "success": "Worker session is scoped to the DEV project and emits trace/audit identifiers.", - "failure": "Missing session identity, unsafe mutation, or cleanup failure." - }, - { - "component": "Agent skills", - "serviceId": "hwlab-agent-skills", - "probe": { - "type": "skill-bundle-version" - }, - "requiredFields": [ - "serviceId", - "commitId", - "buildSource", - "deployEnv", - "healthTimestamp" - ], - "success": "Skill artifact is traceable to commit/build source and compatible with worker.", - "failure": "Unversioned skill bundle or missing build source." - }, - { - "component": "Gateway", - "serviceId": "hwlab-gateway", - "probe": { - "methods": [ - "GET /health/live", - "GET /status" - ] - }, - "requiredFields": [ - "serviceId", - "deployEnv", - "healthTimestamp" - ], - "success": "Gateway boundary is live for dev and does not bypass patch-panel constraints.", - "failure": "Hardware boundary unavailable, non-dev gateway, or direct box mutation." - }, - { - "component": "Gateway simulator", - "serviceId": "hwlab-gateway-simu", - "probe": { - "methods": [ - "GET /health/live", - "GET /status", - "GET /boxes" - ] - }, - "requiredFields": [ - "serviceId", - "deployEnv", - "healthTimestamp" - ], - "success": "Simulator is live, lists expected box resources, and identifies the DEV project.", - "failure": "Simulator down, box list missing, or stale timestamp." - }, - { - "component": "Box simulator", - "serviceId": "hwlab-box-simu", - "probe": { - "methods": [ - "GET /health/live", - "GET /status" - ] - }, - "requiredFields": [ - "serviceId", - "deployEnv", - "healthTimestamp" - ], - "success": "Simulator is live and reports resources, ports, and patch-panel-only propagation.", - "failure": "Cross-device propagation outside patch panel or missing resource state." - }, - { - "component": "Patch panel", - "serviceId": "hwlab-patch-panel", - "probe": { - "methods": [ - "GET /health/live", - "GET /status", - "GET /wiring" - ] - }, - "requiredFields": [ - "serviceId", - "deployEnv", - "healthTimestamp" - ], - "success": "Wiring config is parseable and patch-panel state owns routing decisions.", - "failure": "Invalid topology, direct bypass path, or stale patch state." - }, - { - "component": "CLI", - "serviceId": "hwlab-cli", - "probe": { - "commands": [ - "npm run cli:health", - "npm run cli:dry-run" - ] - }, - "requiredFields": [ - "serviceId", - "commitId", - "buildSource", - "deployEnv", - "healthTimestamp" - ], - "success": "CLI uses the fixed DEV endpoint and dry-run states no DEV/PROD changes were made.", - "failure": "Wrong endpoint, missing dry-run guard, or real mutation attempted." - } - ], - "smokeSteps": [ - { - "id": "static-contract-parse", - "order": 1, - "probe": "Parse fixtures/mvp/m5-e2e/dev-acceptance-checklist.json and confirm docs/reference/MVP-e2e-acceptance.md exists.", - "successCriteria": "Checklist JSON parses and markdown file is present.", - "failureCriteria": "Missing file or invalid JSON.", - "blockerClass": "contract_blocker" - }, - { - "id": "endpoint-freeze", - "order": 2, - "probe": "Compare documented and fixture DEV endpoints.", - "successCriteria": "Public frontend is exactly http://74.48.78.17:16666; public API/edge is exactly http://74.48.78.17:16667; internal 6667 appears only as a k3s service/listen port.", - "failureCriteria": "Any alternate public DEV endpoint, old public :6667, or PROD target.", - "blockerClass": "environment_blocker" - }, - { - "id": "dev-ingress-health", - "order": 3, - "probe": "Observe GET http://74.48.78.17:16667/health or recorded equivalent.", - "successCriteria": "Request reaches HWLAB DEV route and returns HWLAB identity or accepted downstream health.", - "failureCriteria": "Timeout, non-HWLAB target, or wrong port.", - "blockerClass": "network_blocker" - }, - { - "id": "edge-route", - "order": 4, - "probe": "Observe master edge proxy route table for DEV.", - "successCriteria": "Edge route maps DEV endpoint to frp and records artifact identity.", - "failureCriteria": "Missing route, stale route, or missing artifact identity.", - "blockerClass": "network_blocker" - }, - { - "id": "frp-tunnel", - "order": 5, - "probe": "Observe frp tunnel status.", - "successCriteria": "Tunnel links master edge to D601 router for DEV.", - "failureCriteria": "Tunnel down or target mismatch.", - "blockerClass": "network_blocker" - }, - { - "id": "d601-router", - "order": 6, - "probe": "Observe hwlab-dev/hwlab-router health/status.", - "successCriteria": "Router is live and forwards only to HWLAB DEV services.", - "failureCriteria": "Namespace mismatch or route bypass.", - "blockerClass": "runtime_blocker" - }, - { - "id": "cloud-surface", - "order": 7, - "probe": "Check cloud API health and cloud web endpoint configuration.", - "successCriteria": "Cloud API health is valid on public :16667; web assets are served on public :16666 and point API traffic at :16667 or the internal k3s API service.", - "failureCriteria": "Bad health, endpoint drift, or direct hardware control from web.", - "blockerClass": "runtime_blocker" - }, - { - "id": "gateway-sim-patch-panel", - "order": 8, - "probe": "Check gateway, gateway simulator, box simulator, and patch-panel health/status.", - "successCriteria": "Health/status JSON is parseable and topology keeps routing under patch-panel ownership; for M3 live, a traced DO1 -> patch-panel -> DI1 operation exists.", - "failureCriteria": "Missing health, invalid topology, bypass path, or any attempt to promote support/fixture evidence to M3 live.", - "blockerClass": "runtime_blocker" - }, - { - "id": "agent-runtime", - "order": 9, - "probe": "Check agent manager, worker, and skills contracts.", - "successCriteria": "Agent artifacts are traceable; dry-run/scoped smoke emits session, trace, audit, and cleanup evidence.", - "failureCriteria": "Missing traceability, unsafe mutation, or cleanup leak.", - "blockerClass": "agent_blocker" - }, - { - "id": "artifact-observability", - "order": 10, - "probe": "Verify required artifact observability fields for each accepted artifact.", - "successCriteria": "Every accepted artifact has service ID, commit, image/tag/digest or reason, build source, env, and health timestamp.", - "failureCriteria": "Missing required observability field.", - "blockerClass": "observability_blocker" - } - ], - "blockerClasses": [ - { - "id": "contract_blocker", - "description": "Static contract, schema, checklist, or documentation cannot be parsed or contradicts frozen names." - }, - { - "id": "environment_blocker", - "description": "DEV endpoint, environment, namespace, or PROD boundary is wrong." - }, - { - "id": "network_blocker", - "description": "Master edge, frp, D601 route, or public DEV ingress cannot be observed or routes incorrectly." - }, - { - "id": "runtime_blocker", - "description": "HWLAB cloud, router, gateway, simulator, box simulator, or patch-panel runtime contract fails." - }, - { - "id": "agent_blocker", - "description": "Agent manager, worker, skills, trace, audit, or cleanup contract fails." - }, - { - "id": "observability_blocker", - "description": "Artifact identity, commit, image/tag/digest, build source, deploy env, or health timestamp is missing." - }, - { - "id": "safety_blocker", - "description": "A prohibited action was attempted: real deployment, PROD target, heavyweight e2e, secret read, force push, or UniDesk runtime substitution." - } - ], - "prohibitedActions": [ - "real-dev-deploy", - "prod-deploy", - "prod-smoke", - "heavyweight-e2e", - "secret-or-token-read", - "force-push", - "unidesk-runtime-substitution" - ], - "passRule": "All smoke steps must be successful or explicitly not applicable with a non-production reason, and every accepted artifact must satisfy the observability contract.", - "failRule": "Fail on any blocker class, wrong endpoint, wrong environment, missing HWLAB service identity, missing artifact observability, invalid JSON, or prohibited action." -} diff --git a/fixtures/mvp/m5-e2e/dry-run-plan.json b/fixtures/mvp/m5-e2e/dry-run-plan.json deleted file mode 100644 index 0523db2f..00000000 --- a/fixtures/mvp/m5-e2e/dry-run-plan.json +++ /dev/null @@ -1,1353 +0,0 @@ -{ - "$schema": "https://hwlab.pikastech.local/fixtures/mvp/m5-e2e/dry-run-plan.schema.json", - "$id": "https://hwlab.pikastech.local/fixtures/mvp/m5-e2e/dry-run-plan.json", - "planVersion": "m5-v1", - "issue": "pikasTech/HWLAB#26", - "mode": "dry-run", - "environment": "dev", - "endpoint": "http://74.48.78.17:16667", - "safety": { - "allowNetwork": false, - "allowDeploy": false, - "allowBrowserE2E": false, - "allowSecrets": false, - "allowedSourceGlobs": [ - "protocol/**", - "fixtures/mvp/m5-e2e/dev-acceptance-checklist.json", - "docs/reference/MVP-e2e-acceptance.md", - "fixtures/mvp/runtime.json", - "fixtures/mvp/m5-e2e/**" - ], - "prohibitedActions": [ - "real-dev-deploy", - "prod-deploy", - "prod-smoke", - "browser-e2e", - "heavyweight-e2e", - "secret-or-token-read", - "force-push", - "unidesk-runtime-substitution" - ] - }, - "m0ToM4Dependencies": [ - { - "id": "m0-contract-audit", - "status": "contract_only", - "provides": [ - "frozen service ids", - "protocol schemas", - "MVP E2E sequence contract" - ], - "requiredFor": [ - "step-dev-health", - "step-direct-hardware-call", - "step-agent-hardware-call", - "step-evidence" - ] - }, - { - "id": "m1-local-smoke", - "status": "contract_only", - "provides": [ - "local parse and smoke boundary", - "dry-run safety posture" - ], - "requiredFor": [ - "step-dev-health", - "step-cleanup" - ] - }, - { - "id": "m2-cloud-core", - "status": "contract_only", - "provides": [ - "project record", - "JSON-RPC envelope", - "audit surface" - ], - "requiredFor": [ - "step-audit", - "step-direct-hardware-call", - "step-agent-hardware-call" - ] - }, - { - "id": "m3-hardware-loop", - "status": "contract_only", - "provides": [ - "gateway session", - "box resources", - "box capabilities", - "wiring config", - "patch-panel status" - ], - "requiredFor": [ - "step-gateway-box-status", - "step-wiring", - "step-direct-hardware-call", - "step-agent-hardware-call" - ] - }, - { - "id": "m4-agent-runtime", - "status": "contract_only", - "provides": [ - "agent session", - "worker session", - "trace events", - "agent evidence", - "worker cleanup" - ], - "requiredFor": [ - "step-agent-session", - "step-agent-hardware-call", - "step-evidence", - "step-cleanup" - ] - } - ], - "artifacts": [ - { - "serviceId": "hwlab-edge-proxy", - "commitId": "caa9ed0", - "image": "hwlab-edge-proxy", - "tag": "dry-run-caa9ed0", - "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z" - }, - { - "serviceId": "hwlab-tunnel-client", - "commitId": "caa9ed0", - "image": "hwlab-tunnel-client", - "tag": "dry-run-caa9ed0", - "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", - "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z" - }, - { - "serviceId": "hwlab-router", - "commitId": "caa9ed0", - "image": "hwlab-router", - "tag": "dry-run-caa9ed0", - "digest": "sha256:3333333333333333333333333333333333333333333333333333333333333333", - "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z" - }, - { - "serviceId": "hwlab-cloud-api", - "commitId": "caa9ed0", - "image": "hwlab-cloud-api", - "tag": "dry-run-caa9ed0", - "digest": "sha256:4444444444444444444444444444444444444444444444444444444444444444", - "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z" - }, - { - "serviceId": "hwlab-cloud-web", - "commitId": "caa9ed0", - "image": "hwlab-cloud-web-static-assets", - "tag": "dry-run-caa9ed0", - "digest": "not_applicable", - "digestNotApplicableReason": "static asset digest is produced by the future DEV build pipeline, not this local dry-run fixture", - "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z" - }, - { - "serviceId": "hwlab-gateway", - "commitId": "caa9ed0", - "image": "hwlab-gateway", - "tag": "dry-run-caa9ed0", - "digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555", - "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z" - }, - { - "serviceId": "hwlab-gateway-simu", - "commitId": "caa9ed0", - "image": "hwlab-gateway-simu", - "tag": "dry-run-caa9ed0", - "digest": "sha256:6666666666666666666666666666666666666666666666666666666666666666", - "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z" - }, - { - "serviceId": "hwlab-box-simu", - "commitId": "caa9ed0", - "image": "hwlab-box-simu", - "tag": "dry-run-caa9ed0", - "digest": "sha256:7777777777777777777777777777777777777777777777777777777777777777", - "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z" - }, - { - "serviceId": "hwlab-patch-panel", - "commitId": "caa9ed0", - "image": "hwlab-patch-panel", - "tag": "dry-run-caa9ed0", - "digest": "sha256:8888888888888888888888888888888888888888888888888888888888888888", - "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z" - }, - { - "serviceId": "hwlab-agent-mgr", - "commitId": "caa9ed0", - "image": "hwlab-agent-mgr", - "tag": "dry-run-caa9ed0", - "digest": "sha256:9999999999999999999999999999999999999999999999999999999999999999", - "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z" - }, - { - "serviceId": "hwlab-agent-worker", - "commitId": "caa9ed0", - "image": "hwlab-agent-worker", - "tag": "dry-run-caa9ed0", - "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z" - }, - { - "serviceId": "hwlab-agent-skills", - "commitId": "caa9ed0", - "image": "hwlab-agent-runtime-skills", - "tag": "dry-run-caa9ed0", - "digest": "not_applicable", - "digestNotApplicableReason": "skill bundle digest is produced by the future DEV build pipeline, not this local dry-run fixture", - "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z" - }, - { - "serviceId": "hwlab-cli", - "commitId": "caa9ed0", - "image": "hwlab-cli-npm-package", - "tag": "dry-run-caa9ed0", - "digest": "not_applicable", - "digestNotApplicableReason": "local CLI package is not containerized in this dry-run fixture", - "buildSource": "pikasTech/HWLAB@origin/main#dry-run-fixture", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z" - } - ], - "health": [ - { - "healthId": "hlt_m5-dev-ingress", - "serviceId": "hwlab-edge-proxy", - "component": "DEV ingress", - "status": "healthy", - "deployEnv": "dev", - "endpoint": "http://74.48.78.17:16667/health", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "routeId": "route_m5-dev", - "nextHopServiceId": "hwlab-tunnel-client" - } - }, - { - "healthId": "hlt_m5-edge-route", - "serviceId": "hwlab-edge-proxy", - "component": "master edge proxy", - "status": "healthy", - "deployEnv": "dev", - "endpoint": "http://74.48.78.17:16667", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "routeId": "route_m5-dev", - "targetServiceId": "hwlab-tunnel-client" - } - }, - { - "healthId": "hlt_m5-frp", - "serviceId": "hwlab-tunnel-client", - "component": "frp", - "status": "healthy", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "tunnelId": "tun_m5-d601", - "targetServiceId": "hwlab-router", - "namespace": "hwlab-dev" - } - }, - { - "healthId": "hlt_m5-router", - "serviceId": "hwlab-router", - "component": "D601 router", - "status": "healthy", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "namespace": "hwlab-dev", - "routeTargetServiceIds": [ - "hwlab-cloud-api", - "hwlab-cloud-web" - ] - } - }, - { - "healthId": "hlt_m5-cloud-api", - "serviceId": "hwlab-cloud-api", - "component": "Cloud API", - "status": "healthy", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "live": true, - "rpcProbe": "json-rpc-envelope" - } - }, - { - "healthId": "hlt_m5-cloud-web", - "serviceId": "hwlab-cloud-web", - "component": "Cloud Web", - "status": "healthy", - "deployEnv": "dev", - "endpoint": "http://74.48.78.17:16667", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "directHardwareCall": false - } - }, - { - "healthId": "hlt_m5-gateway", - "serviceId": "hwlab-gateway", - "component": "Gateway", - "status": "healthy", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "gatewaySessionId": "gws_m5-0001", - "patchPanelRequired": true - } - }, - { - "healthId": "hlt_m5-gateway-simu", - "serviceId": "hwlab-gateway-simu", - "component": "Gateway simulator", - "status": "healthy", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "gatewaySessionId": "gws_m5-simu-0001", - "boxIds": [ - "box_m5-control", - "box_m5-target" - ] - } - }, - { - "healthId": "hlt_m5-box-simu", - "serviceId": "hwlab-box-simu", - "component": "Box simulator", - "status": "healthy", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "resourceIds": [ - "res_m5-control-relay", - "res_m5-target-board" - ], - "patchPanelOnlyPropagation": true - } - }, - { - "healthId": "hlt_m5-patch-panel", - "serviceId": "hwlab-patch-panel", - "component": "Patch panel", - "status": "healthy", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "patchPanelStatusId": "pps_m5-0001", - "wiringConfigId": "wir_m5-0001" - } - }, - { - "healthId": "hlt_m5-agent-mgr", - "serviceId": "hwlab-agent-mgr", - "component": "Agent manager", - "status": "healthy", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "agentSessionId": "agt_m5-0001", - "workerLeak": false - } - }, - { - "healthId": "hlt_m5-agent-worker", - "serviceId": "hwlab-agent-worker", - "component": "Agent worker", - "status": "healthy", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "workerSessionId": "wkr_m5-0001", - "scopedProjectId": "proj_m5-mvp-e2e" - } - }, - { - "healthId": "hlt_m5-agent-skills", - "serviceId": "hwlab-agent-skills", - "component": "Agent skills", - "status": "healthy", - "deployEnv": "dev", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "skillName": "hwlab-agent-runtime" - } - }, - { - "healthId": "hlt_m5-cli", - "serviceId": "hwlab-cli", - "component": "CLI", - "status": "healthy", - "deployEnv": "dev", - "endpoint": "http://74.48.78.17:16667", - "healthTimestamp": "2026-05-21T00:00:00.000Z", - "observedBy": "fixture", - "dryRun": true, - "output": { - "command": "hwlab-cli test e2e --env dev --mvp --dry-run" - } - } - ], - "project": { - "projectId": "proj_m5-mvp-e2e", - "name": "HWLAB M5 MVP E2E Dry Run", - "description": "Static dry-run project that validates MVP E2E orchestration dependencies without touching DEV.", - "status": "active", - "environment": "dev", - "labels": { - "track": "mvp", - "milestone": "m5" - }, - "createdBy": "service_hwlab-cli", - "createdAt": "2026-05-21T00:00:00.000Z", - "updatedAt": "2026-05-21T00:00:00.000Z" - }, - "gatewaySessions": [ - { - "gatewaySessionId": "gws_m5-0001", - "projectId": "proj_m5-mvp-e2e", - "serviceId": "hwlab-gateway", - "gatewayId": "gtw_m5-dev-boundary", - "endpoint": "http://74.48.78.17:16667/gateway", - "status": "connected", - "environment": "dev", - "startedAt": "2026-05-21T00:00:00.000Z", - "lastSeenAt": "2026-05-21T00:00:00.000Z", - "labels": { - "mode": "dry-run" - } - }, - { - "gatewaySessionId": "gws_m5-simu-0001", - "projectId": "proj_m5-mvp-e2e", - "serviceId": "hwlab-gateway-simu", - "gatewayId": "gtw_m5-simu", - "status": "connected", - "environment": "dev", - "startedAt": "2026-05-21T00:00:00.000Z", - "lastSeenAt": "2026-05-21T00:00:00.000Z", - "labels": { - "mode": "dry-run" - } - } - ], - "boxResources": [ - { - "resourceId": "res_m5-control-relay", - "projectId": "proj_m5-mvp-e2e", - "gatewaySessionId": "gws_m5-0001", - "boxId": "box_m5-control", - "resourceType": "relay", - "name": "M5 control relay", - "state": "available", - "environment": "dev", - "metadata": { - "ports": [ - "out1" - ] - }, - "createdAt": "2026-05-21T00:00:00.000Z", - "updatedAt": "2026-05-21T00:00:00.000Z" - }, - { - "resourceId": "res_m5-target-board", - "projectId": "proj_m5-mvp-e2e", - "gatewaySessionId": "gws_m5-0001", - "boxId": "box_m5-target", - "resourceType": "board", - "name": "M5 target board", - "state": "available", - "environment": "dev", - "metadata": { - "ports": [ - "reset" - ] - }, - "createdAt": "2026-05-21T00:00:00.000Z", - "updatedAt": "2026-05-21T00:00:00.000Z" - } - ], - "boxCapabilities": [ - { - "capabilityId": "cap_m5-relay-set", - "resourceId": "res_m5-control-relay", - "projectId": "proj_m5-mvp-e2e", - "name": "relay.set", - "description": "Set relay output state through the gateway and patch panel.", - "direction": "output", - "valueType": "boolean", - "constraints": { - "allowedValues": [ - true, - false - ] - }, - "mutatesState": true, - "createdAt": "2026-05-21T00:00:00.000Z", - "updatedAt": "2026-05-21T00:00:00.000Z" - }, - { - "capabilityId": "cap_m5-board-reset", - "resourceId": "res_m5-target-board", - "projectId": "proj_m5-mvp-e2e", - "name": "board.reset", - "description": "Receive a reset signal from the patch panel owned connection.", - "direction": "input", - "valueType": "boolean", - "constraints": { - "allowedValues": [ - true, - false - ] - }, - "mutatesState": false, - "createdAt": "2026-05-21T00:00:00.000Z", - "updatedAt": "2026-05-21T00:00:00.000Z" - } - ], - "wiringConfig": { - "wiringConfigId": "wir_m5-0001", - "projectId": "proj_m5-mvp-e2e", - "gatewaySessionId": "gws_m5-0001", - "name": "M5 relay to target board reset", - "status": "active", - "connections": [ - { - "from": { - "resourceId": "res_m5-control-relay", - "port": "out1", - "capabilityId": "cap_m5-relay-set" - }, - "to": { - "resourceId": "res_m5-target-board", - "port": "reset", - "capabilityId": "cap_m5-board-reset" - }, - "mode": "exclusive" - } - ], - "constraints": { - "patchPanelOwnsRouting": true, - "directBoxBypassAllowed": false - }, - "createdAt": "2026-05-21T00:00:00.000Z", - "updatedAt": "2026-05-21T00:00:00.000Z" - }, - "patchPanelStatus": { - "patchPanelStatusId": "pps_m5-0001", - "projectId": "proj_m5-mvp-e2e", - "gatewaySessionId": "gws_m5-0001", - "wiringConfigId": "wir_m5-0001", - "serviceId": "hwlab-patch-panel", - "state": "active", - "environment": "dev", - "activeConnections": [ - { - "fromResourceId": "res_m5-control-relay", - "fromPort": "out1", - "toResourceId": "res_m5-target-board", - "toPort": "reset" - } - ], - "observedAt": "2026-05-21T00:00:00.000Z", - "metadata": { - "dryRun": true, - "sourceWiringConfigId": "wir_m5-0001" - } - }, - "agentSession": { - "agentSessionId": "agt_m5-0001", - "projectId": "proj_m5-mvp-e2e", - "serviceId": "hwlab-agent-mgr", - "status": "completed", - "environment": "dev", - "requestedBy": "user_m5-dry-run", - "goal": "Exercise the M5 MVP dry-run hardware call through an agent worker.", - "startedAt": "2026-05-21T00:00:00.000Z", - "completedAt": "2026-05-21T00:05:00.000Z", - "updatedAt": "2026-05-21T00:05:00.000Z", - "metadata": { - "dryRun": true, - "workerSessionId": "wkr_m5-0001", - "cleanupId": "cln_m5-worker" - } - }, - "workerSession": { - "workerSessionId": "wkr_m5-0001", - "agentSessionId": "agt_m5-0001", - "projectId": "proj_m5-mvp-e2e", - "serviceId": "hwlab-agent-worker", - "gatewaySessionId": "gws_m5-0001", - "status": "completed", - "environment": "dev", - "startedAt": "2026-05-21T00:01:00.000Z", - "completedAt": "2026-05-21T00:05:00.000Z", - "updatedAt": "2026-05-21T00:05:00.000Z", - "metadata": { - "dryRun": true, - "scopedProjectId": "proj_m5-mvp-e2e", - "workspaceVolumeId": "vol_m5-agent" - } - }, - "operations": [ - { - "operationId": "op_m5-direct-0001", - "projectId": "proj_m5-mvp-e2e", - "gatewaySessionId": "gws_m5-0001", - "resourceId": "res_m5-control-relay", - "capabilityId": "cap_m5-relay-set", - "requestedBy": "user_m5-dry-run", - "input": { - "value": true, - "dryRun": true - }, - "output": { - "accepted": true, - "patchPanelStatusId": "pps_m5-0001" - }, - "status": "succeeded", - "environment": "dev", - "requestedAt": "2026-05-21T00:02:00.000Z", - "startedAt": "2026-05-21T00:02:10.000Z", - "completedAt": "2026-05-21T00:02:30.000Z", - "updatedAt": "2026-05-21T00:02:30.000Z" - }, - { - "operationId": "op_m5-agent-0001", - "projectId": "proj_m5-mvp-e2e", - "gatewaySessionId": "gws_m5-0001", - "agentSessionId": "agt_m5-0001", - "workerSessionId": "wkr_m5-0001", - "resourceId": "res_m5-control-relay", - "capabilityId": "cap_m5-relay-set", - "requestedBy": "agent_m5-dry-run", - "input": { - "value": false, - "dryRun": true - }, - "output": { - "accepted": true, - "patchPanelStatusId": "pps_m5-0001" - }, - "status": "succeeded", - "environment": "dev", - "requestedAt": "2026-05-21T00:03:00.000Z", - "startedAt": "2026-05-21T00:03:10.000Z", - "completedAt": "2026-05-21T00:03:30.000Z", - "updatedAt": "2026-05-21T00:03:30.000Z" - } - ], - "rpc": [ - { - "name": "direct-hardware-request", - "operationId": "op_m5-direct-0001", - "request": { - "jsonrpc": "2.0", - "id": "req_m5-direct-0001", - "method": "hardware.operation.request", - "params": { - "projectId": "proj_m5-mvp-e2e", - "gatewaySessionId": "gws_m5-0001", - "resourceId": "res_m5-control-relay", - "capabilityId": "cap_m5-relay-set", - "input": { - "value": true, - "dryRun": true - } - }, - "meta": { - "traceId": "trc_m5-direct-0001", - "serviceId": "hwlab-cloud-api", - "environment": "dev" - } - }, - "response": { - "jsonrpc": "2.0", - "id": "req_m5-direct-0001", - "result": { - "accepted": true, - "operationId": "op_m5-direct-0001", - "gatewaySessionId": "gws_m5-0001" - }, - "meta": { - "traceId": "trc_m5-direct-0001", - "serviceId": "hwlab-gateway", - "environment": "dev" - } - } - }, - { - "name": "agent-hardware-request", - "operationId": "op_m5-agent-0001", - "request": { - "jsonrpc": "2.0", - "id": "req_m5-agent-0001", - "method": "hardware.operation.request", - "params": { - "projectId": "proj_m5-mvp-e2e", - "gatewaySessionId": "gws_m5-0001", - "agentSessionId": "agt_m5-0001", - "workerSessionId": "wkr_m5-0001", - "resourceId": "res_m5-control-relay", - "capabilityId": "cap_m5-relay-set", - "input": { - "value": false, - "dryRun": true - } - }, - "meta": { - "traceId": "trc_m5-agent-0001", - "serviceId": "hwlab-agent-worker", - "environment": "dev" - } - }, - "response": { - "jsonrpc": "2.0", - "id": "req_m5-agent-0001", - "result": { - "accepted": true, - "operationId": "op_m5-agent-0001", - "gatewaySessionId": "gws_m5-0001", - "agentSessionId": "agt_m5-0001", - "workerSessionId": "wkr_m5-0001" - }, - "meta": { - "traceId": "trc_m5-agent-0001", - "serviceId": "hwlab-gateway", - "environment": "dev" - } - } - } - ], - "traceEvents": [ - { - "traceEventId": "tev_m5-direct-accepted", - "traceId": "trc_m5-direct-0001", - "projectId": "proj_m5-mvp-e2e", - "operationId": "op_m5-direct-0001", - "serviceId": "hwlab-cloud-api", - "level": "info", - "message": "Direct dry-run hardware operation accepted.", - "environment": "dev", - "metadata": { - "state": "accepted" - }, - "occurredAt": "2026-05-21T00:02:00.000Z" - }, - { - "traceEventId": "tev_m5-direct-dispatched", - "traceId": "trc_m5-direct-0001", - "projectId": "proj_m5-mvp-e2e", - "operationId": "op_m5-direct-0001", - "serviceId": "hwlab-gateway", - "level": "info", - "message": "Direct dry-run hardware operation dispatched to gateway.", - "environment": "dev", - "metadata": { - "state": "dispatched" - }, - "occurredAt": "2026-05-21T00:02:05.000Z" - }, - { - "traceEventId": "tev_m5-direct-executed", - "traceId": "trc_m5-direct-0001", - "projectId": "proj_m5-mvp-e2e", - "operationId": "op_m5-direct-0001", - "serviceId": "hwlab-patch-panel", - "level": "info", - "message": "Direct dry-run hardware operation executed by patch-panel-owned wiring.", - "environment": "dev", - "metadata": { - "state": "executed" - }, - "occurredAt": "2026-05-21T00:02:20.000Z" - }, - { - "traceEventId": "tev_m5-direct-completed", - "traceId": "trc_m5-direct-0001", - "projectId": "proj_m5-mvp-e2e", - "operationId": "op_m5-direct-0001", - "serviceId": "hwlab-cloud-api", - "level": "info", - "message": "Direct dry-run hardware operation completed.", - "environment": "dev", - "metadata": { - "state": "completed" - }, - "occurredAt": "2026-05-21T00:02:30.000Z" - }, - { - "traceEventId": "tev_m5-agent-accepted", - "traceId": "trc_m5-agent-0001", - "projectId": "proj_m5-mvp-e2e", - "agentSessionId": "agt_m5-0001", - "workerSessionId": "wkr_m5-0001", - "operationId": "op_m5-agent-0001", - "serviceId": "hwlab-agent-worker", - "level": "info", - "message": "Agent dry-run hardware operation accepted.", - "environment": "dev", - "metadata": { - "state": "accepted" - }, - "occurredAt": "2026-05-21T00:03:00.000Z" - }, - { - "traceEventId": "tev_m5-agent-dispatched", - "traceId": "trc_m5-agent-0001", - "projectId": "proj_m5-mvp-e2e", - "agentSessionId": "agt_m5-0001", - "workerSessionId": "wkr_m5-0001", - "operationId": "op_m5-agent-0001", - "serviceId": "hwlab-gateway", - "level": "info", - "message": "Agent dry-run hardware operation dispatched to gateway.", - "environment": "dev", - "metadata": { - "state": "dispatched" - }, - "occurredAt": "2026-05-21T00:03:05.000Z" - }, - { - "traceEventId": "tev_m5-agent-executed", - "traceId": "trc_m5-agent-0001", - "projectId": "proj_m5-mvp-e2e", - "agentSessionId": "agt_m5-0001", - "workerSessionId": "wkr_m5-0001", - "operationId": "op_m5-agent-0001", - "serviceId": "hwlab-patch-panel", - "level": "info", - "message": "Agent dry-run hardware operation executed by patch-panel-owned wiring.", - "environment": "dev", - "metadata": { - "state": "executed" - }, - "occurredAt": "2026-05-21T00:03:20.000Z" - }, - { - "traceEventId": "tev_m5-agent-completed", - "traceId": "trc_m5-agent-0001", - "projectId": "proj_m5-mvp-e2e", - "agentSessionId": "agt_m5-0001", - "workerSessionId": "wkr_m5-0001", - "operationId": "op_m5-agent-0001", - "serviceId": "hwlab-agent-worker", - "level": "info", - "message": "Agent dry-run hardware operation completed.", - "environment": "dev", - "metadata": { - "state": "completed" - }, - "occurredAt": "2026-05-21T00:03:30.000Z" - } - ], - "auditEvents": [ - { - "auditId": "aud_m5-project-selected", - "traceId": "trc_m5-project", - "actorType": "user", - "actorId": "user_m5-dry-run", - "action": "project.select", - "targetType": "project", - "targetId": "proj_m5-mvp-e2e", - "projectId": "proj_m5-mvp-e2e", - "serviceId": "hwlab-cloud-api", - "environment": "dev", - "outcome": "succeeded", - "metadata": { - "dryRun": true - }, - "occurredAt": "2026-05-21T00:00:00.000Z" - }, - { - "auditId": "aud_m5-gateway-started", - "traceId": "trc_m5-gateway", - "actorType": "service", - "actorId": "service_hwlab-gateway", - "action": "gateway.session.start", - "targetType": "gateway_session", - "targetId": "gws_m5-0001", - "projectId": "proj_m5-mvp-e2e", - "gatewaySessionId": "gws_m5-0001", - "serviceId": "hwlab-gateway", - "environment": "dev", - "outcome": "succeeded", - "metadata": { - "dryRun": true - }, - "occurredAt": "2026-05-21T00:00:30.000Z" - }, - { - "auditId": "aud_m5-wiring-applied", - "traceId": "trc_m5-wiring", - "actorType": "service", - "actorId": "service_hwlab-patch-panel", - "action": "wiring.apply", - "targetType": "wiring_config", - "targetId": "wir_m5-0001", - "projectId": "proj_m5-mvp-e2e", - "gatewaySessionId": "gws_m5-0001", - "serviceId": "hwlab-patch-panel", - "environment": "dev", - "outcome": "succeeded", - "metadata": { - "patchPanelStatusId": "pps_m5-0001", - "dryRun": true - }, - "occurredAt": "2026-05-21T00:01:00.000Z" - }, - { - "auditId": "aud_m5-direct-operation", - "traceId": "trc_m5-direct-0001", - "actorType": "user", - "actorId": "user_m5-dry-run", - "action": "hardware.operation.request", - "targetType": "hardware_operation", - "targetId": "op_m5-direct-0001", - "projectId": "proj_m5-mvp-e2e", - "gatewaySessionId": "gws_m5-0001", - "operationId": "op_m5-direct-0001", - "serviceId": "hwlab-cloud-api", - "environment": "dev", - "outcome": "succeeded", - "metadata": { - "dryRun": true - }, - "occurredAt": "2026-05-21T00:02:30.000Z" - }, - { - "auditId": "aud_m5-agent-session", - "traceId": "trc_m5-agent-session", - "actorType": "agent", - "actorId": "agent_m5-dry-run", - "action": "agent.session.start", - "targetType": "agent_session", - "targetId": "agt_m5-0001", - "projectId": "proj_m5-mvp-e2e", - "workerSessionId": "wkr_m5-0001", - "serviceId": "hwlab-agent-mgr", - "environment": "dev", - "outcome": "succeeded", - "metadata": { - "dryRun": true - }, - "occurredAt": "2026-05-21T00:02:45.000Z" - }, - { - "auditId": "aud_m5-agent-operation", - "traceId": "trc_m5-agent-0001", - "actorType": "worker", - "actorId": "worker_m5-dry-run", - "action": "hardware.operation.request", - "targetType": "hardware_operation", - "targetId": "op_m5-agent-0001", - "projectId": "proj_m5-mvp-e2e", - "gatewaySessionId": "gws_m5-0001", - "workerSessionId": "wkr_m5-0001", - "operationId": "op_m5-agent-0001", - "serviceId": "hwlab-agent-worker", - "environment": "dev", - "outcome": "succeeded", - "metadata": { - "agentSessionId": "agt_m5-0001", - "dryRun": true - }, - "occurredAt": "2026-05-21T00:03:30.000Z" - }, - { - "auditId": "aud_m5-cleanup", - "traceId": "trc_m5-cleanup", - "actorType": "worker", - "actorId": "worker_m5-dry-run", - "action": "worker.cleanup", - "targetType": "worker_session", - "targetId": "wkr_m5-0001", - "projectId": "proj_m5-mvp-e2e", - "gatewaySessionId": "gws_m5-0001", - "workerSessionId": "wkr_m5-0001", - "serviceId": "hwlab-agent-worker", - "environment": "dev", - "outcome": "succeeded", - "metadata": { - "cleanupId": "cln_m5-worker", - "dryRun": true - }, - "occurredAt": "2026-05-21T00:05:00.000Z" - } - ], - "evidenceRecords": [ - { - "evidenceId": "evi_m5-direct-0001", - "projectId": "proj_m5-mvp-e2e", - "operationId": "op_m5-direct-0001", - "kind": "trace", - "uri": "fixtures/mvp/m5-e2e/evidence/direct-operation.txt", - "mimeType": "text/plain", - "sha256": "4cd5cc2c942501b55f7a32f4184e9a1c920b31f13110f44ec68ddc0ad7dc1e34", - "sizeBytes": 180, - "serviceId": "hwlab-cloud-api", - "environment": "dev", - "metadata": { - "traceId": "trc_m5-direct-0001", - "dryRun": true - }, - "createdAt": "2026-05-21T00:02:40.000Z" - }, - { - "evidenceId": "evi_m5-agent-0001", - "projectId": "proj_m5-mvp-e2e", - "operationId": "op_m5-agent-0001", - "agentSessionId": "agt_m5-0001", - "workerSessionId": "wkr_m5-0001", - "kind": "trace", - "uri": "fixtures/mvp/m5-e2e/evidence/agent-operation.txt", - "mimeType": "text/plain", - "sha256": "96e7ffa99efd78b3d5d1b1dfd08be2f077f618dacf786b47f764794714c15c1c", - "sizeBytes": 178, - "serviceId": "hwlab-agent-worker", - "environment": "dev", - "metadata": { - "traceId": "trc_m5-agent-0001", - "dryRun": true - }, - "createdAt": "2026-05-21T00:03:40.000Z" - } - ], - "cleanup": [ - { - "cleanupId": "cln_m5-worker", - "targetType": "worker_session", - "targetId": "wkr_m5-0001", - "status": "completed", - "serviceId": "hwlab-agent-worker", - "environment": "dev", - "requires": [ - "op_m5-agent-0001", - "evi_m5-agent-0001" - ], - "outputs": { - "workerSessionStatus": "completed", - "workspaceReleased": true, - "gatewaySessionClosed": false - }, - "completedAt": "2026-05-21T00:05:00.000Z" - }, - { - "cleanupId": "cln_m5-gateway-state", - "targetType": "gateway_session", - "targetId": "gws_m5-0001", - "status": "completed", - "serviceId": "hwlab-gateway", - "environment": "dev", - "requires": [ - "op_m5-direct-0001", - "op_m5-agent-0001" - ], - "outputs": { - "transientStateCleared": true, - "patchPanelStatePreservedForEvidence": true - }, - "completedAt": "2026-05-21T00:05:10.000Z" - } - ], - "steps": [ - { - "id": "step-dev-health", - "order": 1, - "title": "DEV health", - "kind": "health", - "requires": [], - "inputs": [ - "endpoint", - "health:hlt_m5-dev-ingress", - "health:hlt_m5-edge-route", - "health:hlt_m5-frp", - "health:hlt_m5-router", - "health:hlt_m5-cloud-api", - "health:hlt_m5-cloud-web" - ], - "outputs": [ - "route_m5-dev", - "tun_m5-d601", - "hwlab-dev", - "hwlab-cloud-api", - "hwlab-cloud-web" - ], - "acceptanceStepIds": [ - "static-contract-parse", - "endpoint-freeze", - "dev-ingress-health", - "edge-route", - "frp-tunnel", - "d601-router", - "cloud-surface" - ] - }, - { - "id": "step-gateway-box-status", - "order": 2, - "title": "gateway/box status", - "kind": "status", - "requires": [ - "step-dev-health" - ], - "inputs": [ - "gatewaySession:gws_m5-0001", - "gatewaySession:gws_m5-simu-0001", - "boxResource:res_m5-control-relay", - "boxResource:res_m5-target-board", - "capability:cap_m5-relay-set", - "capability:cap_m5-board-reset" - ], - "outputs": [ - "gws_m5-0001", - "res_m5-control-relay", - "res_m5-target-board", - "cap_m5-relay-set", - "cap_m5-board-reset" - ], - "acceptanceStepIds": [ - "gateway-sim-patch-panel" - ] - }, - { - "id": "step-wiring", - "order": 3, - "title": "wiring", - "kind": "wiring", - "requires": [ - "step-gateway-box-status" - ], - "inputs": [ - "wiringConfig:wir_m5-0001", - "patchPanelStatus:pps_m5-0001" - ], - "outputs": [ - "wir_m5-0001", - "pps_m5-0001" - ], - "acceptanceStepIds": [ - "gateway-sim-patch-panel" - ] - }, - { - "id": "step-direct-hardware-call", - "order": 4, - "title": "direct hardware call", - "kind": "hardware_operation", - "requires": [ - "step-wiring" - ], - "inputs": [ - "rpc:direct-hardware-request", - "operation:op_m5-direct-0001", - "resource:res_m5-control-relay", - "capability:cap_m5-relay-set", - "patchPanelStatus:pps_m5-0001" - ], - "outputs": [ - "op_m5-direct-0001", - "trc_m5-direct-0001" - ], - "acceptanceStepIds": [ - "gateway-sim-patch-panel" - ] - }, - { - "id": "step-audit", - "order": 5, - "title": "audit", - "kind": "audit", - "requires": [ - "step-direct-hardware-call" - ], - "inputs": [ - "audit:aud_m5-project-selected", - "audit:aud_m5-gateway-started", - "audit:aud_m5-wiring-applied", - "audit:aud_m5-direct-operation" - ], - "outputs": [ - "aud_m5-direct-operation" - ], - "acceptanceStepIds": [ - "artifact-observability" - ] - }, - { - "id": "step-agent-session", - "order": 6, - "title": "agent session", - "kind": "agent", - "requires": [ - "step-audit" - ], - "inputs": [ - "agentSession:agt_m5-0001", - "workerSession:wkr_m5-0001", - "health:hlt_m5-agent-mgr", - "health:hlt_m5-agent-worker", - "health:hlt_m5-agent-skills" - ], - "outputs": [ - "agt_m5-0001", - "wkr_m5-0001" - ], - "acceptanceStepIds": [ - "agent-runtime" - ] - }, - { - "id": "step-agent-hardware-call", - "order": 7, - "title": "agent hardware call", - "kind": "hardware_operation", - "requires": [ - "step-agent-session", - "step-wiring" - ], - "inputs": [ - "rpc:agent-hardware-request", - "operation:op_m5-agent-0001", - "agentSession:agt_m5-0001", - "workerSession:wkr_m5-0001", - "resource:res_m5-control-relay", - "capability:cap_m5-relay-set", - "patchPanelStatus:pps_m5-0001" - ], - "outputs": [ - "op_m5-agent-0001", - "trc_m5-agent-0001" - ], - "acceptanceStepIds": [ - "agent-runtime" - ] - }, - { - "id": "step-evidence", - "order": 8, - "title": "evidence", - "kind": "evidence", - "requires": [ - "step-direct-hardware-call", - "step-agent-hardware-call" - ], - "inputs": [ - "trace:trc_m5-direct-0001", - "trace:trc_m5-agent-0001", - "evidence:evi_m5-direct-0001", - "evidence:evi_m5-agent-0001" - ], - "outputs": [ - "evi_m5-direct-0001", - "evi_m5-agent-0001" - ], - "acceptanceStepIds": [ - "agent-runtime", - "artifact-observability" - ] - }, - { - "id": "step-cleanup", - "order": 9, - "title": "cleanup", - "kind": "cleanup", - "requires": [ - "step-evidence" - ], - "inputs": [ - "cleanup:cln_m5-worker", - "cleanup:cln_m5-gateway-state", - "audit:aud_m5-cleanup" - ], - "outputs": [ - "cln_m5-worker", - "cln_m5-gateway-state" - ], - "acceptanceStepIds": [ - "agent-runtime" - ] - } - ], - "expectedSequence": [ - "step-dev-health", - "step-gateway-box-status", - "step-wiring", - "step-direct-hardware-call", - "step-audit", - "step-agent-session", - "step-agent-hardware-call", - "step-evidence", - "step-cleanup" - ], - "realDevGate": { - "command": "hwlab-cli test e2e --env dev --mvp", - "requiresAllBlockersClear": true, - "requiresDryRunPass": true, - "requiresArtifactObservability": true, - "requiresD601RouteObservation": true, - "requiresHumanApprovalForRealDev": true - } -} diff --git a/fixtures/mvp/m5-e2e/evidence/agent-operation.txt b/fixtures/mvp/m5-e2e/evidence/agent-operation.txt deleted file mode 100644 index 80970617..00000000 --- a/fixtures/mvp/m5-e2e/evidence/agent-operation.txt +++ /dev/null @@ -1,6 +0,0 @@ -M5 MVP E2E dry-run evidence -operationId: op_m5-agent-0001 -traceId: trc_m5-agent-0001 -mode: dry-run -source: fixtures/mvp/m5-e2e/dry-run-plan.json -No DEV or PROD request was made. diff --git a/fixtures/mvp/m5-e2e/evidence/direct-operation.txt b/fixtures/mvp/m5-e2e/evidence/direct-operation.txt deleted file mode 100644 index 42b9ca99..00000000 --- a/fixtures/mvp/m5-e2e/evidence/direct-operation.txt +++ /dev/null @@ -1,6 +0,0 @@ -M5 MVP E2E dry-run evidence -operationId: op_m5-direct-0001 -traceId: trc_m5-direct-0001 -mode: dry-run -source: fixtures/mvp/m5-e2e/dry-run-plan.json -No DEV or PROD request was made. diff --git a/fixtures/mvp/runtime.json b/fixtures/mvp/runtime.json deleted file mode 100644 index b0061aa9..00000000 --- a/fixtures/mvp/runtime.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "project": { - "projectId": "proj_mvp-l6", - "name": "HWLAB L6 MVP", - "description": "Mock project for the HWLAB CLI and cloud web skeleton.", - "status": "active", - "environment": "dev", - "labels": { - "track": "mvp", - "surface": "cli-web" - }, - "createdBy": "service_hwlab-cli", - "createdAt": "2026-05-21T00:00:00.000Z", - "updatedAt": "2026-05-21T00:00:00.000Z" - }, - "projects": [ - { - "projectId": "proj_mvp-l6", - "name": "HWLAB L6 MVP", - "status": "active", - "environment": "dev", - "updatedAt": "2026-05-21T00:00:00.000Z" - }, - { - "projectId": "proj_cloud-web", - "name": "Cloud Web Skeleton", - "status": "active", - "environment": "dev", - "updatedAt": "2026-05-20T12:00:00.000Z" - } - ], - "serviceRoute": [ - "browser/CLI/gateway", - "master hwlab-edge-proxy", - "frp", - "D601 hwlab-dev/hwlab-router", - "cloud-web/cloud-api" - ], - "mvpSteps": [ - "browser/CLI/gateway", - "master hwlab-edge-proxy", - "frp", - "D601 hwlab-dev/hwlab-router", - "cloud-web/cloud-api", - "hardware trusted closed loop", - "agent automation closed loop", - "evidence record", - "worker cleanup" - ], - "endpoints": { - "dev": "http://74.48.78.17:16667" - }, - "loops": [ - { - "name": "hardware trusted closed loop", - "description": "Route a validated wiring config to the gateway and observe the corresponding hardware operation lifecycle." - }, - { - "name": "agent automation closed loop", - "description": "Schedule agent and worker sessions, execute a scoped operation, and persist the resulting audit and evidence trail." - } - ], - "evidence": [ - { - "evidenceId": "evidence_mvp-runtime-plan", - "projectId": "proj_mvp-l6", - "operationId": "op_mvp-0001", - "kind": "report", - "uri": "fixtures/mvp/evidence/mvp-runtime-plan.md", - "sha256": "0000000000000000000000000000000000000000000000000000000000000000", - "serviceId": "hwlab-cli", - "environment": "dev", - "createdAt": "2026-05-21T00:00:00.000Z", - "metadata": { - "source": "mock" - } - } - ], - "cleanup": [ - "release worker sessions", - "clear transient gateway state", - "archive evidence references", - "close the CLI-runner task" - ] -} diff --git a/internal/cloud/server-rest-payloads.ts b/internal/cloud/server-rest-payloads.ts index 0816217b..7421b692 100644 --- a/internal/cloud/server-rest-payloads.ts +++ b/internal/cloud/server-rest-payloads.ts @@ -21,7 +21,6 @@ const LIVE_BUILD_SERVICE_DEFAULTS = Object.freeze({ "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-cli": Object.freeze({ urlEnv: "HWLAB_CLI_URL", defaultUrl: "http://hwlab-cli.hwlab-dev.svc.cluster.local:7501" }), "hwlab-agent-skills": Object.freeze({ urlEnv: "HWLAB_AGENT_SKILLS_URL", defaultUrl: "http://hwlab-agent-skills.hwlab-dev.svc.cluster.local:7430" }) }); const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([ @@ -769,7 +768,6 @@ function serviceDefaultUrl(serviceId) { "hwlab-device-pod": 7601, "hwlab-gateway": 7001, "hwlab-edge-proxy": 6667, - "hwlab-cli": 7501, "hwlab-agent-skills": 7430 }[serviceId] ?? 8080; return `http://${serviceId}.hwlab-dev.svc.cluster.local:${port}`; diff --git a/internal/mvp-gate/summary.mjs b/internal/mvp-gate/summary.mjs index 3eb30cfa..08aac22b 100644 --- a/internal/mvp-gate/summary.mjs +++ b/internal/mvp-gate/summary.mjs @@ -4,326 +4,96 @@ import path from "node:path"; import { DEV_ENDPOINT } from "../protocol/index.mjs"; export { DEV_ENDPOINT }; -export const DEFAULT_GATE_REPORT_SOURCE = "source:mvp-gate-summary"; -export const DEFAULT_M5_PLAN_PATH = "fixtures/mvp/m5-e2e/dry-run-plan.json"; + +export const DEFAULT_GATE_REPORT_SOURCE = "source:v02-live-api-summary"; +export const V02_CLI_LIVE_CHECKS = Object.freeze([ + "node scripts/run-bun.mjs tools/hwlab-cli/bin/hwlab-cli.ts client auth session --base-url http://74.48.78.17:19666 --no-session", + "node scripts/run-bun.mjs tools/hwlab-cli/bin/hwlab-cli.ts client auth login --base-url http://74.48.78.17:19666 --username admin --password-env HWLAB_PASSWORD", + "node scripts/run-bun.mjs tools/hwlab-cli/bin/hwlab-cli.ts client device-pods list --base-url http://74.48.78.17:19666", + "node scripts/run-bun.mjs tools/hwlab-cli/bin/hwlab-cli.ts client device-pods status device-pod-71-freq --base-url http://74.48.78.17:19666", + "node scripts/run-bun.mjs tools/hwlab-cli/bin/hwlab-cli.ts client workbench summary --base-url http://74.48.78.17:19666 --pod-id device-pod-71-freq" +]); + export const M3_WORKBENCH_TOPOLOGY = Object.freeze({ - projectId: "prj_m3_hardware_loop", - projectStatus: "blocked", - gateways: Object.freeze([ - Object.freeze({ - gatewaySessionId: "gws_gwsimu_1", - serviceId: "hwlab-gateway-simu", - gatewayId: "gwsimu_1", - status: "connected", - environment: "dev", - endpoint: null - }), - Object.freeze({ - gatewaySessionId: "gws_gwsimu_2", - serviceId: "hwlab-gateway-simu", - gatewayId: "gwsimu_2", - status: "connected", - environment: "dev", - endpoint: null - }) - ]), - boxResources: Object.freeze([ - Object.freeze({ - resourceId: "res_boxsimu_1", - gatewaySessionId: "gws_gwsimu_1", - boxId: "boxsimu_1", - resourceType: "simulator_endpoint", - name: "BOX-SIMU 1 source DO1", - state: "available", - environment: "dev" - }), - Object.freeze({ - resourceId: "res_boxsimu_2", - gatewaySessionId: "gws_gwsimu_2", - boxId: "boxsimu_2", - resourceType: "simulator_endpoint", - name: "BOX-SIMU 2 target DI1", - state: "available", - environment: "dev" - }) - ]), - patchPanel: Object.freeze({ - patchPanelStatusId: "pps_m3_hardware_loop", - serviceId: "hwlab-patch-panel", - state: "blocked", - environment: "dev", - activeConnectionCount: 1, - activeConnections: Object.freeze([ - Object.freeze({ - fromResourceId: "res_boxsimu_1", - fromPort: "DO1", - toResourceId: "res_boxsimu_2", - toPort: "DI1" - }) - ]) - }) + projectId: "retired_legacy_mvp_fixture", + projectStatus: "retired", + gateways: Object.freeze([]), + boxResources: Object.freeze([]), + patchPanel: null }); function readJsonFile(repoRoot, relativePath) { const absolutePath = path.resolve(repoRoot, relativePath); - const raw = fs.readFileSync(absolutePath, "utf8"); - return JSON.parse(raw); + return JSON.parse(fs.readFileSync(absolutePath, "utf8")); } function defaultMvpGateReport() { return { - reportVersion: "v1", - issue: "pikasTech/HWLAB#39", - taskId: "dev-mvp-gate-source-summary", - commitId: "source-summary", - acceptanceLevel: "dev_mvp_gate", + reportVersion: "v2", + issue: "pikasTech/HWLAB#7", + taskId: "v02-live-api-summary", + acceptanceLevel: "v02_live_api_observation", devOnly: true, prodDisabled: true, - gateStatus: "blocked", + gateStatus: "retired", sourceContract: { status: "pass", documents: [ - "docs/reference/MVP-e2e-acceptance.md", - "docs/reference/m3-loop-rollout-runbook.md", - "docs/reference/dev-runtime-boundary.md", - "docs/reference/spec-v02-documentation-governance.md", - "docs/reference/g14-gitops-cicd.md", + "docs/reference/spec-v02-hwlab-cli.md", "docs/reference/spec-v02-services.md", - "protocol/README.md", - "protocol/audit.md", - "protocol/evidence-chain.md", - "protocol/mvp-e2e-contract.md" + "docs/reference/spec-device-pod.md", + "docs/reference/g14-gitops-cicd.md" ], - summary: "The DEV gate is anchored to the frozen matrix, gate contract, and M0-M5 milestone source docs; progress and acceptance evidence live in GitHub issue or PR comments, not repository report files." + summary: "Legacy M5 fixture gate is retired. v0.2 validation uses short-connection hwlab-cli client commands against the same Cloud Web APIs used by the browser." }, - validationCommands: [ - "node scripts/repo-reports-guard.mjs", - "node --check scripts/validate-dev-gate-report.mjs", - "node scripts/validate-dev-gate-report.mjs" - ], + validationCommands: V02_CLI_LIVE_CHECKS, localSmoke: { status: "pass", - commands: [ - "node scripts/validate-contract.mjs", - "node scripts/validate-artifact-catalog.mjs", - "node scripts/m1-contract-smoke.mjs", - "node scripts/m2-dev-deploy-smoke.mjs --dry-run" - ], + commands: ["node scripts/run-bun.mjs test tools/hwlab-cli/client.test.ts"], evidence: [ - "Source contracts remain parseable.", - "MVP dry-run fixtures remain local/source only.", - "Repository report directory is forbidden by repo guard." + "hwlab-cli stores only cookie session metadata.", + "device-pods and workbench commands use Cloud Web API paths, not fixtures." ], - summary: "M0-M2 contract and smoke checks remain local/source scoped and do not create repository report files." + summary: "Short-connection CLI unit coverage replaces the old fixture dry-run gate." }, - dryRun: { - status: "pass", - commands: [ - "node tools/hwlab-cli/bin/hwlab-cli.mjs health", - "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run" - ], + liveChecks: { + status: "manual", + commands: V02_CLI_LIVE_CHECKS, evidence: [ - "Dry-run uses fixtures and performs no DEV/PROD mutations.", - "Dry-run evidence is not live acceptance." + "Unauthenticated requests must return explicit auth state or HTTP auth errors with JSON output.", + "Authenticated requests must exercise /auth, /v1/device-pods, /v1/live-builds, and health endpoints through 19666." ], - summary: "The M5 orchestration dry-run is fixture-only and does not establish a live DEV gate." - }, - devPreconditions: { - status: "blocked", - classification: "runtime durable adapter; skills injection; hardware loop", - requirements: [ - "Current DEV route and health checks must be rerun when needed and summarized in GitHub.", - "Runtime durable adapter proves postgres schema, migration, and read-query readiness before M4/M5 can promote.", - "Agent manager, skills service, and worker jobs expose explicit skill commit/version before M4/M5 can promote.", - "M3 hardware loop proves box-simu-1 DO1 to box-simu-2 DI1 via patch-panel.", - "Keep the run DEV-only and avoid PROD, heavy e2e, secrets, restarts, or UniDesk substitution." - ], - commands: [ - "curl -fsS --max-time 8 http://74.48.78.17:16666/", - "curl -fsS --max-time 8 http://74.48.78.17:16667/health/live", - "curl -fsS --max-time 8 http://74.48.78.17:16667/health", - "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production", - "node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production" - ], - evidence: [ - "Repository report files were removed and no longer qualify as current evidence.", - "Live DEV acceptance must be represented in #7, the owning issue, daily brief, or PR comments." - ], - summary: "Live MVP remains blocked until current DEV checks clear runtime durability, skills injection, and M3 hardware-loop blockers outside repository report files." + summary: "Live non-visual workbench checks are performed through hwlab-cli client, not a service, image, Job, or CI gate." }, blockers: [ { - type: "runtime_blocker", - scope: "runtime-durable-adapter", - status: "open", - sourceIssue: "pikasTech/HWLAB#164", - classification: "runtime_durable_adapter_query_blocked", - summary: - "cloud-api durable runtime adapter readiness is blocked by runtime_durable_adapter_query_blocked; it must prove schema, migration, and read-query durability before M4/M5 promotion." - }, - { - type: "agent_blocker", - scope: "skills-commit-version-injection", - status: "open", - sourceIssue: "pikasTech/HWLAB#164", - classification: "skills injection", - summary: "DEV skills injection must expose explicit skills commit and version across manager, skills, and worker paths." - }, - { - type: "runtime_blocker", - scope: "m3-hardware-loop-runtime", - status: "open", - sourceIssue: "pikasTech/HWLAB#38", - classification: "hardware loop", - summary: "Live M3 must prove the patch-panel trusted loop with operation, trace, audit, and evidence identifiers." + type: "runtime_observation", + scope: "v02-live-api", + status: "manual", + summary: "Run the current short-connection CLI against Cloud Web when live evidence is needed." } ], milestones: [ { - id: "M0", - status: "pass", - commands: ["node scripts/validate-contract.mjs"], - evidence: ["Source contracts are parseable."], - summary: "Frozen contracts remain source-ready." + id: "v0.2-cli", + status: "active", + commands: V02_CLI_LIVE_CHECKS, + evidence: ["WEB-equivalent non-visual APIs are tested through hwlab-cli client."], + summary: "The current validation path is short-connection live API probing." }, { - id: "M1", - status: "pass", - commands: ["node scripts/m1-contract-smoke.mjs"], - evidence: ["Local skeleton smoke remains local-only."], - summary: "The local M1 contract smoke passes." - }, - { - id: "M2", - status: "pass", - commands: ["node scripts/m2-dev-deploy-smoke.mjs --dry-run"], - evidence: ["Deploy smoke remains fixture-only."], - summary: "The deploy smoke validates source contracts without live acceptance." - }, - { - id: "M3", - status: "blocked", - commands: ["node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"], - evidence: ["Repository report snapshots were removed; current live trusted-loop proof is required."], - summary: "Active DEV-LIVE M3 remains blocked by hardware loop topology until the trusted loop is proven.", - blocker: "hardware loop" - }, - { - id: "M4", - status: "blocked", - commands: ["node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"], - evidence: ["Runtime durable adapter readiness and skills injection must be current live facts."], - summary: "Active DEV-LIVE M4 remains blocked by runtime durable adapter readiness and skills commit/version injection.", - blocker: "runtime_durable_adapter_query_blocked; skills injection" - }, - { - id: "M5", - status: "blocked", - commands: ["node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run"], - evidence: ["M5 dry-run remains fixture-only; live acceptance must be posted in GitHub."], - summary: "The M5 orchestration is green in dry-run mode, but active DEV-LIVE acceptance remains blocked.", - blocker: "runtime durable adapter; skills injection; hardware loop" + id: "legacy-m5-fixture", + status: "retired", + commands: [], + evidence: ["Old hwlab-cli artifact/test fixture is removed from active checks."], + summary: "Do not restore the former M5 fixture gate or hwlab-cli image/job path." } ] }; } -function countByServiceId(items) { - return new Map(items.map((item) => [item.serviceId, item])); -} - -function requireArray(value, label) { - if (!Array.isArray(value)) { - throw new Error(`${label} must be an array`); - } - return value; -} - -function assertRequiredMvpGate(report, plan) { - if (report.devOnly !== true || report.prodDisabled !== true) { - throw new Error("MVP gate report must be DEV-only and PROD-disabled"); - } - if (report.gateStatus !== "blocked") { - throw new Error("MVP gate report must not claim live DEV pass in this MVP fixture"); - } - if (plan.mode !== "dry-run" || plan.environment !== "dev" || plan.endpoint !== DEV_ENDPOINT) { - throw new Error("M5 plan must stay dry-run, dev, and pinned to the frozen DEV endpoint"); - } - - const milestones = requireArray(report.milestones, "report.milestones"); - for (const milestone of ["M0", "M1", "M2", "M3", "M4", "M5"]) { - if (!milestones.some((item) => item.id === milestone)) { - throw new Error(`MVP gate report missing milestone ${milestone}`); - } - } - - const artifacts = requireArray(plan.artifacts, "plan.artifacts"); - const health = requireArray(plan.health, "plan.health"); - const gatewaySessions = requireArray(plan.gatewaySessions, "plan.gatewaySessions"); - const boxResources = requireArray(plan.boxResources, "plan.boxResources"); - const evidenceRecords = requireArray(plan.evidenceRecords, "plan.evidenceRecords"); - const operations = requireArray(plan.operations, "plan.operations"); - - const byService = countByServiceId(artifacts); - for (const serviceId of [ - "hwlab-cloud-web", - "hwlab-cloud-api", - "hwlab-gateway", - "hwlab-gateway-simu", - "hwlab-box-simu", - "hwlab-patch-panel", - "hwlab-agent-mgr", - "hwlab-agent-worker", - "hwlab-cli" - ]) { - if (!byService.has(serviceId)) { - throw new Error(`M5 plan missing artifact for ${serviceId}`); - } - } - - if (gatewaySessions.length < 2) { - throw new Error("M5 plan must include two gateway sessions"); - } - if (boxResources.length < 2) { - throw new Error("M5 plan must include two box resources"); - } - if (!plan.patchPanelStatus?.patchPanelStatusId) { - throw new Error("M5 plan missing patch-panel status evidence"); - } - if (!plan.agentSession?.agentSessionId || !plan.workerSession?.workerSessionId) { - throw new Error("M5 plan missing agent and worker session evidence"); - } - if (evidenceRecords.length < 2 || operations.length < 2) { - throw new Error("M5 plan must include direct and agent evidence records"); - } -} - -function summarizeArtifact(artifact) { - return { - serviceId: artifact.serviceId, - commitId: artifact.commitId, - image: artifact.image, - tag: artifact.tag, - digest: artifact.digest, - digestNotApplicableReason: artifact.digestNotApplicableReason ?? null, - buildSource: artifact.buildSource, - deployEnv: artifact.deployEnv, - healthTimestamp: artifact.healthTimestamp - }; -} - -function summarizeHealth(health) { - return { - healthId: health.healthId, - serviceId: health.serviceId, - component: health.component, - status: health.status, - deployEnv: health.deployEnv, - endpoint: health.endpoint ?? null, - observedBy: health.observedBy, - dryRun: health.dryRun === true, - healthTimestamp: health.healthTimestamp - }; +function clone(value) { + return JSON.parse(JSON.stringify(value)); } function summarizeMilestone(milestone) { @@ -345,198 +115,70 @@ function summarizeBlocker(blocker) { }; } -function summarizeStep(step) { - return { - id: step.id, - order: step.order, - title: step.title, - kind: step.kind, - requires: step.requires, - inputs: step.inputs, - outputs: step.outputs, - acceptanceStepIds: step.acceptanceStepIds - }; -} - -function summarizeGatewaySession(session) { - return { - gatewaySessionId: session.gatewaySessionId, - serviceId: session.serviceId, - gatewayId: session.gatewayId, - status: session.status, - environment: session.environment, - endpoint: session.endpoint ?? null - }; -} - -function summarizeBoxResource(resource) { - return { - resourceId: resource.resourceId, - gatewaySessionId: resource.gatewaySessionId, - boxId: resource.boxId, - resourceType: resource.resourceType, - name: resource.name, - state: resource.state, - environment: resource.environment - }; -} - -function summarizeEvidence(record) { - return { - evidenceId: record.evidenceId, - operationId: record.operationId, - agentSessionId: record.agentSessionId ?? null, - workerSessionId: record.workerSessionId ?? null, - kind: record.kind, - uri: record.uri, - sha256: record.sha256, - serviceId: record.serviceId, - environment: record.environment, - traceId: record.metadata?.traceId ?? null, - dryRun: record.metadata?.dryRun === true, - createdAt: record.createdAt ?? null - }; -} - -function summarizeAuditEvent(event) { - return { - auditId: event.auditId, - traceId: event.traceId, - action: event.action, - targetType: event.targetType, - targetId: event.targetId, - projectId: event.projectId ?? null, - gatewaySessionId: event.gatewaySessionId ?? null, - operationId: event.operationId ?? null, - workerSessionId: event.workerSessionId ?? null, - serviceId: event.serviceId, - environment: event.environment, - outcome: event.outcome ?? null, - dryRun: event.metadata?.dryRun === true, - occurredAt: event.occurredAt - }; -} - -function clone(value) { - return JSON.parse(JSON.stringify(value)); -} - -export function buildMvpGateSummary({ report, plan }) { - assertRequiredMvpGate(report, plan); - - const artifacts = plan.artifacts.map(summarizeArtifact); - const health = plan.health.map(summarizeHealth); - const milestones = report.milestones.map(summarizeMilestone); - const blockers = report.blockers.map(summarizeBlocker); - const steps = plan.steps.map(summarizeStep).sort((a, b) => a.order - b.order); - const blocked = blockers.some((blocker) => blocker.status === "open") || report.gateStatus === "blocked"; - +export function buildMvpGateSummary({ report = defaultMvpGateReport() } = {}) { + const blockers = Array.isArray(report.blockers) ? report.blockers.map(summarizeBlocker) : []; + const milestones = Array.isArray(report.milestones) ? report.milestones.map(summarizeMilestone) : []; return { generatedFrom: { gateReport: DEFAULT_GATE_REPORT_SOURCE, - m5Plan: DEFAULT_M5_PLAN_PATH + m5Plan: "retired" }, - issue: "pikasTech/HWLAB#56", - supports: ["pikasTech/HWLAB#7", "pikasTech/HWLAB#9"], - gateStatus: report.gateStatus, - blocked, - acceptanceLevel: report.acceptanceLevel, - reportCommitId: report.commitId, - dryRunOnly: true, + issue: report.issue ?? "pikasTech/HWLAB#7", + supports: ["pikasTech/HWLAB#7"], + gateStatus: report.gateStatus ?? "retired", + activeGate: false, + legacyFixtureGateRetired: true, + acceptanceLevel: report.acceptanceLevel ?? "v02_live_api_observation", + reportCommitId: report.commitId ?? "source-summary", + dryRunOnly: false, devOnly: report.devOnly === true, prodDisabled: report.prodDisabled === true, - environment: plan.environment, - endpoint: plan.endpoint, - namespace: "hwlab-dev", - sourceSummary: report.sourceContract.summary, - localSmoke: report.localSmoke, - dryRun: report.dryRun, - devPreconditions: report.devPreconditions, + environment: "v02", + endpoint: "http://74.48.78.17:19666", + namespace: "hwlab-v02", + sourceSummary: report.sourceContract?.summary ?? "Legacy fixture gate retired.", + localSmoke: report.localSmoke ?? null, + liveChecks: report.liveChecks ?? null, blockers, milestones, - artifactCount: artifacts.length, - healthCount: health.length, - gatewaySessionCount: plan.gatewaySessions.length, - boxResourceCount: plan.boxResources.length, - patchPanelCount: 1, - operationCount: plan.operations.length, - evidenceCount: plan.evidenceRecords.length, - traceCount: plan.traceEvents.length, - auditCount: plan.auditEvents.length, - cleanupCount: plan.cleanup.length, - artifacts, - health, topology: clone(M3_WORKBENCH_TOPOLOGY), - m5DryRunTopology: { - projectId: plan.project.projectId, - projectStatus: plan.project.status, - gateways: plan.gatewaySessions.map(summarizeGatewaySession), - boxResources: plan.boxResources.map(summarizeBoxResource), - patchPanel: { - patchPanelStatusId: plan.patchPanelStatus.patchPanelStatusId, - serviceId: plan.patchPanelStatus.serviceId, - state: plan.patchPanelStatus.state, - environment: plan.patchPanelStatus.environment, - activeConnectionCount: plan.patchPanelStatus.activeConnections.length, - activeConnections: plan.patchPanelStatus.activeConnections + serviceModel: { + "hwlab-cli": { + runtimeService: false, + image: false, + kubernetesJob: false, + gitopsDesiredState: false, + invocation: "short-connection client from G14:/root/hwlab-v02" } }, - agent: { - agentSessionId: plan.agentSession.agentSessionId, - agentServiceId: plan.agentSession.serviceId, - agentStatus: plan.agentSession.status, - workerSessionId: plan.workerSession.workerSessionId, - workerServiceId: plan.workerSession.serviceId, - workerStatus: plan.workerSession.status, - projectId: plan.agentSession.projectId, - environment: plan.agentSession.environment, - cleanupId: plan.agentSession.metadata.cleanupId - }, - operations: plan.operations.map((operation) => ({ - operationId: operation.operationId, - requestedBy: operation.requestedBy, - status: operation.status, - resourceId: operation.resourceId, - capabilityId: operation.capabilityId, - agentSessionId: operation.agentSessionId ?? null, - workerSessionId: operation.workerSessionId ?? null, - environment: operation.environment, - dryRun: operation.input?.dryRun === true, - requestedAt: operation.requestedAt ?? null, - updatedAt: operation.updatedAt ?? null - })), - auditEvents: plan.auditEvents.map(summarizeAuditEvent), - evidenceRecords: plan.evidenceRecords.map(summarizeEvidence), - steps, + validationCommands: report.validationCommands ?? V02_CLI_LIVE_CHECKS, realDevGate: { - sourcePlanCommand: plan.realDevGate.command, - command: `${plan.realDevGate.command} --live --confirm-dev --confirmed-non-production`, - requiresAllBlockersClear: plan.realDevGate.requiresAllBlockersClear, - requiresDryRunPass: plan.realDevGate.requiresDryRunPass, - requiresArtifactObservability: plan.realDevGate.requiresArtifactObservability, - requiresD601RouteObservation: plan.realDevGate.requiresD601RouteObservation, - requiresHumanApprovalForRealDev: plan.realDevGate.requiresHumanApprovalForRealDev + sourcePlanCommand: "retired", + command: "use hwlab-cli client live commands from docs/reference/spec-v02-hwlab-cli.md", + requiresAllBlockersClear: false, + requiresDryRunPass: false, + requiresArtifactObservability: false, + requiresD601RouteObservation: false, + requiresHumanApprovalForRealDev: false }, safety: { - mode: plan.mode, - allowNetwork: plan.safety.allowNetwork, - allowDeploy: plan.safety.allowDeploy, - allowBrowserE2E: plan.safety.allowBrowserE2E, - allowSecrets: plan.safety.allowSecrets, - prohibitedActions: plan.safety.prohibitedActions + mode: "short-connection-live-api", + allowNetwork: true, + allowDeploy: false, + allowBrowserE2E: false, + allowSecrets: false, + prohibitedActions: ["create hwlab-cli image", "create hwlab-cli service", "create hwlab-cli job", "restore legacy M5 fixture gate"] } }; } export function loadMvpGateSummary(repoRoot, options = {}) { const reportPath = options.reportPath ?? null; - const planPath = options.planPath ?? DEFAULT_M5_PLAN_PATH; const report = reportPath ? readJsonFile(repoRoot, reportPath) : defaultMvpGateReport(); - const plan = readJsonFile(repoRoot, planPath); - const summary = buildMvpGateSummary({ report, plan }); + const summary = buildMvpGateSummary({ report }); summary.generatedFrom = { gateReport: reportPath ?? DEFAULT_GATE_REPORT_SOURCE, - m5Plan: planPath + m5Plan: "retired" }; return summary; } diff --git a/internal/protocol/index.mjs b/internal/protocol/index.mjs index 16fc3c28..b1b68ca7 100644 --- a/internal/protocol/index.mjs +++ b/internal/protocol/index.mjs @@ -13,7 +13,6 @@ export const SERVICE_IDS = Object.freeze([ "hwlab-device-pod", "hwlab-gateway", "hwlab-edge-proxy", - "hwlab-cli", "hwlab-agent-skills" ]); diff --git a/package.json b/package.json index ecfe7e12..6827b314 100644 --- a/package.json +++ b/package.json @@ -22,15 +22,11 @@ "deploy:contract:check": "node scripts/run-bun.mjs scripts/deploy-contract-plan.mjs --check", "deploy:desired-state:plan": "node scripts/run-bun.mjs scripts/deploy-desired-state-plan.mjs --pretty", "deploy:desired-state:check": "node scripts/run-bun.mjs scripts/deploy-desired-state-plan.mjs --check", - "cli:health": "node tools/hwlab-cli/bin/hwlab-cli.mjs health", - "cli:dry-run": "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp", - "cli:projects": "node tools/hwlab-cli/bin/hwlab-cli.mjs project list", - "l6:smoke": "node scripts/l6-cli-web-smoke.mjs", + "cli:client": "node scripts/run-bun.mjs tools/hwlab-cli/bin/hwlab-cli.ts client", "web:check": "(cd web/hwlab-cloud-web && bun run check) && bun test web/hwlab-cloud-web/message-markdown.test.ts", "web:build": "(cd web/hwlab-cloud-web && bun run build)", "artifact-catalog:preview-blocked": "node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --blocked --no-write", "g14:gitops:render": "node scripts/g14-gitops-render.mjs", - "cicd:jobs": "node tools/hwlab-cli/bin/hwlab-cli.mjs cicd jobs", "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", diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index 2c39e5df..9984e007 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -66,7 +66,6 @@ const servicePorts = new Map([ ["hwlab-device-pod", 7601], ["hwlab-gateway", 7001], ["hwlab-edge-proxy", 6667], - ["hwlab-cli", 7420], ["hwlab-agent-skills", 7430] ]); @@ -78,7 +77,6 @@ const v02RuntimeServiceIds = Object.freeze([ "hwlab-device-pod", "hwlab-gateway", "hwlab-edge-proxy", - "hwlab-cli", "hwlab-agent-skills" ]); @@ -944,16 +942,6 @@ async function preflight({ args, services, catalog, deployManifest, baseImagePre }) ); } - if (service.serviceId === "hwlab-cli" && service.implementationState === "library-only") { - blockers.push( - blocker({ - type: "runtime_blocker", - scope: service.serviceId, - summary: "tools/hwlab-cli declares bin/hwlab-cli.mjs but the bin file is absent; a health-only placeholder image was not built as a real CLI artifact.", - next: "Add tools/hwlab-cli/bin/hwlab-cli.mjs that calls tools/hwlab-cli/lib/cli.mjs." - }) - ); - } } return blockers; @@ -1917,11 +1905,9 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif }, dryRun: { status: "not_run", - commands: [ - "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run" - ], + commands: [], evidence: [ - "This report records DEV artifact build/publish state only; deployment dry-run remains separate." + "This report records DEV artifact build/publish state only; hwlab-cli is not an artifact service." ], summary: "DEV deploy dry-run is not implied by artifact publication." }, diff --git a/scripts/cloud-api-runtime-smoke.mjs b/scripts/cloud-api-runtime-smoke.mjs index 82886da7..4654431a 100644 --- a/scripts/cloud-api-runtime-smoke.mjs +++ b/scripts/cloud-api-runtime-smoke.mjs @@ -52,7 +52,7 @@ function rpcBody(id, method, params = {}) { meta: { traceId: `trc_${id}`, actorId: "usr_cloud_api_runtime_smoke", - serviceId: "hwlab-cli", + serviceId: "hwlab-cloud-api", environment: ENVIRONMENT_DEV } }); diff --git a/scripts/dev-m4-agent-loop-smoke.mjs b/scripts/dev-m4-agent-loop-smoke.mjs index 000f9fdc..b555d07d 100644 --- a/scripts/dev-m4-agent-loop-smoke.mjs +++ b/scripts/dev-m4-agent-loop-smoke.mjs @@ -483,7 +483,7 @@ async function runLivePreflight(fixture, dryRun) { params: {}, meta: { traceId: "trc_dev_m4_health", - serviceId: "hwlab-cli", + serviceId: "hwlab-cloud-api", environment: ENVIRONMENT_DEV } }) @@ -519,7 +519,7 @@ async function runLivePreflight(fixture, dryRun) { }, meta: { traceId: "trc_dev_m4_hw", - serviceId: "hwlab-cli", + serviceId: "hwlab-cloud-api", environment: ENVIRONMENT_DEV } }) diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index d89523a0..85fb1224 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -38,7 +38,6 @@ const defaultServiceIds = Object.freeze([ "hwlab-device-pod", "hwlab-gateway", "hwlab-edge-proxy", - "hwlab-cli", "hwlab-agent-skills" ]); const v02RuntimeServiceIds = Object.freeze([ @@ -49,7 +48,6 @@ const v02RuntimeServiceIds = Object.freeze([ "hwlab-device-pod", "hwlab-gateway", "hwlab-edge-proxy", - "hwlab-cli", "hwlab-agent-skills" ]); const v02RemovedServiceIds = new Set([ @@ -865,9 +863,6 @@ function transformWorkloads({ workloads, deploy, catalog, source, registryPrefix upsertEnv(container.env, "HWLAB_API_BASE_URL", `http://hwlab-cloud-api.${namespace}.svc.cluster.local:6667`); upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", webEndpoint); } - if (serviceId === "hwlab-cli") { - upsertEnv(container.env, "HWLAB_CLI_ENDPOINT", runtimeEndpoint); - } } rewritePodSecretRefs(podTemplate.spec, profile); } diff --git a/scripts/g14-gitops-render.test.ts b/scripts/g14-gitops-render.test.ts index 82a4c155..9cb9cb9a 100644 --- a/scripts/g14-gitops-render.test.ts +++ b/scripts/g14-gitops-render.test.ts @@ -166,7 +166,6 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots "hwlab-device-pod", "hwlab-gateway", "hwlab-edge-proxy", - "hwlab-cli", "hwlab-agent-skills" ]; for (let index = 0; index < retainedServiceIds.length; index += 1) { diff --git a/scripts/l6-cli-web-smoke.mjs b/scripts/l6-cli-web-smoke.mjs deleted file mode 100644 index 80cab408..00000000 --- a/scripts/l6-cli-web-smoke.mjs +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env node -import assert from "node:assert/strict"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { runCli } from "../tools/hwlab-cli/lib/cli.mjs"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); - -async function captureCli(args) { - let stdout = ""; - let stderr = ""; - const exitCode = await runCli(args, { - cwd: repoRoot, - stdout: { - write(chunk) { - stdout += chunk; - } - }, - stderr: { - write(chunk) { - stderr += chunk; - } - } - }); - return { exitCode, stdout, stderr }; -} - -const dryRun = await captureCli(["test", "e2e", "--env", "dev", "--mvp"]); -assert.equal(dryRun.exitCode, 0, dryRun.stderr); -const dryRunJson = JSON.parse(dryRun.stdout); -assert.equal(dryRunJson.mode, "dry-run"); -assert.equal(dryRunJson.environment, "dev"); -assert.match(String(dryRunJson.endpoint ?? ""), /^http:\/\//u); -assert.match(dryRunJson.statement, /no DEV\/PROD changes/); - -const explicitDryRun = await captureCli(["test", "e2e", "--env", "dev", "--mvp", "--dry-run"]); -assert.equal(explicitDryRun.exitCode, 0, explicitDryRun.stderr); -assert.equal(JSON.parse(explicitDryRun.stdout).mode, "dry-run"); - -const liveBlocked = await captureCli([ - "test", - "e2e", - "--env", - "dev", - "--mvp", - "--live", - "--confirm-dev", - "--confirmed-non-production" -]); -assert.equal(liveBlocked.exitCode, 2); -const liveBlockedJson = JSON.parse(liveBlocked.stderr); -assert.equal(liveBlockedJson.code, "BLOCKED"); -assert.ok(Array.isArray(liveBlockedJson.blockers)); - -const activeWebSource = [ - "web/hwlab-cloud-web/index.html", - "web/hwlab-cloud-web/app.ts", - "web/hwlab-cloud-web/app-device-pod.ts", - "web/hwlab-cloud-web/app-conversation.ts", - "web/hwlab-cloud-web/app-trace.ts", - "web/hwlab-cloud-web/app-helpers.ts", - "web/hwlab-cloud-web/live-status.ts", - "web/hwlab-cloud-web/help.md" -].map((file) => fs.readFileSync(path.resolve(repoRoot, file), "utf8")).join("\n"); -const distContract = fs.readFileSync(path.resolve(repoRoot, "web/hwlab-cloud-web/scripts/dist-contract.ts"), "utf8"); - -assert.match(activeWebSource, /Device Pod/u); -assert.match(activeWebSource, /\/v1\/device-pods/u); -for (const legacyTerm of ["Gateway-SIMU", "BOX-SIMU", "Patch Panel", "hwlab-patch-panel", "gateway-simu", "box-simu", "/v1/m3"]) { - assert.doesNotMatch(activeWebSource, new RegExp(escapeRegExp(legacyTerm), "u"), `legacy frontend term returned: ${legacyTerm}`); -} -assert.doesNotMatch(distContract, /gate-summary\.mjs/u, "Cloud Web dist must not ship the legacy gate-summary topology asset"); -assert.equal(fs.existsSync(path.resolve(repoRoot, "web/hwlab-cloud-web/gate-summary.mjs")), false, "legacy web gate-summary asset must be removed"); - -console.log("L6 CLI/Web smoke passed: CLI dry-run remains non-mutating and Cloud Web uses the Device Pod contract without the legacy gate-summary topology asset"); - -function escapeRegExp(value) { - return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); -} diff --git a/scripts/m1-contract-smoke.mjs b/scripts/m1-contract-smoke.mjs index 077dce28..8d3f7de5 100644 --- a/scripts/m1-contract-smoke.mjs +++ b/scripts/m1-contract-smoke.mjs @@ -10,7 +10,6 @@ import { fileURLToPath } from "node:url"; import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs"; import { validateCodeAgentChatSchema } from "../internal/cloud/code-agent-chat.ts"; -import { runCli } from "../tools/hwlab-cli/lib/cli.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const children = []; @@ -202,7 +201,7 @@ function rpcEnvelope({ id, method, params = {} }) { params, meta: { traceId: `trc_${id}`, - serviceId: "hwlab-cli", + serviceId: "hwlab-cloud-api", environment: ENVIRONMENT_DEV } }; @@ -513,47 +512,6 @@ async function smokePatchPanelAndSimulators() { logOk("patch-panel/sim status and route runtime"); } -async function smokeCliDryRunFixture() { - const runtime = JSON.parse(await readFile(path.join(repoRoot, "fixtures/mvp/runtime.json"), "utf8")); - assert.equal(runtime.endpoints.dev, DEV_ENDPOINT); - assert.ok(runtime.serviceRoute.includes("cloud-web/cloud-api")); - assert.ok(runtime.loops.some((loop) => loop.name === "hardware trusted closed loop")); - assert.ok(runtime.evidence.some((record) => record.evidenceId === "evidence_mvp-runtime-plan")); - - let stdout = ""; - let stderr = ""; - const exitCode = await runCli(["test", "e2e", "--env", "dev", "--mvp", "--dry-run"], { - cwd: repoRoot, - stdout: { - write(chunk) { - stdout += chunk; - } - }, - stderr: { - write(chunk) { - stderr += chunk; - } - } - }); - - assert.equal(exitCode, 0, stderr); - const output = JSON.parse(stdout); - assert.equal(output.command, "hwlab-cli test e2e --env dev --mvp --dry-run"); - assert.equal(output.mode, "dry-run"); - assert.equal(output.environment, "dev"); - assert.equal(output.endpoint, DEV_ENDPOINT); - assert.equal(output.safety.allowNetwork, false); - assert.equal(output.contractStatus.artifacts.services.includes("hwlab-cloud-web"), true); - assert.equal(output.contractStatus.artifacts.services.includes("hwlab-cloud-api"), true); - assert.equal(output.contractStatus.topology.gatewaySessions, 2); - assert.equal(output.contractStatus.topology.boxResources, 2); - assert.equal(output.contractStatus.agent.agentSessionId, "agt_m5-0001"); - assert.equal(output.contractStatus.evidence.records.length, 2); - assert.match(output.statement, /no DEV\/PROD changes/); - - logOk("CLI dry-run fixture parse"); -} - async function smokeAgentMgrLocalContract() { const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-m1-agent-mgr-")); const agentSessionId = "agt_m1_local_contract"; @@ -670,7 +628,6 @@ try { await smokeCloudApi(); await smokePatchPanelAndSimulators(); await smokeAgentMgrLocalContract(); - await smokeCliDryRunFixture(); process.stdout.write("[m1-smoke] passed\n"); } finally { await Promise.allSettled(children.map(stopNode)); diff --git a/scripts/m5-mvp-e2e-dry-run.mjs b/scripts/m5-mvp-e2e-dry-run.mjs deleted file mode 100644 index da3e7b44..00000000 --- a/scripts/m5-mvp-e2e-dry-run.mjs +++ /dev/null @@ -1,632 +0,0 @@ -#!/usr/bin/env node -import assert from "node:assert/strict"; -import crypto from "node:crypto"; -import { readFile, stat } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const planPath = "fixtures/mvp/m5-e2e/dry-run-plan.json"; -const checklistPath = "fixtures/mvp/m5-e2e/dev-acceptance-checklist.json"; -const matrixPath = "docs/reference/MVP-e2e-acceptance.md"; -const runtimePath = "fixtures/mvp/runtime.json"; -const contractPath = "protocol/mvp-e2e-contract.md"; -const commonSchemaPath = "protocol/schemas/common.json"; -const JSON_RPC_VERSION = "2.0"; -let serviceIds = []; -let serviceIdSet = new Set(); - -const requiredArtifactFields = [ - "serviceId", - "commitId", - "image", - "tag", - "digest", - "buildSource", - "deployEnv", - "healthTimestamp" -]; - -const requiredStepSequence = [ - "step-dev-health", - "step-gateway-box-status", - "step-wiring", - "step-direct-hardware-call", - "step-audit", - "step-agent-session", - "step-agent-hardware-call", - "step-evidence", - "step-cleanup" -]; - -const requiredTraceStates = ["accepted", "dispatched", "executed", "completed"]; - -function readJSON(relativePath) { - return readFile(path.join(repoRoot, relativePath), "utf8").then((raw) => JSON.parse(raw)); -} - -function byKey(items, key) { - const map = new Map(); - for (const item of items) { - assert.ok(item && typeof item === "object", `${key} item must be an object`); - assert.ok(item[key], `${key} item missing ${key}`); - assert.equal(map.has(item[key]), false, `${key} ${item[key]} must be unique`); - map.set(item[key], item); - } - return map; -} - -function requireDevObject(label, object, environmentField = "environment") { - assert.equal(object[environmentField], ENVIRONMENT_DEV, `${label} ${environmentField}`); -} - -function requireTimestamp(label, value) { - assert.equal(typeof value, "string", `${label} timestamp must be a string`); - assert.equal(Number.isNaN(Date.parse(value)), false, `${label} timestamp must parse`); -} - -function requireServiceId(label, serviceId) { - assert.equal(serviceIdSet.has(serviceId), true, `${label} unknown serviceId ${serviceId}`); -} - -function assertArrayIncludesAll(label, actual, expected) { - assert.ok(Array.isArray(actual), `${label} must be an array`); - for (const item of expected) { - assert.equal(actual.includes(item), true, `${label} missing ${item}`); - } -} - -function assertNoProdTarget(value, trail = "plan") { - if (typeof value === "string") { - assert.equal(value.includes(":6666"), false, `${trail} must not target deprecated legacy port`); - assert.equal(/\bprod\b/i.test(value), false, `${trail} must not target PROD`); - return; - } - - if (Array.isArray(value)) { - value.forEach((item, index) => assertNoProdTarget(item, `${trail}[${index}]`)); - return; - } - - if (value && typeof value === "object") { - for (const [key, nested] of Object.entries(value)) { - if ( - key === "prohibitedActions" || - key === "failureCriteria" || - key === "failRule" || - key === "digest" || - key === "sha256" - ) { - continue; - } - assertNoProdTarget(nested, `${trail}.${key}`); - } - } -} - -function assertObjectHasOnlyDryRunSafety(plan) { - assert.equal(plan.mode, "dry-run", "plan mode"); - assert.equal(plan.safety.allowNetwork, false, "network must be disabled"); - assert.equal(plan.safety.allowDeploy, false, "deploy must be disabled"); - assert.equal(plan.safety.allowBrowserE2E, false, "browser e2e must be disabled"); - assert.equal(plan.safety.allowSecrets, false, "secret reads must be disabled"); - assertArrayIncludesAll("prohibited actions", plan.safety.prohibitedActions, [ - "real-dev-deploy", - "prod-deploy", - "prod-smoke", - "browser-e2e", - "heavyweight-e2e", - "secret-or-token-read", - "force-push", - "unidesk-runtime-substitution" - ]); -} - -function validateMeta(meta) { - assert.ok(meta && typeof meta === "object" && !Array.isArray(meta), "meta must be an object"); - assert.ok(meta.traceId, "meta.traceId is required"); - requireServiceId("meta.serviceId", meta.serviceId); - assert.equal(meta.environment, ENVIRONMENT_DEV, "meta.environment"); -} - -function validateRequest(envelope) { - assert.ok(envelope && typeof envelope === "object" && !Array.isArray(envelope), "request must be an object"); - assert.equal(envelope.jsonrpc, JSON_RPC_VERSION, "request jsonrpc"); - assert.notEqual(envelope.id, undefined, "request id"); - assert.match(envelope.method, /^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$/, "request method"); - if (Object.hasOwn(envelope, "params")) { - assert.ok( - envelope.params && typeof envelope.params === "object" && !Array.isArray(envelope.params), - "request params must be an object" - ); - } - validateMeta(envelope.meta); -} - -function validateResponse(envelope) { - assert.ok(envelope && typeof envelope === "object" && !Array.isArray(envelope), "response must be an object"); - assert.equal(envelope.jsonrpc, JSON_RPC_VERSION, "response jsonrpc"); - assert.notEqual(envelope.id, undefined, "response id"); - const hasResult = Object.hasOwn(envelope, "result"); - const hasError = Object.hasOwn(envelope, "error"); - assert.notEqual(hasResult, hasError, "response must contain exactly one of result or error"); - if (hasError) { - assert.ok(envelope.error && typeof envelope.error === "object", "response error must be an object"); - assert.equal(Number.isInteger(envelope.error.code), true, "response error code"); - assert.ok(envelope.error.message, "response error message"); - } - validateMeta(envelope.meta); -} - -function assertEndpointFreeze(plan, checklist, runtime, contract) { - assert.equal(plan.environment, ENVIRONMENT_DEV, "plan environment"); - assert.equal(plan.endpoint, DEV_ENDPOINT, "plan endpoint"); - assert.equal(checklist.environment, ENVIRONMENT_DEV, "checklist environment"); - assert.equal(checklist.endpoint, DEV_ENDPOINT, "checklist endpoint"); - assert.equal(runtime.endpoints.dev, DEV_ENDPOINT, "runtime fixture endpoint"); - assert.equal(contract.includes(DEV_ENDPOINT), true, "MVP contract must include DEV endpoint"); - assertNoProdTarget(plan); -} - -function assertArtifacts(plan, checklist) { - assert.deepEqual(checklist.artifactObservabilityFields, requiredArtifactFields); - const artifacts = byKey(plan.artifacts, "serviceId"); - const requiredServiceIds = new Set([ - "hwlab-edge-proxy", - "hwlab-tunnel-client", - "hwlab-router", - "hwlab-cloud-api", - "hwlab-cloud-web", - "hwlab-gateway", - "hwlab-gateway-simu", - "hwlab-box-simu", - "hwlab-patch-panel", - "hwlab-agent-mgr", - "hwlab-agent-worker", - "hwlab-agent-skills", - "hwlab-cli" - ]); - - for (const serviceId of requiredServiceIds) { - assert.equal(artifacts.has(serviceId), true, `artifact missing ${serviceId}`); - } - - for (const artifact of artifacts.values()) { - requireServiceId(`artifact ${artifact.serviceId}`, artifact.serviceId); - for (const field of requiredArtifactFields) { - assert.ok(artifact[field], `artifact ${artifact.serviceId} missing ${field}`); - } - assert.equal(artifact.deployEnv, ENVIRONMENT_DEV, `artifact ${artifact.serviceId} deployEnv`); - requireTimestamp(`artifact ${artifact.serviceId}.healthTimestamp`, artifact.healthTimestamp); - if (artifact.digest === "not_applicable") { - assert.ok( - artifact.digestNotApplicableReason, - `artifact ${artifact.serviceId} must explain not_applicable digest` - ); - } else { - assert.match(artifact.digest, /^sha256:[a-f0-9]{64}$/, `artifact ${artifact.serviceId} digest`); - } - } - - return artifacts; -} - -function assertHealth(plan, checklist, artifacts) { - const healthById = byKey(plan.health, "healthId"); - const healthByComponent = byKey(plan.health, "component"); - const healthByService = new Map(); - for (const health of plan.health) { - if (!healthByService.has(health.serviceId)) { - healthByService.set(health.serviceId, health); - } - } - - for (const contract of checklist.healthContracts) { - const health = healthByComponent.get(contract.component); - assert.ok(health, `health missing component ${contract.component}`); - assert.equal(health.serviceId, contract.serviceId, `health ${contract.component} serviceId`); - assert.equal(artifacts.has(health.serviceId), true, `health ${contract.component} missing artifact`); - assert.equal(health.status, "healthy", `health ${contract.component} status`); - assert.equal(health.deployEnv, ENVIRONMENT_DEV, `health ${contract.component} deployEnv`); - assert.equal(health.dryRun, true, `health ${contract.component} dryRun`); - requireTimestamp(`health ${contract.component}`, health.healthTimestamp); - } - - assert.equal(healthByService.get("hwlab-edge-proxy").endpoint, `${DEV_ENDPOINT}/health`); - assert.equal(healthByService.get("hwlab-cloud-web").output.directHardwareCall, false); - assert.equal(healthByService.get("hwlab-gateway").output.patchPanelRequired, true); - assert.equal(healthByService.get("hwlab-box-simu").output.patchPanelOnlyPropagation, true); - assert.equal(healthByService.get("hwlab-agent-mgr").output.workerLeak, false); - - return { byId: healthById, byService: healthByService, count: plan.health.length }; -} - -function assertAcceptanceSteps(plan, checklist) { - const checklistStepIds = new Set(checklist.smokeSteps.map((step) => step.id)); - assert.deepEqual( - checklist.smokeSteps.map((step) => step.order), - checklist.smokeSteps.map((_, index) => index + 1), - "checklist smoke steps must be ordered" - ); - - const referencedStepIds = new Set(); - for (const step of plan.steps) { - for (const acceptanceStepId of step.acceptanceStepIds) { - assert.equal( - checklistStepIds.has(acceptanceStepId), - true, - `${step.id} references unknown acceptance step ${acceptanceStepId}` - ); - referencedStepIds.add(acceptanceStepId); - } - } - - assertArrayIncludesAll("dry-run acceptance coverage", [...referencedStepIds], [ - "static-contract-parse", - "endpoint-freeze", - "dev-ingress-health", - "edge-route", - "frp-tunnel", - "d601-router", - "cloud-surface", - "gateway-sim-patch-panel", - "agent-runtime", - "artifact-observability" - ]); -} - -function assertStepGraph(plan) { - const steps = plan.steps.slice().sort((a, b) => a.order - b.order); - assert.deepEqual( - steps.map((step) => step.id), - requiredStepSequence, - "M5 sequence must cover required dry-run flow" - ); - assert.deepEqual(plan.expectedSequence, requiredStepSequence, "expectedSequence must match required sequence"); - - const seen = new Set(); - for (const step of steps) { - assert.equal(seen.has(step.id), false, `duplicate step ${step.id}`); - assert.ok(Array.isArray(step.inputs) && step.inputs.length > 0, `${step.id} inputs`); - assert.ok(Array.isArray(step.outputs) && step.outputs.length > 0, `${step.id} outputs`); - for (const dependency of step.requires) { - assert.equal(seen.has(dependency), true, `${step.id} requires ${dependency} before it is produced`); - } - seen.add(step.id); - } -} - -function assertProjectGatewayAndBoxes(plan) { - requireDevObject("project", plan.project); - assert.equal(plan.project.status, "active", "project status"); - - const gatewaySessions = byKey(plan.gatewaySessions, "gatewaySessionId"); - assert.equal(gatewaySessions.has("gws_m5-0001"), true, "real gateway boundary session is required"); - assert.equal(gatewaySessions.has("gws_m5-simu-0001"), true, "gateway simulator session is required"); - - for (const session of gatewaySessions.values()) { - assert.equal(session.projectId, plan.project.projectId, `gateway ${session.gatewaySessionId} project`); - assert.ok(["hwlab-gateway", "hwlab-gateway-simu"].includes(session.serviceId), "gateway serviceId"); - assert.equal(session.status, "connected", `gateway ${session.gatewaySessionId} status`); - requireDevObject(`gateway ${session.gatewaySessionId}`, session); - } - - const resources = byKey(plan.boxResources, "resourceId"); - const capabilities = byKey(plan.boxCapabilities, "capabilityId"); - - for (const resource of resources.values()) { - assert.equal(resource.projectId, plan.project.projectId, `resource ${resource.resourceId} project`); - assert.equal(gatewaySessions.has(resource.gatewaySessionId), true, `resource ${resource.resourceId} gateway`); - assert.equal(resource.state, "available", `resource ${resource.resourceId} state`); - requireDevObject(`resource ${resource.resourceId}`, resource); - } - - for (const capability of capabilities.values()) { - assert.equal(capability.projectId, plan.project.projectId, `capability ${capability.capabilityId} project`); - assert.equal(resources.has(capability.resourceId), true, `capability ${capability.capabilityId} resource`); - assert.match(capability.name, /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/); - } - - return { gatewaySessions, resources, capabilities }; -} - -function assertWiring(plan, { gatewaySessions, resources, capabilities }) { - const wiring = plan.wiringConfig; - assert.equal(wiring.projectId, plan.project.projectId, "wiring project"); - assert.equal(gatewaySessions.has(wiring.gatewaySessionId), true, "wiring gateway"); - assert.equal(wiring.status, "active", "wiring status"); - assert.equal(wiring.constraints.patchPanelOwnsRouting, true, "wiring must be patch-panel-owned"); - assert.equal(wiring.constraints.directBoxBypassAllowed, false, "wiring must reject direct box bypass"); - - for (const connection of wiring.connections) { - for (const side of ["from", "to"]) { - const endpoint = connection[side]; - assert.equal(resources.has(endpoint.resourceId), true, `wiring ${side} resource`); - assert.equal(capabilities.has(endpoint.capabilityId), true, `wiring ${side} capability`); - assert.equal( - capabilities.get(endpoint.capabilityId).resourceId, - endpoint.resourceId, - `wiring ${side} capability must belong to resource` - ); - } - } - - const patch = plan.patchPanelStatus; - assert.equal(patch.projectId, plan.project.projectId, "patch project"); - assert.equal(patch.gatewaySessionId, wiring.gatewaySessionId, "patch gateway"); - assert.equal(patch.wiringConfigId, wiring.wiringConfigId, "patch wiring"); - assert.equal(patch.serviceId, "hwlab-patch-panel", "patch service"); - assert.equal(patch.state, "active", "patch state"); - requireDevObject("patch", patch); - requireTimestamp("patch observedAt", patch.observedAt); - - const activeConnectionKeys = new Set( - patch.activeConnections.map( - (connection) => - `${connection.fromResourceId}:${connection.fromPort}->${connection.toResourceId}:${connection.toPort}` - ) - ); - for (const connection of wiring.connections) { - const key = `${connection.from.resourceId}:${connection.from.port}->${connection.to.resourceId}:${connection.to.port}`; - assert.equal(activeConnectionKeys.has(key), true, `patch panel missing active connection ${key}`); - } -} - -function assertRpcAndOperations(plan, { gatewaySessions, resources, capabilities }) { - const operations = byKey(plan.operations, "operationId"); - const rpcByName = byKey(plan.rpc, "name"); - - for (const rpc of rpcByName.values()) { - validateRequest(rpc.request); - validateResponse(rpc.response); - assert.equal(rpc.request.meta.environment, ENVIRONMENT_DEV, `${rpc.name} request env`); - assert.equal(rpc.response.meta.environment, ENVIRONMENT_DEV, `${rpc.name} response env`); - assert.equal(rpc.request.meta.traceId, rpc.response.meta.traceId, `${rpc.name} trace continuity`); - assert.equal(rpc.request.params.dryRun, undefined, `${rpc.name} params must not use top-level dryRun`); - assert.equal(rpc.request.params.input.dryRun, true, `${rpc.name} input dryRun`); - assert.equal(rpc.response.result.operationId, rpc.operationId, `${rpc.name} response operation`); - assert.equal(operations.has(rpc.operationId), true, `${rpc.name} operation exists`); - } - - assert.equal(rpcByName.has("direct-hardware-request"), true, "direct RPC is required"); - assert.equal(rpcByName.has("agent-hardware-request"), true, "agent RPC is required"); - - for (const operation of operations.values()) { - assert.equal(operation.projectId, plan.project.projectId, `operation ${operation.operationId} project`); - assert.equal(gatewaySessions.has(operation.gatewaySessionId), true, `operation ${operation.operationId} gateway`); - assert.equal(resources.has(operation.resourceId), true, `operation ${operation.operationId} resource`); - assert.equal(capabilities.has(operation.capabilityId), true, `operation ${operation.operationId} capability`); - assert.equal(operation.status, "succeeded", `operation ${operation.operationId} status`); - assert.equal(operation.input.dryRun, true, `operation ${operation.operationId} dryRun`); - assert.equal(operation.output.patchPanelStatusId, plan.patchPanelStatus.patchPanelStatusId); - requireDevObject(`operation ${operation.operationId}`, operation); - } - - const agentOperation = operations.get("op_m5-agent-0001"); - assert.equal(agentOperation.agentSessionId, plan.agentSession.agentSessionId, "agent operation session"); - assert.equal(agentOperation.workerSessionId, plan.workerSession.workerSessionId, "agent operation worker"); - - return { operations, rpcByName }; -} - -function assertAgent(plan, { gatewaySessions }) { - const { agentSession, workerSession } = plan; - assert.equal(agentSession.projectId, plan.project.projectId, "agent project"); - assert.equal(agentSession.serviceId, "hwlab-agent-mgr", "agent service"); - assert.equal(agentSession.status, "completed", "agent status"); - assert.equal(agentSession.metadata.workerSessionId, workerSession.workerSessionId, "agent worker link"); - requireDevObject("agent", agentSession); - - assert.equal(workerSession.agentSessionId, agentSession.agentSessionId, "worker agent link"); - assert.equal(workerSession.projectId, plan.project.projectId, "worker project"); - assert.equal(workerSession.serviceId, "hwlab-agent-worker", "worker service"); - assert.equal(workerSession.status, "completed", "worker status"); - assert.equal(gatewaySessions.has(workerSession.gatewaySessionId), true, "worker gateway"); - assert.equal(workerSession.metadata.scopedProjectId, plan.project.projectId, "worker project scope"); - requireDevObject("worker", workerSession); -} - -function assertTraceAuditEvidenceAndCleanup(plan, { operations }) { - const traceEvents = byKey(plan.traceEvents, "traceEventId"); - const auditEvents = byKey(plan.auditEvents, "auditId"); - const evidenceRecords = byKey(plan.evidenceRecords, "evidenceId"); - const cleanupRecords = byKey(plan.cleanup, "cleanupId"); - - for (const trace of traceEvents.values()) { - assert.equal(trace.projectId, plan.project.projectId, `trace ${trace.traceEventId} project`); - requireServiceId(`trace ${trace.traceEventId}`, trace.serviceId); - requireDevObject(`trace ${trace.traceEventId}`, trace); - requireTimestamp(`trace ${trace.traceEventId}`, trace.occurredAt); - if (trace.operationId) { - assert.equal(operations.has(trace.operationId), true, `trace ${trace.traceEventId} operation`); - } - } - - for (const operationId of ["op_m5-direct-0001", "op_m5-agent-0001"]) { - const states = plan.traceEvents - .filter((trace) => trace.operationId === operationId) - .map((trace) => trace.metadata.state); - assert.deepEqual(states, requiredTraceStates, `${operationId} trace lifecycle`); - } - - for (const audit of auditEvents.values()) { - assert.equal(audit.projectId, plan.project.projectId, `audit ${audit.auditId} project`); - requireServiceId(`audit ${audit.auditId}`, audit.serviceId); - requireDevObject(`audit ${audit.auditId}`, audit); - requireTimestamp(`audit ${audit.auditId}`, audit.occurredAt); - assert.ok(audit.outcome, `audit ${audit.auditId} outcome`); - } - - assert.equal( - [...auditEvents.values()].some((audit) => audit.action === "hardware.operation.request"), - true, - "hardware operation audit required" - ); - assert.equal(auditEvents.has("aud_m5-cleanup"), true, "cleanup audit required"); - - for (const evidence of evidenceRecords.values()) { - assert.equal(evidence.projectId, plan.project.projectId, `evidence ${evidence.evidenceId} project`); - assert.equal(operations.has(evidence.operationId), true, `evidence ${evidence.evidenceId} operation`); - requireServiceId(`evidence ${evidence.evidenceId}`, evidence.serviceId); - requireDevObject(`evidence ${evidence.evidenceId}`, evidence); - requireTimestamp(`evidence ${evidence.evidenceId}`, evidence.createdAt); - } - - for (const operationId of operations.keys()) { - assert.equal( - [...evidenceRecords.values()].some((evidence) => evidence.operationId === operationId), - true, - `evidence missing for ${operationId}` - ); - } - - for (const cleanup of cleanupRecords.values()) { - assert.equal(cleanup.status, "completed", `cleanup ${cleanup.cleanupId} status`); - assert.equal(cleanup.environment, ENVIRONMENT_DEV, `cleanup ${cleanup.cleanupId} environment`); - requireServiceId(`cleanup ${cleanup.cleanupId}`, cleanup.serviceId); - requireTimestamp(`cleanup ${cleanup.cleanupId}`, cleanup.completedAt); - } - - assert.equal(cleanupRecords.get("cln_m5-worker").outputs.workspaceReleased, true, "worker cleanup release"); - assert.equal(cleanupRecords.get("cln_m5-gateway-state").outputs.transientStateCleared, true); - - return { traceEvents, auditEvents, evidenceRecords, cleanupRecords }; -} - -async function assertEvidenceFiles(evidenceRecords) { - for (const evidence of evidenceRecords.values()) { - const absolutePath = path.join(repoRoot, evidence.uri); - const content = await readFile(absolutePath); - const digest = crypto.createHash("sha256").update(content).digest("hex"); - const stats = await stat(absolutePath); - assert.equal(digest, evidence.sha256, `evidence ${evidence.evidenceId} sha256`); - assert.equal(stats.size, evidence.sizeBytes, `evidence ${evidence.evidenceId} sizeBytes`); - } -} - -function assertStepInputsOutputs(plan, indexes) { - const resolvers = { - health: (id) => indexes.healthIds.has(id), - gatewaySession: (id) => indexes.gatewaySessions.has(id), - boxResource: (id) => indexes.resources.has(id), - resource: (id) => indexes.resources.has(id), - capability: (id) => indexes.capabilities.has(id), - wiringConfig: (id) => id === plan.wiringConfig.wiringConfigId, - patchPanelStatus: (id) => id === plan.patchPanelStatus.patchPanelStatusId, - rpc: (id) => indexes.rpcByName.has(id), - operation: (id) => indexes.operations.has(id), - audit: (id) => indexes.auditEvents.has(id), - agentSession: (id) => id === plan.agentSession.agentSessionId, - workerSession: (id) => id === plan.workerSession.workerSessionId, - trace: (id) => indexes.traceIds.has(id), - evidence: (id) => indexes.evidenceRecords.has(id), - cleanup: (id) => indexes.cleanupRecords.has(id) - }; - - for (const step of plan.steps) { - for (const input of step.inputs) { - if (input === "endpoint") { - continue; - } - const [type, id] = input.split(":"); - assert.ok(type && id, `${step.id} input ${input} must be typed`); - assert.ok(resolvers[type], `${step.id} input ${input} has unknown type ${type}`); - assert.equal(resolvers[type](id), true, `${step.id} input ${input} does not resolve`); - } - } - - const allOutputs = new Set([ - "route_m5-dev", - "tun_m5-d601", - "hwlab-dev", - "hwlab-cloud-api", - "hwlab-cloud-web", - ...indexes.gatewaySessions.keys(), - ...indexes.resources.keys(), - ...indexes.capabilities.keys(), - plan.wiringConfig.wiringConfigId, - plan.patchPanelStatus.patchPanelStatusId, - ...indexes.operations.keys(), - ...indexes.traceIds, - ...indexes.auditEvents.keys(), - plan.agentSession.agentSessionId, - plan.workerSession.workerSessionId, - ...indexes.evidenceRecords.keys(), - ...indexes.cleanupRecords.keys() - ]); - - for (const step of plan.steps) { - for (const output of step.outputs) { - assert.equal(allOutputs.has(output), true, `${step.id} output ${output} does not resolve`); - } - } -} - -function assertDependencies(plan) { - assertArrayIncludesAll( - "M0-M4 dependencies", - plan.m0ToM4Dependencies.map((dependency) => dependency.id), - ["m0-contract-audit", "m1-local-smoke", "m2-cloud-core", "m3-hardware-loop", "m4-agent-runtime"] - ); - - const dependencyStepIds = new Set(plan.steps.map((step) => step.id)); - for (const dependency of plan.m0ToM4Dependencies) { - assert.ok(dependency.provides.length > 0, `${dependency.id} provides`); - assert.equal(dependency.status, "contract_only", `${dependency.id} status`); - for (const stepId of dependency.requiredFor) { - assert.equal(dependencyStepIds.has(stepId), true, `${dependency.id} references unknown ${stepId}`); - } - } - - assert.equal(plan.realDevGate.command, "hwlab-cli test e2e --env dev --mvp"); - assert.equal(plan.realDevGate.requiresAllBlockersClear, true); - assert.equal(plan.realDevGate.requiresDryRunPass, true); - assert.equal(plan.realDevGate.requiresHumanApprovalForRealDev, true); -} - -const [plan, checklist, runtime, contract, matrix, commonSchema] = await Promise.all([ - readJSON(planPath), - readJSON(checklistPath), - readJSON(runtimePath), - readFile(path.join(repoRoot, contractPath), "utf8"), - readFile(path.join(repoRoot, matrixPath), "utf8"), - readJSON(commonSchemaPath) -]); - -serviceIds = commonSchema.$defs.serviceId.enum; -serviceIdSet = new Set(serviceIds); - -assert.equal(matrix.includes("HWLAB MVP E2E"), true, "MVP E2E reference exists"); -assertObjectHasOnlyDryRunSafety(plan); -assertEndpointFreeze(plan, checklist, runtime, contract); -const artifacts = assertArtifacts(plan, checklist); -const health = assertHealth(plan, checklist, artifacts); -assertAcceptanceSteps(plan, checklist); -assertStepGraph(plan); -const topology = assertProjectGatewayAndBoxes(plan); -assertWiring(plan, topology); -assertAgent(plan, topology); -const rpcAndOperations = assertRpcAndOperations(plan, topology); -const evidenceIndexes = assertTraceAuditEvidenceAndCleanup(plan, rpcAndOperations); -await assertEvidenceFiles(evidenceIndexes.evidenceRecords); -assertStepInputsOutputs(plan, { - ...topology, - ...rpcAndOperations, - ...evidenceIndexes, - healthIds: new Set(health.byId.keys()), - traceIds: new Set(plan.traceEvents.map((trace) => trace.traceId)) -}); -assertDependencies(plan); - -console.log( - [ - `M5 MVP E2E dry-run passed: ${plan.steps.length} steps`, - `${artifacts.size} artifacts`, - `${health.count} health contracts`, - `${rpcAndOperations.operations.size} hardware operations`, - `${evidenceIndexes.evidenceRecords.size} evidence records`, - "0 network calls" - ].join(", ") -); diff --git a/scripts/refresh-artifact-catalog.mjs b/scripts/refresh-artifact-catalog.mjs index 4f2122d4..6f654d1a 100644 --- a/scripts/refresh-artifact-catalog.mjs +++ b/scripts/refresh-artifact-catalog.mjs @@ -34,7 +34,6 @@ const v02RuntimeServiceIds = Object.freeze([ "hwlab-device-pod", "hwlab-gateway", "hwlab-edge-proxy", - "hwlab-cli", "hwlab-agent-skills" ]); diff --git a/scripts/refresh-artifact-catalog.test.mjs b/scripts/refresh-artifact-catalog.test.mjs index 6a7b610f..9f8e2b29 100644 --- a/scripts/refresh-artifact-catalog.test.mjs +++ b/scripts/refresh-artifact-catalog.test.mjs @@ -18,7 +18,6 @@ const V02_SERVICE_IDS = Object.freeze([ "hwlab-device-pod", "hwlab-gateway", "hwlab-edge-proxy", - "hwlab-cli", "hwlab-agent-skills" ]); diff --git a/scripts/src/check-plan.mjs b/scripts/src/check-plan.mjs index 8fe1f5f8..d217367a 100644 --- a/scripts/src/check-plan.mjs +++ b/scripts/src/check-plan.mjs @@ -134,12 +134,9 @@ export const checkProfiles = Object.freeze({ { id: "check-116-dev-runtime-dev-runtime-hotfix-audit-test", group: "dev-runtime", command: ["node","--check","scripts/src/dev-runtime-hotfix-audit.test.mjs"] }, { id: "check-117-dev-runtime-dev-runtime-hotfix-audit-test", group: "dev-runtime", command: ["node","--test","scripts/src/dev-runtime-hotfix-audit.test.mjs"] }, { id: "check-118-smoke-l2-runtime-contract-smoke", group: "smoke", command: ["node","--check","scripts/l2-runtime-contract-smoke.mjs"] }, - { id: "check-120-smoke-l6-cli-web-smoke", group: "smoke", command: ["node","--check","scripts/l6-cli-web-smoke.mjs"] }, { id: "check-121-tools-hwlab-agent-runtime-cli", group: "tools", command: ["node","--check","skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs"] }, { id: "check-122-smoke-m3-io-skill-client", group: "smoke", command: ["node","--check","skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"] }, - { id: "check-123-tools-hwlab-cli", group: "tools", command: ["node","--check","tools/hwlab-cli/bin/hwlab-cli.mjs"] }, - { id: "check-124-tools-cli", group: "tools", command: ["node","--check","tools/hwlab-cli/lib/cli.mjs"] }, - { id: "check-125-tools-cli-test", group: "tools", command: ["node","--check","tools/hwlab-cli/lib/cli.test.mjs"] }, + { id: "check-123-tools-hwlab-cli-client", group: "tools", command: ["node","scripts/run-bun.mjs","test","tools/hwlab-cli/client.test.ts"] }, { id: "check-126-cloud-web-check", group: "cloud-web", cwd: "web/hwlab-cloud-web", command: ["bun","run","check"] }, { id: "check-127-repo-run-bun", group: "repo", command: ["node","scripts/run-bun.mjs","scripts/validate-contract.mjs"] }, { id: "check-128-dev-runtime-validate-dev-gate-report", group: "dev-runtime", command: ["node","scripts/validate-dev-gate-report.mjs"] }, @@ -154,7 +151,6 @@ export const checkProfiles = Object.freeze({ { 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-141-smoke-l6-cli-web-smoke", group: "smoke", command: ["node","scripts/l6-cli-web-smoke.mjs"] }, { id: "check-142-cloud-web-check", group: "cloud-web", cwd: "web/hwlab-cloud-web", command: ["bun","run","check"] }, { id: "check-143-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","scripts/code-agent-chat-smoke.mjs"] }, { id: "check-144-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","scripts/cloud-api-runtime-smoke.mjs"] }, diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index 9cc9d03c..022f9aca 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -222,14 +222,12 @@ function validateSource(ctx, manifest) { expectEqual(ctx, manifest.profiles?.dev?.endpoint, endpoints.api?.url, "$.profiles.dev.endpoint", "DEV profile endpoint"); const cloudApi = servicesById.get("hwlab-cloud-api"); - const cli = servicesById.get("hwlab-cli"); const cloudWeb = servicesById.get("hwlab-cloud-web"); const edgeProxy = servicesById.get("hwlab-edge-proxy"); expectEqual(ctx, cloudApi?.env?.HWLAB_PUBLIC_ENDPOINT, endpoints.api?.url, "$.services.hwlab-cloud-api.env.HWLAB_PUBLIC_ENDPOINT", "cloud API public endpoint env"); validateCloudApiDbSource(ctx, cloudApi?.env ?? {}); validateCloudApiCodeAgentSource(ctx, cloudApi?.env ?? {}); validateCodeAgentProxyTimeoutSource(ctx, { cloudApi, cloudWeb, edgeProxy }); - expectEqual(ctx, cli?.env?.HWLAB_CLI_ENDPOINT, endpoints.api?.url, "$.services.hwlab-cli.env.HWLAB_CLI_ENDPOINT", "CLI endpoint env"); const proxies = manifest.frp?.proxies ?? []; const proxyNames = new Set(); diff --git a/scripts/src/dev-artifact-services.mjs b/scripts/src/dev-artifact-services.mjs index 0f2a7eca..76799856 100644 --- a/scripts/src/dev-artifact-services.mjs +++ b/scripts/src/dev-artifact-services.mjs @@ -95,17 +95,6 @@ export async function resolveDevArtifactService(repoRoot, serviceId, catalog = n }); } - if (serviceId === "hwlab-cli") { - const hasBin = await pathExists(repoRoot, "tools/hwlab-cli/bin/hwlab-cli.mjs"); - return withServicePublishPolicy({ - serviceId, - runtimeKind: "cli", - entrypoint: hasBin ? "tools/hwlab-cli/bin/hwlab-cli.mjs" : "tools/hwlab-cli/lib/cli.mjs", - implementationState: hasBin ? "repo-entrypoint" : "library-only", - sourceState - }); - } - if (serviceId === "hwlab-agent-skills") { return withServicePublishPolicy({ serviceId, diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 8722402f..b8ba6afc 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -6107,27 +6107,6 @@ function liveBuildsFixturePayload() { source: "unavailable" }, revision: "unknown" - }, - { - serviceId: "hwlab-cli", - name: "hwlab-cli", - kind: "hwlab", - status: "unavailable", - build: { - createdAt: null, - metadataSource: "unavailable", - unavailableReason: "构建时间不可用:hwlab-cli 是 suspended Job template,当前 live desired replicas=0" - }, - image: { - reference: "unknown", - tag: "unknown", - digest: "unknown" - }, - commit: { - id: "unknown", - source: "unavailable" - }, - revision: "unknown" } ] }; diff --git a/scripts/src/dev-edge-health-smoke-lib.mjs b/scripts/src/dev-edge-health-smoke-lib.mjs index 85a49a89..5b6f18b7 100644 --- a/scripts/src/dev-edge-health-smoke-lib.mjs +++ b/scripts/src/dev-edge-health-smoke-lib.mjs @@ -194,7 +194,7 @@ async function createDevGateReport(edgeHealth) { dryRun: { status: "not_run", commands: [ - "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run" + "node scripts/run-bun.mjs tools/hwlab-cli/bin/hwlab-cli.ts client workbench summary --base-url http://74.48.78.17:19666" ], evidence: [ "This edge-health task used the dedicated read-only smoke instead of the MVP e2e dry-run." diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index 2f36acc9..d91571b8 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -70,7 +70,6 @@ const serviceSpecificPaths = Object.freeze({ "hwlab-device-pod": ["cmd/hwlab-device-pod/", "internal/device-pod/"], "hwlab-gateway": ["cmd/hwlab-gateway/"], "hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"], - "hwlab-cli": ["tools/hwlab-cli/"], "hwlab-agent-skills": ["skills/"] }); @@ -480,7 +479,6 @@ function environmentDigestFromCatalog(catalogRecord) { function runtimeKindForService(serviceId) { if (bunCommandServices.has(serviceId)) return "bun-command"; if (serviceId === "hwlab-cloud-web") return "cloud-web"; - if (serviceId === "hwlab-cli") return "cli"; if (serviceId === "hwlab-agent-skills") return "skills-bundle"; return "node-command"; } @@ -488,7 +486,6 @@ function runtimeKindForService(serviceId) { function entrypointForService(serviceId) { if (bunCommandServices.has(serviceId)) return `cmd/${serviceId}/main.ts`; if (serviceId === "hwlab-cloud-web") return "web/hwlab-cloud-web/index.html"; - if (serviceId === "hwlab-cli") return "tools/hwlab-cli/bin/hwlab-cli.mjs"; if (serviceId === "hwlab-agent-skills") return "skills/hwlab-agent-runtime/SKILL.md"; return `cmd/${serviceId}/main.mjs`; } diff --git a/scripts/src/rpt004-mvp-e2e-harness.test.mjs b/scripts/src/rpt004-mvp-e2e-harness.test.mjs index da6246f7..cfb265b5 100644 --- a/scripts/src/rpt004-mvp-e2e-harness.test.mjs +++ b/scripts/src/rpt004-mvp-e2e-harness.test.mjs @@ -552,7 +552,6 @@ function fixtureArtifactJson() { "hwlab-router", "hwlab-tunnel-client", "hwlab-edge-proxy", - "hwlab-cli", "hwlab-agent-skills" ]; const deploy = { diff --git a/scripts/validate-runtime-boundary.mjs b/scripts/validate-runtime-boundary.mjs index ed97780c..e6d613a5 100644 --- a/scripts/validate-runtime-boundary.mjs +++ b/scripts/validate-runtime-boundary.mjs @@ -156,7 +156,7 @@ function assertHwlabImage(image, serviceId, label) { } function assertHealthProbe(container, serviceId, label) { - if (serviceId === "hwlab-agent-worker" || serviceId === "hwlab-cli") { + if (serviceId === "hwlab-agent-worker") { return; } diff --git a/tools/hwlab-cli/bin/hwlab-cli.mjs b/tools/hwlab-cli/bin/hwlab-cli.mjs old mode 100755 new mode 100644 index cb0ce648..dee34d3e --- a/tools/hwlab-cli/bin/hwlab-cli.mjs +++ b/tools/hwlab-cli/bin/hwlab-cli.mjs @@ -1,10 +1,24 @@ #!/usr/bin/env node -import { runCli } from "../lib/cli.mjs"; +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; -const exitCode = await runCli(process.argv.slice(2), { - cwd: process.cwd(), - stdout: process.stdout, - stderr: process.stderr +const binDir = path.dirname(fileURLToPath(import.meta.url)); +const tsEntrypoint = path.join(binDir, "hwlab-cli.ts"); +const repoRoot = path.resolve(binDir, "../../.."); +const runBun = path.join(repoRoot, "scripts/run-bun.mjs"); +const command = process.env.HWLAB_BUN_COMMAND || process.execPath; +const args = process.env.HWLAB_BUN_COMMAND + ? [tsEntrypoint, ...process.argv.slice(2)] + : [runBun, tsEntrypoint, ...process.argv.slice(2)]; +const child = spawn(command, args, { stdio: "inherit", env: process.env, cwd: repoRoot }); + +child.on("error", (error) => { + process.stderr.write(JSON.stringify({ ok: false, cli: "hwlab-cli", status: "failed", error: { code: "bun_spawn_failed", message: error.message } }, null, 2) + "\n"); + process.exit(127); +}); +child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 0); }); - -process.exitCode = exitCode; diff --git a/tools/hwlab-cli/bin/hwlab-cli.ts b/tools/hwlab-cli/bin/hwlab-cli.ts new file mode 100644 index 00000000..b9aa0833 --- /dev/null +++ b/tools/hwlab-cli/bin/hwlab-cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env bun +import { main } from "../../src/hwlab-cli-lib.ts"; + +await main(); diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts new file mode 100644 index 00000000..64776556 --- /dev/null +++ b/tools/hwlab-cli/client.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "bun:test"; + +import { runHwlabCli } from "../src/hwlab-cli-lib.ts"; + +test("hwlab-cli client login uses Cloud Web auth and stores only cookie session", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-")); + const calls: any[] = []; + const result = await runHwlabCli([ + "client", + "auth", + "login", + "--base-url", + "http://web.test", + "--username", + "admin", + "--password-env", + "HWLAB_PASSWORD" + ], { + cwd, + env: { HWLAB_PASSWORD: "secret-password" }, + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) }); + return new Response(JSON.stringify({ authenticated: true, user: { id: "usr_admin", username: "admin", role: "admin" }, expiresAt: "2026-05-30T12:00:00.000Z" }), { + status: 200, + headers: { "content-type": "application/json", "set-cookie": "hwlab_session=session-a; Path=/; HttpOnly" } + }); + }, + now: () => "2026-05-30T00:00:00.000Z" + }); + + assert.equal(result.exitCode, 0); + assert.equal(result.payload.action, "client.auth.login"); + assert.equal(result.payload.cookieStored, true); + assert.equal(calls[0].url, "http://web.test/auth/login"); + assert.deepEqual(calls[0].body, { username: "admin", password: "secret-password" }); + assert.equal(JSON.stringify(result.payload).includes("secret-password"), false); + + const session = JSON.parse(await readFile(path.join(cwd, ".state/hwlab-cli/session.json"), "utf8")); + assert.equal(session.baseUrl, "http://web.test"); + assert.equal(session.cookie, "hwlab_session=session-a"); + assert.equal(JSON.stringify(session).includes("secret-password"), false); +}); + +test("hwlab-cli client device-pods uses saved cookie and same Web API paths", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-")); + await runHwlabCli(["client", "auth", "login", "--base-url", "http://web.test", "--username", "admin", "--password", "pw"], { + cwd, + fetchImpl: async () => new Response(JSON.stringify({ authenticated: true, user: { username: "admin" } }), { status: 200, headers: { "set-cookie": "hwlab_session=session-a; Path=/" } }) + }); + const seen: any[] = []; + const result = await runHwlabCli(["client", "device-pods", "status", "device-pod-71-freq", "--base-url", "http://web.test"], { + cwd, + fetchImpl: async (url, init) => { + seen.push({ url: String(url), headers: init?.headers }); + return new Response(JSON.stringify({ ok: true, devicePodId: "device-pod-71-freq", contractVersion: "device-pod-authority-v1", profileHash: "sha256:abc" }), { status: 200 }); + } + }); + + assert.equal(result.exitCode, 0); + 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"); +}); + +test("hwlab-cli client agent send submits async and polls result", async () => { + const calls: any[] = []; + const result = await runHwlabCli([ + "client", + "agent", + "send", + "--base-url", + "http://web.test", + "--cookie", + "hwlab_session=session-a", + "--message", + "hello", + "--trace-id", + "trc_test", + "--conversation-id", + "cnv_test", + "--poll-interval-ms", + "1", + "--timeout-ms", + "1000" + ], { + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null }); + 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 }); + }, + sleep: async () => {} + }); + + assert.equal(result.exitCode, 0); + assert.equal(calls[0].url, "http://web.test/v1/agent/chat"); + assert.equal(calls[0].init.headers.prefer, "respond-async"); + assert.equal(calls[0].body.shortConnection, true); + 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"); +}); + +test("hwlab-cli client workbench summary probes Cloud Web non-visual surfaces", async () => { + const seen: string[] = []; + const result = await runHwlabCli(["client", "workbench", "summary", "--base-url", "http://web.test", "--cookie", "session-a", "--pod-id", "device-pod-71-freq"], { + fetchImpl: async (url) => { + seen.push(String(url)); + return new Response(JSON.stringify({ ok: true, status: "ok" }), { status: 200 }); + } + }); + + assert.equal(result.exitCode, 0); + assert.equal(seen.includes("http://web.test/health/live"), true); + assert.equal(seen.includes("http://web.test/v1"), true); + assert.equal(seen.includes("http://web.test/v1/device-pods"), true); + assert.equal(seen.includes("http://web.test/v1/device-pods/device-pod-71-freq/status"), true); + assert.equal(result.payload.serviceRuntime, undefined); +}); diff --git a/tools/hwlab-cli/lib/cli.mjs b/tools/hwlab-cli/lib/cli.mjs deleted file mode 100644 index bb883db7..00000000 --- a/tools/hwlab-cli/lib/cli.mjs +++ /dev/null @@ -1,312 +0,0 @@ -import { DEV_ENDPOINT, loadMvpGateSummary } from "../../../internal/mvp-gate/summary.mjs"; -import { runM3IoSkillCommand } from "../../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; - -function formatJson(value) { - return JSON.stringify(value, null, 2); -} - -function writeLine(stream, line = "") { - stream.write(`${line}\n`); -} - -function writeJson(stream, value) { - writeLine(stream, formatJson(value)); -} - -function parseArgs(args) { - const flags = new Set(); - const options = new Map(); - const rest = []; - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg.startsWith("--")) { - const next = args[index + 1]; - if (next && !next.startsWith("--")) { - options.set(arg, next); - index += 1; - } else { - flags.add(arg); - } - } else { - rest.push(arg); - } - } - return { flags, options, rest }; -} - -function makeError(code, message, details = {}) { - return { - ok: false, - code, - message, - ...details - }; -} - -function mvpGateOverview(summary) { - return { - ok: true, - command: "hwlab-cli health", - environment: summary.environment, - endpoint: summary.endpoint, - gateStatus: summary.gateStatus, - devOnly: summary.devOnly, - prodDisabled: summary.prodDisabled, - reportCommitId: summary.reportCommitId, - artifactCount: summary.artifactCount, - healthCount: summary.healthCount, - milestoneStatus: Object.fromEntries(summary.milestones.map((item) => [item.id, item.status])), - blocked: summary.blocked, - blockers: summary.blockers - }; -} - -function mvpProjectList(summary) { - return { - ok: true, - command: "hwlab-cli project list", - environment: summary.environment, - endpoint: summary.endpoint, - projects: [ - { - projectId: summary.m5DryRunTopology.projectId, - name: "HWLAB M5 MVP E2E Dry Run", - status: summary.m5DryRunTopology.projectStatus, - environment: summary.environment, - source: summary.generatedFrom.m5Plan - } - ], - topology: { - gatewaySessions: summary.m5DryRunTopology.gateways.map((gateway) => gateway.gatewaySessionId), - boxResources: summary.m5DryRunTopology.boxResources.map((resource) => resource.resourceId), - patchPanelStatusId: summary.m5DryRunTopology.patchPanel.patchPanelStatusId - } - }; -} - -function mvpDryRunPlan(summary, command) { - return { - ok: true, - command, - mode: "dry-run", - dryRunOnly: true, - environment: summary.environment, - endpoint: summary.endpoint, - gateStatus: summary.gateStatus, - blocked: summary.blocked, - generatedFrom: summary.generatedFrom, - safety: summary.safety, - milestones: summary.milestones, - contractStatus: { - artifacts: { - status: "fixture", - count: summary.artifactCount, - commitIds: [...new Set(summary.artifacts.map((artifact) => artifact.commitId))], - services: summary.artifacts.map((artifact) => artifact.serviceId) - }, - health: { - status: "fixture", - count: summary.healthCount, - components: summary.health.map((health) => ({ - component: health.component, - serviceId: health.serviceId, - status: health.status, - dryRun: health.dryRun - })) - }, - topology: { - projectId: summary.m5DryRunTopology.projectId, - gatewaySessions: summary.gatewaySessionCount, - boxResources: summary.boxResourceCount, - patchPanelStatusId: summary.m5DryRunTopology.patchPanel.patchPanelStatusId, - activeConnectionCount: summary.m5DryRunTopology.patchPanel.activeConnectionCount - }, - agent: summary.agent, - evidence: { - operations: summary.operationCount, - traceEvents: summary.traceCount, - auditEvents: summary.auditCount, - records: summary.evidenceRecords, - cleanupRecords: summary.cleanupCount - } - }, - steps: summary.steps.map((step) => ({ - id: step.id, - order: step.order, - title: step.title, - kind: step.kind, - requires: step.requires - })), - blockers: summary.blockers, - realDevGate: summary.realDevGate, - statement: "dry-run only: no DEV/PROD changes were made" - }; -} - -function mvpLiveBlocked(summary, command) { - return { - ok: false, - code: "BLOCKED", - command, - mode: "live-dev", - environment: summary.environment, - endpoint: summary.endpoint, - gateStatus: summary.gateStatus, - blocked: true, - blockers: summary.blockers, - requiredBeforeRetry: [ - "Clear all open blockers in the owning GitHub issues and PR comments.", - "Record live HWLAB DEV health for /health and /live on the frozen DEV endpoint.", - "Keep the run DEV-only; do not use UniDesk runtime or PROD substitutes." - ], - generatedFrom: summary.generatedFrom - }; -} - -function writeHelp(stdout) { - writeJson(stdout, { - ok: true, - command: "hwlab-cli", - commands: [ - "health", - "project list", - "m3 status --api-base-url URL", - "m3 io --action do.write --value true --approved --api-base-url URL", - "m3 io --action di.read --api-base-url URL", - "G14 CI/CD is managed by native Tekton + GitOps; hwlab-cli cicd is removed", - "test e2e --env dev --mvp", - "test e2e --env dev --mvp --dry-run", - "test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production" - ], - defaults: { - testE2eMvp: "dry-run plan", - output: "json", - devEndpoint: DEV_ENDPOINT - } - }); -} - -export async function runCli(argv, io) { - const stdout = io.stdout ?? process.stdout; - const stderr = io.stderr ?? process.stderr; - const repoRoot = io.cwd ?? process.cwd(); - const env = io.env ?? process.env; - - const [command = "help", subcommand, ...rest] = argv; - const { flags, options } = parseArgs(rest); - - if (command === "cicd") { - writeJson(stderr, makeError("legacy-cicd-removed", "hwlab-cli cicd was removed from the active G14 release path. Use G14 k3s Tekton/GitOps via tran G14:k3s and scripts/g14-gitops-render.mjs.", { - subcommand: subcommand ?? null, - replacement: { - observePipelineRuns: "tran G14:k3s kubectl get pipelineruns -n hwlab-ci", - observeArgo: "tran G14:k3s kubectl -n argocd get application hwlab-g14-dev hwlab-g14-prod", - renderSource: "node scripts/g14-gitops-render.mjs --check" - } - })); - return 2; - } - - if (command === "m3" && ["io", "status"].includes(subcommand)) { - const result = await runM3IoSkillCommand(["m3", subcommand, ...rest], { - env, - now: io.now, - requestJson: io.requestJson - }); - writeJson(result.ok ? stdout : stderr, { - ...result, - command: `hwlab-cli ${["m3", subcommand, ...redactM3CliArgs(rest)].join(" ")}` - }); - return result.ok ? 0 : 2; - } - - let summary; - try { - summary = loadMvpGateSummary(repoRoot); - } catch (error) { - writeJson(stderr, makeError("GATE_SUMMARY_UNREADABLE", error.message)); - return 1; - } - - if (command === "health") { - writeJson(stdout, mvpGateOverview(summary)); - return 0; - } - - if (command === "project" && subcommand === "list") { - writeJson(stdout, mvpProjectList(summary)); - return 0; - } - - if (command === "test" && subcommand === "e2e") { - const env = options.get("--env") ?? null; - const isMvp = flags.has("--mvp"); - const isLive = flags.has("--live"); - const isDryRun = flags.has("--dry-run") || !isLive; - const commandLine = `hwlab-cli test e2e --env ${env ?? ""}${isMvp ? " --mvp" : ""}${ - isLive ? " --live" : isDryRun && flags.has("--dry-run") ? " --dry-run" : "" - }`; - - if (env !== "dev") { - writeJson(stderr, makeError("UNSUPPORTED_ENV", "Only --env dev is supported for the MVP gate.", { env })); - return 1; - } - if (!isMvp) { - writeJson(stderr, makeError("MISSING_MVP_FLAG", "Use --mvp for the HWLAB MVP E2E gate.")); - return 1; - } - - if (isLive) { - if (!flags.has("--confirm-dev") || !flags.has("--confirmed-non-production")) { - writeJson( - stderr, - makeError("LIVE_CONFIRMATION_REQUIRED", "Live DEV requires --confirm-dev --confirmed-non-production.", { - requiredFlags: ["--confirm-dev", "--confirmed-non-production"] - }) - ); - return 1; - } - if (summary.blocked) { - writeJson(stderr, mvpLiveBlocked(summary, commandLine)); - return 2; - } - writeJson( - stderr, - makeError("LIVE_DEV_NOT_IMPLEMENTED", "The MVP CLI does not execute live DEV e2e from this runner.") - ); - return 2; - } - - writeJson(stdout, mvpDryRunPlan(summary, commandLine)); - return 0; - } - - if (command === "project" && !subcommand) { - writeJson(stderr, makeError("USAGE", "usage: hwlab-cli project list")); - return 1; - } - - if (command === "test" && !subcommand) { - writeJson(stderr, makeError("USAGE", "usage: hwlab-cli test e2e --env dev --mvp")); - return 1; - } - - writeHelp(stdout); - return 0; -} - -function redactM3CliArgs(args = []) { - const redacted = []; - for (let index = 0; index < args.length; index += 1) { - redacted.push(args[index]); - if (args[index] === "--api-base-url" && index + 1 < args.length) { - redacted.push(""); - index += 1; - } - } - return redacted; -} - -export function formatRuntime(runtime) { - return formatJson(runtime); -} diff --git a/tools/hwlab-cli/lib/cli.test.mjs b/tools/hwlab-cli/lib/cli.test.mjs deleted file mode 100644 index 0bed6773..00000000 --- a/tools/hwlab-cli/lib/cli.test.mjs +++ /dev/null @@ -1,117 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - HWLAB_M3_IO_API_ROUTE, - HWLAB_M3_STATUS_API_ROUTE -} from "../../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; -import { runCli } from "./cli.mjs"; - -async function captureCli(args, options = {}) { - let stdout = ""; - let stderr = ""; - const exitCode = await runCli(args, { - cwd: options.cwd ?? process.cwd(), - env: options.env ?? { - PATH: process.env.PATH - }, - requestJson: options.requestJson, - spawnDetached: options.spawnDetached, - stdout: { - write(chunk) { - stdout += chunk; - } - }, - stderr: { - write(chunk) { - stderr += chunk; - } - } - }); - return { exitCode, stdout, stderr }; -} - -test("repo hwlab-cli cicd is removed from the active G14 release path", async () => { - const result = await captureCli(["cicd", "submit", "--kind", "ci-publish"]); - assert.equal(result.exitCode, 2); - const body = JSON.parse(result.stderr); - assert.equal(body.ok, false); - assert.equal(body.code, "legacy-cicd-removed"); - assert.match(body.message, /G14 release path/u); - assert.match(body.replacement.observePipelineRuns, /tran G14:k3s/u); -}); - -test("repo hwlab-cli m3 status uses Skill CLI and calls only HWLAB API /v1/m3/status", async () => { - const calls = []; - const result = await captureCli( - [ - "m3", - "status", - "--api-base-url", - "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - "--trace-id", - "trc_hwlab_cli_status" - ], - { - requestJson: async (url, request) => { - calls.push({ url, request }); - assert.equal(url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_STATUS_API_ROUTE}`); - assert.equal(request.method, "GET"); - return { - ok: true, - status: 200, - body: { - status: "live", - sourceKind: "DEV-LIVE", - traceId: "trc_hwlab_cli_status", - boxes: [{ - id: "boxsimu_2", - resourceId: "res_boxsimu_2", - online: true, - ports: { - DI1: { - value: false - } - } - }] - } - }; - } - } - ); - - assert.equal(result.exitCode, 0, result.stderr); - const body = JSON.parse(result.stdout); - assert.equal(body.route, HWLAB_M3_STATUS_API_ROUTE); - assert.equal(body.method, "GET"); - assert.equal(body.readback.value, false); - assert.equal(body.command.includes("hwlab-cloud-api.hwlab-dev"), false); - assert.equal(calls.length, 1); -}); - -test("repo hwlab-cli m3 io blocks direct hardware targets before request", async () => { - const result = await captureCli( - [ - "m3", - "io", - "--action", - "di.read", - "--api-base-url", - "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301", - "--trace-id", - "trc_hwlab_cli_direct_blocked" - ], - { - requestJson: async () => { - throw new Error("direct hardware target must not be called"); - } - } - ); - - assert.equal(result.exitCode, 2); - const body = JSON.parse(result.stderr); - assert.equal(body.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(body.blocker.code, "direct_hardware_target_blocked"); - assert.equal(body.hwlabApi.redactedUrl, null); - assert.equal(JSON.stringify(body).includes("patch-panel.hwlab-dev"), false); -}); diff --git a/tools/hwlab-cli/package.json b/tools/hwlab-cli/package.json index 29e2f8f5..294976a9 100644 --- a/tools/hwlab-cli/package.json +++ b/tools/hwlab-cli/package.json @@ -4,10 +4,10 @@ "private": true, "type": "module", "bin": { - "hwlab-cli": "./bin/hwlab-cli.mjs" + "hwlab-cli": "./bin/hwlab-cli.ts" }, "scripts": { - "check": "node --check bin/hwlab-cli.mjs && node --check lib/cli.mjs && node --test lib/cli.test.mjs && node ../../scripts/l6-cli-web-smoke.mjs", - "health": "node bin/hwlab-cli.mjs health" + "check": "node ../../scripts/run-bun.mjs test ../../tools/hwlab-cli/client.test.ts", + "client": "node ../../scripts/run-bun.mjs bin/hwlab-cli.ts client" } } diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts index dcbb21cf..22e8c87e 100644 --- a/tools/src/device-pod-cli-lib.ts +++ b/tools/src/device-pod-cli-lib.ts @@ -50,7 +50,7 @@ function help() { profileAuthority: "hwlab-cloud-api", localProfileAuthority: false, configuration: { - apiBaseUrl: "--api-base-url or HWLAB_DEVICE_POD_API_URL/HWLAB_CLOUD_API_URL/HWLAB_CLI_ENDPOINT", + apiBaseUrl: "--api-base-url or HWLAB_DEVICE_POD_API_URL/HWLAB_CLOUD_API_URL", auth: "--session-token, --cookie, --bearer-token, or matching HWLAB_* env" }, usage: [ @@ -288,7 +288,7 @@ function passthroughOptions(parsed: ParsedArgs, keys: string[]) { const MUTATING_INTENTS = new Set(["workspace.apply-patch", "workspace.build", "debug.download", "debug.reset", "io.uart.read-after-launch-flash", "io.uart.write"]); function defaultOperation(surface: string) { return surface === "workspace" ? "ls" : surface === "debug-probe" ? "status" : "read"; } -function apiBaseUrl(parsed: ParsedArgs, env: EnvLike) { const value = text(parsed.apiBaseUrl ?? parsed.apiUrl ?? env.HWLAB_DEVICE_POD_API_URL ?? env.HWLAB_CLOUD_API_URL ?? env.HWLAB_CLI_ENDPOINT); if (!value) throw cliError("api_base_url_required", "device-pod-cli requires --api-base-url or HWLAB_DEVICE_POD_API_URL/HWLAB_CLOUD_API_URL/HWLAB_CLI_ENDPOINT"); return value.replace(/\/+$/u, ""); } +function apiBaseUrl(parsed: ParsedArgs, env: EnvLike) { const value = text(parsed.apiBaseUrl ?? parsed.apiUrl ?? env.HWLAB_DEVICE_POD_API_URL ?? env.HWLAB_CLOUD_API_URL); if (!value) throw cliError("api_base_url_required", "device-pod-cli requires --api-base-url or HWLAB_DEVICE_POD_API_URL/HWLAB_CLOUD_API_URL"); return value.replace(/\/+$/u, ""); } function authHeaders(parsed: ParsedArgs, env: EnvLike) { const cookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE); const sessionToken = text(parsed.sessionToken ?? env.HWLAB_DEVICE_POD_SESSION_TOKEN ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN); const bearer = text(parsed.bearerToken ?? env.HWLAB_BEARER_TOKEN); return clean({ ...(cookie ? { cookie: cookie.includes("=") ? cookie : `hwlab_session=${encodeURIComponent(cookie)}` } : {}), ...(sessionToken ? { "x-hwlab-session-token": sessionToken } : {}), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }); } function responsePayload(action: string, response: any, extra: Record = {}) { const success = response.status >= 200 && response.status < 300 && response.body?.ok !== false; return { ok: success, action, status: success ? "succeeded" : "failed", httpStatus: response.status, ...extra, body: response.body }; } function withMeta(payload: any, now: () => string) { return { generatedAt: now(), cli: CLI_NAME, version: VERSION, ...payload }; } diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts new file mode 100644 index 00000000..ec157e39 --- /dev/null +++ b/tools/src/hwlab-cli-lib.ts @@ -0,0 +1,452 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const VERSION = "0.2.0-client"; +const CLI_NAME = "hwlab-cli"; +const DEFAULT_BASE_URL = "http://74.48.78.17:19666"; +const DEFAULT_TIMEOUT_MS = 30000; +const DEFAULT_AGENT_TIMEOUT_MS = 120000; +const DEFAULT_POLL_INTERVAL_MS = 1000; + +type EnvLike = Record; +type ParsedArgs = Record & { _: string[] }; +type FetchLike = typeof fetch; + +type CliOptions = { + env?: EnvLike; + fetchImpl?: FetchLike; + stdinText?: string; + cwd?: string; + now?: () => string; + sleep?: (ms: number) => Promise; +}; + +export async function main(argv = process.argv.slice(2), options: CliOptions = {}) { + const result = await runHwlabCli(argv, options); + console.log(JSON.stringify(result.payload, null, 2)); + process.exitCode = result.exitCode; +} + +export async function runHwlabCli(argv: string[], options: CliOptions = {}) { + const env = options.env ?? process.env; + const now = options.now ?? (() => new Date().toISOString()); + try { + const parsed = parseOptions(argv); + const target = parsed._[0] || "help"; + const context = { + parsed, + env, + fetchImpl: options.fetchImpl ?? fetch, + stdinText: options.stdinText, + cwd: options.cwd ?? process.cwd(), + now, + sleep: options.sleep ?? wait + }; + const payload = target === "client" ? await clientCommand({ ...context, rest: parsed._.slice(1) }) : help(); + return { exitCode: payload.ok === false ? 1 : 0, payload: withMeta(payload, now) }; + } catch (error) { + return { exitCode: 1, payload: withMeta(failure("hwlab-cli", error), now) }; + } +} + +async function clientCommand(context: any) { + const group = context.rest[0] || "help"; + const next = { ...context, rest: context.rest.slice(1) }; + 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 === "agent") return agentCommand(next); + if (group === "workbench") return workbenchCommand(next); + throw cliError("unsupported_client_command", `unsupported client command: ${group}`, { group }); +} + +function help() { + return ok("help", { + version: VERSION, + contractVersion: "hwlab-web-equivalent-client-v1", + mode: "short-connection-client", + defaultBaseUrl: DEFAULT_BASE_URL, + stateFile: ".state/hwlab-cli/session.json", + serviceRuntime: false, + imagePublished: false, + jobTemplate: false, + usage: [ + "hwlab-cli client auth login --base-url URL --username USER --password-env HWLAB_PASSWORD", + "hwlab-cli client auth session", + "hwlab-cli client device-pods list", + "hwlab-cli client device-pods status device-pod-71-freq", + "hwlab-cli client workbench summary --pod-id device-pod-71-freq", + "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" + ] + }); +} + +async function authCommand(context: any) { + const subcommand = context.rest[0] || "session"; + if (subcommand === "login") return authLogin(context); + if (subcommand === "session") { + const response = await requestJson({ ...context, method: "GET", path: "/auth/session" }); + return responsePayload("client.auth.session", response, context, { route: route("GET", "/auth/session") }); + } + if (subcommand === "logout") { + const response = await requestJson({ ...context, method: "POST", path: "/auth/logout" }); + await clearSession(context); + return responsePayload("client.auth.logout", response, context, { route: route("POST", "/auth/logout"), localSessionCleared: true }); + } + throw cliError("unsupported_auth_command", `unsupported auth command: ${subcommand}`, { subcommand }); +} + +async function authLogin(context: any) { + const { parsed, env } = context; + const username = text(parsed.username ?? env.HWLAB_USERNAME) || "admin"; + const password = await passwordValue(context); + const response = await requestJson({ ...context, method: "POST", path: "/auth/login", body: { username, password }, auth: false }); + const cookie = cookieFromResponse(response); + const success = isHttpSuccess(response) && response.body?.authenticated === true; + if (success && cookie) { + await saveSession(context, { + baseUrl: baseUrl(parsed, env), + cookie, + user: safeUser(response.body?.user ?? response.body?.actor), + expiresAt: textOrNull(response.body?.expiresAt), + updatedAt: context.now() + }); + } + return responsePayload("client.auth.login", response, context, { + route: route("POST", "/auth/login"), + username, + cookieStored: Boolean(success && cookie), + body: authBodySummary(response.body) + }); +} + +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") }); + } + 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 }); + } + 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 }); + } + if (subcommand === "probe") return devicePodProbe(context, podId); + throw cliError("unsupported_device_pods_command", `unsupported device-pods command: ${subcommand}`, { subcommand }); +} + +async function devicePodProbe(context: any, podId: string) { + const encoded = encodeURIComponent(podId); + const paths = [ + `/v1/device-pods/${encoded}/debug-probe/chip-id`, + `/v1/device-pods/${encoded}/io-probe/uart/1`, + `/v1/device-pods/${encoded}/io-probe/uart/1/tail?maxBytes=${encodeURIComponent(String(numberOption(context.parsed.maxBytes) ?? 12000))}` + ]; + const probes = await Promise.all(paths.map(async (pathName) => { + try { + const response = await requestJson({ ...context, method: "GET", path: pathName }); + return compactProbe("GET", pathName, response); + } catch (error) { + return { route: route("GET", pathName), ok: false, error: errorSummary(error) }; + } + })); + const failed = probes.some((probe) => probe.ok === false); + return ok("client.device-pods.probe", { status: failed ? "degraded" : "succeeded", devicePodId: podId, probes }, failed ? "degraded" : "succeeded"); +} + +async function agentCommand(context: any) { + const subcommand = context.rest[0] || "send"; + if (subcommand === "send") return agentSend(context); + if (subcommand === "trace") { + 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 }); + } + if (subcommand === "cancel") { + const traceId = requiredText(context.rest[1] ?? context.parsed.traceId, "traceId"); + const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/chat/cancel", body: clean({ traceId, conversationId: text(context.parsed.conversationId), sessionId: text(context.parsed.sessionId) }), extraHeaders: { "x-trace-id": traceId } }); + return responsePayload("client.agent.cancel", response, context, { route: route("POST", "/v1/agent/chat/cancel"), traceId }); + } + throw cliError("unsupported_agent_command", `unsupported agent command: ${subcommand}`, { subcommand }); +} + +async function agentSend(context: any) { + const { parsed } = context; + const message = text(parsed.message ?? parsed.text) || text(context.stdinText); + if (!message) throw cliError("message_required", "client agent send requires --message or stdin text"); + const traceId = text(parsed.traceId) || makeId("trc"); + const conversationId = text(parsed.conversationId) || makeId("cnv"); + const requestBody = clean({ + message, + conversationId, + sessionId: text(parsed.sessionId), + threadId: text(parsed.threadId), + traceId, + providerProfile: text(parsed.providerProfile) || "deepseek", + gatewayShellTimeoutMs: numberOption(parsed.gatewayShellTimeoutMs), + shortConnection: true, + projectId: text(parsed.projectId) || "prj_device_pod_workbench" + }); + const accepted = await requestJson({ + ...context, + method: "POST", + path: "/v1/agent/chat", + body: requestBody, + timeoutMs: numberOption(parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS, + extraHeaders: { + "x-trace-id": traceId, + "prefer": "respond-async", + "x-hwlab-short-connection": "1" + } + }); + if (!isHttpSuccess(accepted)) { + return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId }); + } + if (parsed.noWait === true) { + return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, waited: false }); + } + const result = await pollAgentResult(context, traceId, accepted.body); + return ok("client.agent.send", { + status: result.final ? "succeeded" : "timeout", + route: route("POST", "/v1/agent/chat"), + traceId, + conversationId, + accepted: compactBody(accepted.body), + result: result.response ? compactResponse(result.response) : null, + polls: result.polls, + timeoutMs: result.timeoutMs, + resultUrl: result.resultPath + }, result.final ? "succeeded" : "timeout"); +} + +async function pollAgentResult(context: any, traceId: string, acceptedBody: any) { + const timeoutMs = numberOption(context.parsed.timeoutMs) ?? DEFAULT_AGENT_TIMEOUT_MS; + const pollIntervalMs = numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS; + const startedAt = Date.now(); + const resultPath = text(acceptedBody?.resultUrl) || `/v1/agent/chat/result/${encodeURIComponent(traceId)}`; + let polls = 0; + let lastResponse = null; + while (Date.now() - startedAt < timeoutMs) { + polls += 1; + const response = await requestJson({ ...context, method: "GET", path: resultPath, timeoutMs: Math.min(DEFAULT_TIMEOUT_MS, pollIntervalMs + 2000) }); + lastResponse = response; + if (response.status === 200 && response.body?.status && response.body.status !== "running") { + return { final: true, response, polls, timeoutMs, resultPath }; + } + await context.sleep(pollIntervalMs); + } + return { final: false, response: lastResponse, polls, timeoutMs, resultPath }; +} + +async function workbenchCommand(context: any) { + const subcommand = context.rest[0] || "summary"; + if (subcommand !== "summary") throw cliError("unsupported_workbench_command", `unsupported workbench command: ${subcommand}`, { subcommand }); + const podId = text(context.parsed.podId) || "device-pod-71-freq"; + const encodedPodId = encodeURIComponent(podId); + const paths = [ + "/health/live", + "/v1", + "/v1/live-builds", + "/v1/device-pods", + `/v1/device-pods/${encodedPodId}/status`, + `/v1/device-pods/${encodedPodId}/events?limit=${encodeURIComponent(String(numberOption(context.parsed.limit) ?? 120))}`, + `/v1/device-pods/${encodedPodId}/debug-probe/chip-id`, + `/v1/device-pods/${encodedPodId}/io-probe/uart/1`, + `/v1/device-pods/${encodedPodId}/io-probe/uart/1/tail?maxBytes=${encodeURIComponent(String(numberOption(context.parsed.maxBytes) ?? 12000))}` + ]; + const probes = await Promise.all(paths.map(async (pathName) => { + try { + const response = await requestJson({ ...context, method: "GET", path: pathName, auth: !["/health/live", "/v1", "/v1/live-builds"].includes(pathName) }); + return compactProbe("GET", pathName, response); + } catch (error) { + return { route: route("GET", pathName), ok: false, error: errorSummary(error) }; + } + })); + const failed = probes.some((probe) => probe.ok === false); + return ok("client.workbench.summary", { status: failed ? "degraded" : "succeeded", baseUrl: baseUrl(context.parsed, context.env), devicePodId: podId, probes }, failed ? "degraded" : "succeeded"); +} + +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(); + const timer = setTimeout(() => controller.abort(), timeoutMs ?? numberOption(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS); + try { + const cookie = auth ? await authCookie({ parsed, env, cwd: cwd ?? process.cwd() }) : null; + const headers = clean({ + accept: "application/json", + ...(body ? { "content-type": "application/json" } : {}), + ...(cookie ? { cookie } : {}), + ...extraHeaders + }); + const response = await fetchImpl(url, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal }); + const textBody = await response.text(); + return { status: response.status, headers: response.headers, body: parseJson(textBody), url, method, path: pathName }; + } finally { + clearTimeout(timer); + } +} + +function parseOptions(argv: string[]): ParsedArgs { + const out: ParsedArgs = { _: [] }; + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index] ?? ""; + if (!item.startsWith("--")) { out._.push(item); continue; } + const eq = item.indexOf("="); + const rawKey = eq >= 0 ? item.slice(2, eq) : item.slice(2); + const key = rawKey.replace(/-([a-z])/gu, (_, c) => String(c).toUpperCase()); + if (eq >= 0) { out[key] = item.slice(eq + 1); continue; } + const next = argv[index + 1]; + if (next && !next.startsWith("--")) { out[key] = next; index += 1; } + else out[key] = true; + } + return out; +} + +async function passwordValue({ parsed, env, stdinText }: any) { + if (typeof parsed.passwordEnv === "string") return requiredText(env[parsed.passwordEnv], parsed.passwordEnv); + if (parsed.passwordStdin === true) return stdinText !== undefined ? stdinText.trimEnd() : await readStdin(); + return requiredText(parsed.password ?? env.HWLAB_PASSWORD, "password"); +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8").trimEnd(); +} + +function baseUrl(parsed: ParsedArgs, env: EnvLike) { + return text(parsed.baseUrl ?? env.HWLAB_CLIENT_BASE_URL ?? env.HWLAB_CLOUD_WEB_URL ?? env.HWLAB_CLI_BASE_URL) || DEFAULT_BASE_URL; +} + +async function authCookie({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) { + const explicit = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE); + if (explicit) return explicit.includes("=") ? explicit : `hwlab_session=${encodeURIComponent(explicit)}`; + if (parsed.noSession === true) return null; + const session = await loadSession({ parsed, env, cwd }); + return session?.cookie ?? null; +} + +async function loadSession({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) { + try { + const raw = await readFile(stateFile(parsed, cwd), "utf8"); + const session = JSON.parse(raw); + if (session?.baseUrl !== baseUrl(parsed, env)) return null; + if (!text(session.cookie)) return null; + return session; + } catch { + return null; + } +} + +async function saveSession(context: any, session: Record) { + const file = stateFile(context.parsed, context.cwd); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, `${JSON.stringify(session, null, 2)}\n`, "utf8"); +} + +async function clearSession(context: any) { + await rm(stateFile(context.parsed, context.cwd), { force: true }); +} + +function stateFile(parsed: ParsedArgs, cwd: string) { + return path.resolve(cwd, text(parsed.stateFile) || ".state/hwlab-cli/session.json"); +} + +function cookieFromResponse(response: any) { + const values = typeof response.headers?.getSetCookie === "function" + ? response.headers.getSetCookie() + : [response.headers?.get?.("set-cookie")].filter(Boolean); + const raw = values.find((value: string) => /(?:^|,)\s*hwlab_session=/u.test(value)) ?? values[0]; + if (!raw) return null; + const match = String(raw).match(/hwlab_session=[^;,\s]+/u); + return match?.[0] ?? String(raw).split(";", 1)[0]; +} + +function responsePayload(action: string, response: any, context: any, extra: Record = {}) { + const success = isHttpSuccess(response) && response.body?.ok !== false; + return { + ok: success, + action, + status: success ? "succeeded" : "failed", + baseUrl: baseUrl(context.parsed, context.env), + httpStatus: response.status, + route: extra.route ?? route(response.method, response.path), + ...extra, + body: extra.body ?? response.body + }; +} + +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) }; +} + +function compactResponse(response: any) { + return { httpStatus: response.status, route: route(response.method, response.path), body: compactBody(response.body) }; +} + +function compactBody(body: any) { + if (!body || typeof body !== "object") return body; + return { + ok: body.ok, + status: body.status, + authenticated: body.authenticated, + actor: body.actor, + user: body.user, + contractVersion: body.contractVersion, + serviceId: body.serviceId, + ready: body.ready, + selectedDevicePodId: body.selectedDevicePodId, + devicePodCount: Array.isArray(body.devicePods) ? body.devicePods.length : undefined, + traceId: body.traceId, + conversationId: body.conversationId, + sessionId: body.sessionId, + threadId: body.threadId, + accepted: body.accepted, + shortConnection: body.shortConnection, + resultUrl: body.resultUrl, + traceUrl: body.traceUrl, + error: body.error, + blocker: body.blocker, + summary: body.summary, + freshness: body.freshness, + profileHash: body.profileHash, + targetId: body.targetId, + source: body.source + }; +} + +function authBodySummary(body: any) { + if (!body || typeof body !== "object") return body; + return { authenticated: body.authenticated, user: safeUser(body.user ?? body.actor), expiresAt: textOrNull(body.expiresAt), error: body.error }; +} + +function safeUser(value: any) { + if (!value || typeof value !== "object") return null; + return clean({ id: text(value.id), username: text(value.username), role: text(value.role), displayName: text(value.displayName) }); +} + +function withMeta(payload: any, now: () => string) { return { generatedAt: now(), cli: CLI_NAME, version: VERSION, ...payload }; } +function ok(action: string, data: Record = {}, status = "succeeded") { return { ok: status !== "timeout", action, status, ...data }; } +function failure(action: string, error: any) { return { ok: false, action, status: "failed", error: errorSummary(error), ...(error?.details ? { details: error.details } : {}) }; } +function errorSummary(error: any) { return { code: error?.code ?? "hwlab_cli_error", message: error?.message ?? String(error), ...(error?.details ? { details: error.details } : {}) }; } +function cliError(code: string, message: string, details: Record = {}) { return Object.assign(new Error(message), { code, details }); } +function route(method: string, pathName: string) { return { method, path: pathName }; } +function isHttpSuccess(response: any) { return response.status >= 200 && response.status < 300; } +function text(value: unknown) { return String(value ?? "").trim(); } +function textOrNull(value: unknown) { const result = text(value); return result || null; } +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 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)); }