fix: make hwlab cli a short web client

This commit is contained in:
Codex
2026-05-30 12:31:07 +08:00
parent 6edce687ca
commit 395216122a
45 changed files with 767 additions and 3903 deletions
+9 -3
View File
@@ -2,6 +2,12 @@
HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥官和 runner 进入本仓库时的顶级索引,只放入口、一句话规则和长期参考链接;细则统一维护在 `docs/reference/` HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥官和 runner 进入本仓库时的顶级索引,只放入口、一句话规则和长期参考链接;细则统一维护在 `docs/reference/`
## P0 最高优先级:最新要求与旧门禁清除
- P0: 用户最新明确要求优先于旧测试、旧门禁、旧预检、旧断言、旧 guard 和旧 gate;开发过程中遇到任何阻碍最新目标的旧测试、旧门禁、旧预检或旧断言,一律拆除,禁止继续维护、修补、叠例外或用兼容路径绕行。
- P0: 短连接 CLI、临时工具、文档和轻量 helper 不套用常驻服务、镜像、Job、GitOps、PR、CI/CD 或重型发布流程的旧门禁;如果旧流程要求与最新架构定位冲突,以最新架构定位为准并删除旧流程入口。
- P0: 任何测试、预检或自检只允许表达当前最新目标行为;旧历史断言不得作为回归保护保留,避免把旧路线固化成长期摩擦。
## P0 G14 原生 k8s/GitOps 运行面归一 ## 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)。 - 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 服务规格总览、保留服务清单、稳定外部服务边界和废弃范围:[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)。 - 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)。 - 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-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-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-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-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-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)。 - 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` - G14 GitOps 渲染:`npm run g14:gitops:render`;source 分支不再运行生成物 drift check,发布态由 Tekton 写入 `G14-gitops`
- DEV 依赖 runtime base 构建:`npm run dev-runtime-base:build` - DEV 依赖 runtime base 构建:`npm run dev-runtime-base:build`
- Legacy D601 DEV CD:旧脚本入口已删除;事故回放只读历史 issue/commit,不恢复旧命令。 - Legacy D601 DEV CD:旧脚本入口已删除;事故回放只读历史 issue/commit,不恢复旧命令。
- CI/CD job 查询:`node tools/hwlab-cli/bin/hwlab-cli.mjs cicd status|logs|report <jobId>` - 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` - runner GitHub 可见性预检:`npm run runner:issue-visibility:preflight`
- D601 k3s 只读观测:legacy 回溯入口,仅在确认需要 D601 事故复盘时使用;当前 G14 运行面观察使用 UniDesk route `G14:k3s` - D601 k3s 只读观测:legacy 回溯入口,仅在确认需要 D601 事故复盘时使用;当前 G14 运行面观察使用 UniDesk route `G14:k3s`
- DEV runtime hotfix 只读审计计划:`npm run dev-runtime:hotfix-audit` - DEV runtime hotfix 只读审计计划:`npm run dev-runtime:hotfix-audit`
+2 -49
View File
@@ -306,41 +306,6 @@
}, },
"reusedFrom": "497d88f5f34075f2ba064cdc10d3a80a236e96e4eab4ca71ae3138a751201315" "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", "serviceId": "hwlab-agent-skills",
"commitId": "278bbe1", "commitId": "278bbe1",
@@ -379,8 +344,8 @@
], ],
"serviceInventory": { "serviceInventory": {
"version": "v2", "version": "v2",
"serviceCount": 9, "serviceCount": 8,
"requiredServiceCount": 9, "requiredServiceCount": 8,
"disabledServiceCount": 0, "disabledServiceCount": 0,
"requiredServiceIds": [ "requiredServiceIds": [
"hwlab-cloud-api", "hwlab-cloud-api",
@@ -390,7 +355,6 @@
"hwlab-device-pod", "hwlab-device-pod",
"hwlab-gateway", "hwlab-gateway",
"hwlab-edge-proxy", "hwlab-edge-proxy",
"hwlab-cli",
"hwlab-agent-skills" "hwlab-agent-skills"
], ],
"disabledServiceIds": [], "disabledServiceIds": [],
@@ -472,17 +436,6 @@
"entrypoint": "cmd/hwlab-edge-proxy/main.ts", "entrypoint": "cmd/hwlab-edge-proxy/main.ts",
"disabledReason": null "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", "serviceId": "hwlab-agent-skills",
"publishEnabled": true, "publishEnabled": true,
-17
View File
@@ -75,13 +75,6 @@
"port": 7001, "port": 7001,
"targetPort": "http" "targetPort": "http"
}, },
{
"serviceId": "hwlab-cli",
"name": "hwlab-cli",
"namespace": "hwlab-dev",
"port": 7501,
"targetPort": "http"
},
{ {
"serviceId": "hwlab-agent-skills", "serviceId": "hwlab-agent-skills",
"name": "hwlab-agent-skills", "name": "hwlab-agent-skills",
@@ -283,16 +276,6 @@
"HWLAB_EDGE_PROXY_TIMEOUT_MS": "1260000" "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", "serviceId": "hwlab-agent-skills",
"namespace": "hwlab-dev", "namespace": "hwlab-dev",
-1
View File
@@ -72,7 +72,6 @@
"hwlab-device-pod", "hwlab-device-pod",
"hwlab-gateway", "hwlab-gateway",
"hwlab-edge-proxy", "hwlab-edge-proxy",
"hwlab-cli",
"hwlab-agent-skills" "hwlab-agent-skills"
] ]
}, },
-26
View File
@@ -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", "apiVersion": "v1",
"kind": "Service", "kind": "Service",
-39
View File
@@ -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", "apiVersion": "apps/v1",
"kind": "Deployment", "kind": "Deployment",
+1 -1
View File
@@ -93,7 +93,7 @@ G14 CI/CD 加速的第一判定标准不是 PipelineRun 总耗时单点变短,
- 并发 fan-outper-service TaskRun 应由 Tekton/k8s scheduler 同时调度,多个 service 的 build/reuse 窗口应接近“最慢单个服务任务耗时”,而不是所有服务串行相加。reuse-only 场景的 fan-out 窗口当前基线约为十几秒;真实组件构建场景仍需按 changed service 单独记录 BuildKit 耗时和 cache hit 情况。 - 并发 fan-outper-service TaskRun 应由 Tekton/k8s scheduler 同时调度,多个 service 的 build/reuse 窗口应接近“最慢单个服务任务耗时”,而不是所有服务串行相加。reuse-only 场景的 fan-out 窗口当前基线约为十几秒;真实组件构建场景仍需按 changed service 单独记录 BuildKit 耗时和 cache hit 情况。
- CD rolloutunchanged service 必须复用 `deploy/artifact-catalog.dev.json` 的 image/digestpod template 不应因全局 source commit 改变而无意义滚动。手写 manifest(例如 `deepseek-proxy`、device-agent 类辅助 workload)只要复用某个已发布服务镜像,也必须走同一个 catalog image 选择逻辑,不能直接用当前 source commit tag。 - CD rolloutunchanged service 必须复用 `deploy/artifact-catalog.dev.json` 的 image/digestpod 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` 分支误判成运行面已滚动。 - 运行态验证: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 清单为准。
当前仍然慢的主要位置: 当前仍然慢的主要位置:
@@ -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。 | | `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/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-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/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/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`。 | | `docs/schema-drift-map.md` | 人写摘要归档到 #532;机器 source of truth 仍是 `protocol/schema-drift-map.json`。 |
+40 -26
View File
@@ -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 计划 - 提供 WEB 等价的非视觉业务入口:登录鉴权、session 恢复、Device Pod 看板、Code Agent 对话、trace/result 轮询、logout 和工作台 live summary
- 明确删除 legacy `hwlab-cli cicd` 路径,CI/CD 只能走 G14 Tekton/GitOps - 只走 Cloud Web 同源 API surface;默认 base URL 是 `http://74.48.78.17:19666`,也可通过 `--base-url``HWLAB_CLIENT_BASE_URL` 指向其他 Cloud Web 入口
- `hwlab-cloud-api` 交互时必须通过 `HWLAB_CLI_ENDPOINT` 指向 `19667` 或内部 API base,不直连 gateway/patch/simu 服务 - 不直连 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/bin/hwlab-cli.ts` 是唯一可执行入口,使用 Bun 运行
- `tools/hwlab-cli/lib/cli.mjs` 负责参数解析、JSON 输出、legacy cicd 拒绝、M3 Skill CLI 转发和 MVP e2e dry-run plan - `tools/src/hwlab-cli-lib.ts` 承载参数解析、cookie jar、HTTP 请求、轮询和 JSON 输出
- `hwlab-cli-template``suspend: true` 的 Kubernetes Job template,运行时通过 env 注入 endpoint、commit 和 image metadata - `tools/hwlab-cli/bin/hwlab-cli.mjs` 只允许作为兼容 shim 调用 Bun TS 入口;新功能不得继续写入 `.mjs` 旧库
- 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/运维入口 - CLI session cookie 默认写入 `.state/hwlab-cli/session.json`;状态只保存 base URL、cookie、actor 摘要和过期时间,不保存 password 或 Secret 原文
- 所有 `client` 子命令都是短连接;长耗时 Code Agent 只提交 async request 并短轮询 result/trace,单次命令必须有 timeout 和中间状态输出。
## API 接口说明 ## API 接口说明
| CLI 接口 | 说明 | | CLI 接口 | WEB 等价 API | 说明 |
| --- | --- | | --- | --- | --- |
| `hwlab-cli health` | 输出 MVP gate 摘要和 blocker。 | | `hwlab-cli client auth login` | `POST /auth/login` | 使用账号密码登录 Cloud Web,同步保存 cookie。 |
| `hwlab-cli project list` | 输出项目/topology 摘要。 | | `hwlab-cli client auth session` | `GET /auth/session` | 恢复当前 actor/session。 |
| `hwlab-cli m3 status --api-base-url URL` | 通过 Skill CLI 调用 HWLAB API `/v1/m3/status`。 | | `hwlab-cli client auth logout` | `POST /auth/logout` | 撤销 server session 并清理本地 cookie。 |
| `hwlab-cli m3 io --action ... --api-base-url URL` | 通过 Skill CLI 调用 `/v1/m3/io`;写操作必须显式 approval。 | | `hwlab-cli client device-pods list` | `GET /v1/device-pods` | 对应右侧 Device Pod 列表。 |
| `hwlab-cli test e2e --env dev --mvp --dry-run` | 输出 MVP e2e dry-run plan。 | | `hwlab-cli client device-pods status POD` | `GET /v1/device-pods/{pod}/status` | 对应 Device Pod summary/status。 |
| `hwlab-cli cicd ...` | 必须返回 `legacy-cicd-removed`。 | | `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 ## 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 ## 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。 | | 固定 repo 短连接 client | 目标状态 | `hwlab-cli` 只在 `G14:/root/hwlab-v02` 直接运行。 |
| M3 Skill CLI 转发 | 已实现 | 使用 repo-owned Skill CLI 调 HWLAB API。 | | WEB 等价 API client | 目标状态 | `client` 子命令覆盖 Cloud Web 非视觉业务面。 |
| legacy cicd 删除 | 已实现 | `cicd` 子命令返回明确错误。 | | 本地 cookie session | 目标状态 | `.state/hwlab-cli/session.json` 只保存 cookie/session 摘要。 |
| v02 Job template | 已实现 | `hwlab-cli-template` 存在且 `suspend: true`。 | | 镜像/Service/Job template | 已废弃 | 相关 deploy、GitOps、artifact 和 Tekton 口径必须删除。 |
| live e2e 执行 | 未完全实现 | 当前 live path 在 blocker 或未实现时拒绝执行。 | | PR/CI/CD/worktree 流程 | 已废弃 | CLI 变更不走常驻服务发布流程。 |
| Device Pod REST CLI | 已实现 | `device-pod-cli` 已迁到 Bun+TS 默认 REST authority;旧本地 profile/gateway RPC 路径不再作为正式默认入口。 |
+5 -6
View File
@@ -2,7 +2,7 @@
本文是 HWLAB `v0.2` 微服务总体规格和服务规格索引。它定义组件取舍、依赖方向、语言迁移边界、裁撤口径和单服务 spec 入口;单个服务的 API、测试规格和实现状态以对应 `docs/reference/spec-*.md` 为准。 本文是 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)。 - `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)。 - 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)。 - 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 第一阶段先不改造它。 - `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 是稳定外部依赖。 - 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 裁撤;不再为这些裁撤对象保留单独规格文档。 - `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) | | 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) | | 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) | | 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) | | 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) | | 公网 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) | | 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-gateway-simu` | HWLAB 自研模拟服务 | 裁撤 | 否 | 本文即裁撤权威,不保留单独 spec |
| `hwlab-box-simu` | HWLAB 自研模拟服务 | 裁撤 | 否 | 本文即裁撤权威,不保留单独 spec | | `hwlab-box-simu` | HWLAB 自研模拟服务 | 裁撤 | 否 | 本文即裁撤权威,不保留单独 spec |
| `hwlab-patch-panel` | 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) | | `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) | | 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) | | 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 ```text
hwlab-gateway hwlab-gateway
hwlab-cli
device-pod-cli device-pod-cli
scripts and tools scripts and tools
stable external services stable external services
@@ -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."
}
File diff suppressed because it is too large Load Diff
@@ -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.
@@ -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.
-85
View File
@@ -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"
]
}
-2
View File
@@ -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-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-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-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" }) "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([ const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([
@@ -769,7 +768,6 @@ function serviceDefaultUrl(serviceId) {
"hwlab-device-pod": 7601, "hwlab-device-pod": 7601,
"hwlab-gateway": 7001, "hwlab-gateway": 7001,
"hwlab-edge-proxy": 6667, "hwlab-edge-proxy": 6667,
"hwlab-cli": 7501,
"hwlab-agent-skills": 7430 "hwlab-agent-skills": 7430
}[serviceId] ?? 8080; }[serviceId] ?? 8080;
return `http://${serviceId}.hwlab-dev.svc.cluster.local:${port}`; return `http://${serviceId}.hwlab-dev.svc.cluster.local:${port}`;
+93 -451
View File
@@ -4,326 +4,96 @@ import path from "node:path";
import { DEV_ENDPOINT } from "../protocol/index.mjs"; import { DEV_ENDPOINT } from "../protocol/index.mjs";
export { DEV_ENDPOINT }; 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({ export const M3_WORKBENCH_TOPOLOGY = Object.freeze({
projectId: "prj_m3_hardware_loop", projectId: "retired_legacy_mvp_fixture",
projectStatus: "blocked", projectStatus: "retired",
gateways: Object.freeze([ gateways: Object.freeze([]),
Object.freeze({ boxResources: Object.freeze([]),
gatewaySessionId: "gws_gwsimu_1", patchPanel: null
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"
})
])
})
}); });
function readJsonFile(repoRoot, relativePath) { function readJsonFile(repoRoot, relativePath) {
const absolutePath = path.resolve(repoRoot, relativePath); const absolutePath = path.resolve(repoRoot, relativePath);
const raw = fs.readFileSync(absolutePath, "utf8"); return JSON.parse(fs.readFileSync(absolutePath, "utf8"));
return JSON.parse(raw);
} }
function defaultMvpGateReport() { function defaultMvpGateReport() {
return { return {
reportVersion: "v1", reportVersion: "v2",
issue: "pikasTech/HWLAB#39", issue: "pikasTech/HWLAB#7",
taskId: "dev-mvp-gate-source-summary", taskId: "v02-live-api-summary",
commitId: "source-summary", acceptanceLevel: "v02_live_api_observation",
acceptanceLevel: "dev_mvp_gate",
devOnly: true, devOnly: true,
prodDisabled: true, prodDisabled: true,
gateStatus: "blocked", gateStatus: "retired",
sourceContract: { sourceContract: {
status: "pass", status: "pass",
documents: [ documents: [
"docs/reference/MVP-e2e-acceptance.md", "docs/reference/spec-v02-hwlab-cli.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-services.md", "docs/reference/spec-v02-services.md",
"protocol/README.md", "docs/reference/spec-device-pod.md",
"protocol/audit.md", "docs/reference/g14-gitops-cicd.md"
"protocol/evidence-chain.md",
"protocol/mvp-e2e-contract.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: [ validationCommands: V02_CLI_LIVE_CHECKS,
"node scripts/repo-reports-guard.mjs",
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
],
localSmoke: { localSmoke: {
status: "pass", status: "pass",
commands: [ commands: ["node scripts/run-bun.mjs test tools/hwlab-cli/client.test.ts"],
"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"
],
evidence: [ evidence: [
"Source contracts remain parseable.", "hwlab-cli stores only cookie session metadata.",
"MVP dry-run fixtures remain local/source only.", "device-pods and workbench commands use Cloud Web API paths, not fixtures."
"Repository report directory is forbidden by repo guard."
], ],
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: { liveChecks: {
status: "pass", status: "manual",
commands: [ commands: V02_CLI_LIVE_CHECKS,
"node tools/hwlab-cli/bin/hwlab-cli.mjs health",
"node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run"
],
evidence: [ evidence: [
"Dry-run uses fixtures and performs no DEV/PROD mutations.", "Unauthenticated requests must return explicit auth state or HTTP auth errors with JSON output.",
"Dry-run evidence is not live acceptance." "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." summary: "Live non-visual workbench checks are performed through hwlab-cli client, not a service, image, Job, or CI 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."
}, },
blockers: [ blockers: [
{ {
type: "runtime_blocker", type: "runtime_observation",
scope: "runtime-durable-adapter", scope: "v02-live-api",
status: "open", status: "manual",
sourceIssue: "pikasTech/HWLAB#164", summary: "Run the current short-connection CLI against Cloud Web when live evidence is needed."
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."
} }
], ],
milestones: [ milestones: [
{ {
id: "M0", id: "v0.2-cli",
status: "pass", status: "active",
commands: ["node scripts/validate-contract.mjs"], commands: V02_CLI_LIVE_CHECKS,
evidence: ["Source contracts are parseable."], evidence: ["WEB-equivalent non-visual APIs are tested through hwlab-cli client."],
summary: "Frozen contracts remain source-ready." summary: "The current validation path is short-connection live API probing."
}, },
{ {
id: "M1", id: "legacy-m5-fixture",
status: "pass", status: "retired",
commands: ["node scripts/m1-contract-smoke.mjs"], commands: [],
evidence: ["Local skeleton smoke remains local-only."], evidence: ["Old hwlab-cli artifact/test fixture is removed from active checks."],
summary: "The local M1 contract smoke passes." summary: "Do not restore the former M5 fixture gate or hwlab-cli image/job path."
},
{
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"
} }
] ]
}; };
} }
function countByServiceId(items) { function clone(value) {
return new Map(items.map((item) => [item.serviceId, item])); return JSON.parse(JSON.stringify(value));
}
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 summarizeMilestone(milestone) { function summarizeMilestone(milestone) {
@@ -345,198 +115,70 @@ function summarizeBlocker(blocker) {
}; };
} }
function summarizeStep(step) { export function buildMvpGateSummary({ report = defaultMvpGateReport() } = {}) {
return { const blockers = Array.isArray(report.blockers) ? report.blockers.map(summarizeBlocker) : [];
id: step.id, const milestones = Array.isArray(report.milestones) ? report.milestones.map(summarizeMilestone) : [];
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";
return { return {
generatedFrom: { generatedFrom: {
gateReport: DEFAULT_GATE_REPORT_SOURCE, gateReport: DEFAULT_GATE_REPORT_SOURCE,
m5Plan: DEFAULT_M5_PLAN_PATH m5Plan: "retired"
}, },
issue: "pikasTech/HWLAB#56", issue: report.issue ?? "pikasTech/HWLAB#7",
supports: ["pikasTech/HWLAB#7", "pikasTech/HWLAB#9"], supports: ["pikasTech/HWLAB#7"],
gateStatus: report.gateStatus, gateStatus: report.gateStatus ?? "retired",
blocked, activeGate: false,
acceptanceLevel: report.acceptanceLevel, legacyFixtureGateRetired: true,
reportCommitId: report.commitId, acceptanceLevel: report.acceptanceLevel ?? "v02_live_api_observation",
dryRunOnly: true, reportCommitId: report.commitId ?? "source-summary",
dryRunOnly: false,
devOnly: report.devOnly === true, devOnly: report.devOnly === true,
prodDisabled: report.prodDisabled === true, prodDisabled: report.prodDisabled === true,
environment: plan.environment, environment: "v02",
endpoint: plan.endpoint, endpoint: "http://74.48.78.17:19666",
namespace: "hwlab-dev", namespace: "hwlab-v02",
sourceSummary: report.sourceContract.summary, sourceSummary: report.sourceContract?.summary ?? "Legacy fixture gate retired.",
localSmoke: report.localSmoke, localSmoke: report.localSmoke ?? null,
dryRun: report.dryRun, liveChecks: report.liveChecks ?? null,
devPreconditions: report.devPreconditions,
blockers, blockers,
milestones, 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), topology: clone(M3_WORKBENCH_TOPOLOGY),
m5DryRunTopology: { serviceModel: {
projectId: plan.project.projectId, "hwlab-cli": {
projectStatus: plan.project.status, runtimeService: false,
gateways: plan.gatewaySessions.map(summarizeGatewaySession), image: false,
boxResources: plan.boxResources.map(summarizeBoxResource), kubernetesJob: false,
patchPanel: { gitopsDesiredState: false,
patchPanelStatusId: plan.patchPanelStatus.patchPanelStatusId, invocation: "short-connection client from G14:/root/hwlab-v02"
serviceId: plan.patchPanelStatus.serviceId,
state: plan.patchPanelStatus.state,
environment: plan.patchPanelStatus.environment,
activeConnectionCount: plan.patchPanelStatus.activeConnections.length,
activeConnections: plan.patchPanelStatus.activeConnections
} }
}, },
agent: { validationCommands: report.validationCommands ?? V02_CLI_LIVE_CHECKS,
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,
realDevGate: { realDevGate: {
sourcePlanCommand: plan.realDevGate.command, sourcePlanCommand: "retired",
command: `${plan.realDevGate.command} --live --confirm-dev --confirmed-non-production`, command: "use hwlab-cli client live commands from docs/reference/spec-v02-hwlab-cli.md",
requiresAllBlockersClear: plan.realDevGate.requiresAllBlockersClear, requiresAllBlockersClear: false,
requiresDryRunPass: plan.realDevGate.requiresDryRunPass, requiresDryRunPass: false,
requiresArtifactObservability: plan.realDevGate.requiresArtifactObservability, requiresArtifactObservability: false,
requiresD601RouteObservation: plan.realDevGate.requiresD601RouteObservation, requiresD601RouteObservation: false,
requiresHumanApprovalForRealDev: plan.realDevGate.requiresHumanApprovalForRealDev requiresHumanApprovalForRealDev: false
}, },
safety: { safety: {
mode: plan.mode, mode: "short-connection-live-api",
allowNetwork: plan.safety.allowNetwork, allowNetwork: true,
allowDeploy: plan.safety.allowDeploy, allowDeploy: false,
allowBrowserE2E: plan.safety.allowBrowserE2E, allowBrowserE2E: false,
allowSecrets: plan.safety.allowSecrets, allowSecrets: false,
prohibitedActions: plan.safety.prohibitedActions prohibitedActions: ["create hwlab-cli image", "create hwlab-cli service", "create hwlab-cli job", "restore legacy M5 fixture gate"]
} }
}; };
} }
export function loadMvpGateSummary(repoRoot, options = {}) { export function loadMvpGateSummary(repoRoot, options = {}) {
const reportPath = options.reportPath ?? null; const reportPath = options.reportPath ?? null;
const planPath = options.planPath ?? DEFAULT_M5_PLAN_PATH;
const report = reportPath ? readJsonFile(repoRoot, reportPath) : defaultMvpGateReport(); const report = reportPath ? readJsonFile(repoRoot, reportPath) : defaultMvpGateReport();
const plan = readJsonFile(repoRoot, planPath); const summary = buildMvpGateSummary({ report });
const summary = buildMvpGateSummary({ report, plan });
summary.generatedFrom = { summary.generatedFrom = {
gateReport: reportPath ?? DEFAULT_GATE_REPORT_SOURCE, gateReport: reportPath ?? DEFAULT_GATE_REPORT_SOURCE,
m5Plan: planPath m5Plan: "retired"
}; };
return summary; return summary;
} }
-1
View File
@@ -13,7 +13,6 @@ export const SERVICE_IDS = Object.freeze([
"hwlab-device-pod", "hwlab-device-pod",
"hwlab-gateway", "hwlab-gateway",
"hwlab-edge-proxy", "hwlab-edge-proxy",
"hwlab-cli",
"hwlab-agent-skills" "hwlab-agent-skills"
]); ]);
+1 -5
View File
@@ -22,15 +22,11 @@
"deploy:contract:check": "node scripts/run-bun.mjs scripts/deploy-contract-plan.mjs --check", "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: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", "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:client": "node scripts/run-bun.mjs tools/hwlab-cli/bin/hwlab-cli.ts client",
"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",
"web:check": "(cd web/hwlab-cloud-web && bun run check) && bun test web/hwlab-cloud-web/message-markdown.test.ts", "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)", "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", "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", "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", "docs:validate:m3-rollout": "node scripts/validate-m3-rollout-runbook.mjs",
"d601:k3s:readonly": "node scripts/d601-k3s-readonly-observability.mjs", "d601:k3s:readonly": "node scripts/d601-k3s-readonly-observability.mjs",
"runner:issue-visibility:preflight": "node scripts/runner-issue-visibility-preflight.mjs", "runner:issue-visibility:preflight": "node scripts/runner-issue-visibility-preflight.mjs",
+2 -16
View File
@@ -66,7 +66,6 @@ const servicePorts = new Map([
["hwlab-device-pod", 7601], ["hwlab-device-pod", 7601],
["hwlab-gateway", 7001], ["hwlab-gateway", 7001],
["hwlab-edge-proxy", 6667], ["hwlab-edge-proxy", 6667],
["hwlab-cli", 7420],
["hwlab-agent-skills", 7430] ["hwlab-agent-skills", 7430]
]); ]);
@@ -78,7 +77,6 @@ const v02RuntimeServiceIds = Object.freeze([
"hwlab-device-pod", "hwlab-device-pod",
"hwlab-gateway", "hwlab-gateway",
"hwlab-edge-proxy", "hwlab-edge-proxy",
"hwlab-cli",
"hwlab-agent-skills" "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; return blockers;
@@ -1917,11 +1905,9 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
}, },
dryRun: { dryRun: {
status: "not_run", status: "not_run",
commands: [ commands: [],
"node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run"
],
evidence: [ 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." summary: "DEV deploy dry-run is not implied by artifact publication."
}, },
+1 -1
View File
@@ -52,7 +52,7 @@ function rpcBody(id, method, params = {}) {
meta: { meta: {
traceId: `trc_${id}`, traceId: `trc_${id}`,
actorId: "usr_cloud_api_runtime_smoke", actorId: "usr_cloud_api_runtime_smoke",
serviceId: "hwlab-cli", serviceId: "hwlab-cloud-api",
environment: ENVIRONMENT_DEV environment: ENVIRONMENT_DEV
} }
}); });
+2 -2
View File
@@ -483,7 +483,7 @@ async function runLivePreflight(fixture, dryRun) {
params: {}, params: {},
meta: { meta: {
traceId: "trc_dev_m4_health", traceId: "trc_dev_m4_health",
serviceId: "hwlab-cli", serviceId: "hwlab-cloud-api",
environment: ENVIRONMENT_DEV environment: ENVIRONMENT_DEV
} }
}) })
@@ -519,7 +519,7 @@ async function runLivePreflight(fixture, dryRun) {
}, },
meta: { meta: {
traceId: "trc_dev_m4_hw", traceId: "trc_dev_m4_hw",
serviceId: "hwlab-cli", serviceId: "hwlab-cloud-api",
environment: ENVIRONMENT_DEV environment: ENVIRONMENT_DEV
} }
}) })
-5
View File
@@ -38,7 +38,6 @@ const defaultServiceIds = Object.freeze([
"hwlab-device-pod", "hwlab-device-pod",
"hwlab-gateway", "hwlab-gateway",
"hwlab-edge-proxy", "hwlab-edge-proxy",
"hwlab-cli",
"hwlab-agent-skills" "hwlab-agent-skills"
]); ]);
const v02RuntimeServiceIds = Object.freeze([ const v02RuntimeServiceIds = Object.freeze([
@@ -49,7 +48,6 @@ const v02RuntimeServiceIds = Object.freeze([
"hwlab-device-pod", "hwlab-device-pod",
"hwlab-gateway", "hwlab-gateway",
"hwlab-edge-proxy", "hwlab-edge-proxy",
"hwlab-cli",
"hwlab-agent-skills" "hwlab-agent-skills"
]); ]);
const v02RemovedServiceIds = new Set([ 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_API_BASE_URL", `http://hwlab-cloud-api.${namespace}.svc.cluster.local:6667`);
upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", webEndpoint); upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", webEndpoint);
} }
if (serviceId === "hwlab-cli") {
upsertEnv(container.env, "HWLAB_CLI_ENDPOINT", runtimeEndpoint);
}
} }
rewritePodSecretRefs(podTemplate.spec, profile); rewritePodSecretRefs(podTemplate.spec, profile);
} }
-1
View File
@@ -166,7 +166,6 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots
"hwlab-device-pod", "hwlab-device-pod",
"hwlab-gateway", "hwlab-gateway",
"hwlab-edge-proxy", "hwlab-edge-proxy",
"hwlab-cli",
"hwlab-agent-skills" "hwlab-agent-skills"
]; ];
for (let index = 0; index < retainedServiceIds.length; index += 1) { for (let index = 0; index < retainedServiceIds.length; index += 1) {
-80
View File
@@ -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, "\\$&");
}
+1 -44
View File
@@ -10,7 +10,6 @@ import { fileURLToPath } from "node:url";
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs";
import { validateCodeAgentChatSchema } from "../internal/cloud/code-agent-chat.ts"; 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 repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const children = []; const children = [];
@@ -202,7 +201,7 @@ function rpcEnvelope({ id, method, params = {} }) {
params, params,
meta: { meta: {
traceId: `trc_${id}`, traceId: `trc_${id}`,
serviceId: "hwlab-cli", serviceId: "hwlab-cloud-api",
environment: ENVIRONMENT_DEV environment: ENVIRONMENT_DEV
} }
}; };
@@ -513,47 +512,6 @@ async function smokePatchPanelAndSimulators() {
logOk("patch-panel/sim status and route runtime"); 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() { async function smokeAgentMgrLocalContract() {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-m1-agent-mgr-")); const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-m1-agent-mgr-"));
const agentSessionId = "agt_m1_local_contract"; const agentSessionId = "agt_m1_local_contract";
@@ -670,7 +628,6 @@ try {
await smokeCloudApi(); await smokeCloudApi();
await smokePatchPanelAndSimulators(); await smokePatchPanelAndSimulators();
await smokeAgentMgrLocalContract(); await smokeAgentMgrLocalContract();
await smokeCliDryRunFixture();
process.stdout.write("[m1-smoke] passed\n"); process.stdout.write("[m1-smoke] passed\n");
} finally { } finally {
await Promise.allSettled(children.map(stopNode)); await Promise.allSettled(children.map(stopNode));
-632
View File
@@ -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(", ")
);
-1
View File
@@ -34,7 +34,6 @@ const v02RuntimeServiceIds = Object.freeze([
"hwlab-device-pod", "hwlab-device-pod",
"hwlab-gateway", "hwlab-gateway",
"hwlab-edge-proxy", "hwlab-edge-proxy",
"hwlab-cli",
"hwlab-agent-skills" "hwlab-agent-skills"
]); ]);
@@ -18,7 +18,6 @@ const V02_SERVICE_IDS = Object.freeze([
"hwlab-device-pod", "hwlab-device-pod",
"hwlab-gateway", "hwlab-gateway",
"hwlab-edge-proxy", "hwlab-edge-proxy",
"hwlab-cli",
"hwlab-agent-skills" "hwlab-agent-skills"
]); ]);
+1 -5
View File
@@ -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-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-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-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-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-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-123-tools-hwlab-cli-client", group: "tools", command: ["node","scripts/run-bun.mjs","test","tools/hwlab-cli/client.test.ts"] },
{ 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-126-cloud-web-check", group: "cloud-web", cwd: "web/hwlab-cloud-web", command: ["bun","run","check"] }, { 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-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"] }, { 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-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-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-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-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-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"] }, { id: "check-144-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","scripts/cloud-api-runtime-smoke.mjs"] },
-2
View File
@@ -222,14 +222,12 @@ function validateSource(ctx, manifest) {
expectEqual(ctx, manifest.profiles?.dev?.endpoint, endpoints.api?.url, "$.profiles.dev.endpoint", "DEV profile endpoint"); expectEqual(ctx, manifest.profiles?.dev?.endpoint, endpoints.api?.url, "$.profiles.dev.endpoint", "DEV profile endpoint");
const cloudApi = servicesById.get("hwlab-cloud-api"); const cloudApi = servicesById.get("hwlab-cloud-api");
const cli = servicesById.get("hwlab-cli");
const cloudWeb = servicesById.get("hwlab-cloud-web"); const cloudWeb = servicesById.get("hwlab-cloud-web");
const edgeProxy = servicesById.get("hwlab-edge-proxy"); 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"); 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 ?? {}); validateCloudApiDbSource(ctx, cloudApi?.env ?? {});
validateCloudApiCodeAgentSource(ctx, cloudApi?.env ?? {}); validateCloudApiCodeAgentSource(ctx, cloudApi?.env ?? {});
validateCodeAgentProxyTimeoutSource(ctx, { cloudApi, cloudWeb, edgeProxy }); 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 proxies = manifest.frp?.proxies ?? [];
const proxyNames = new Set(); const proxyNames = new Set();
-11
View File
@@ -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") { if (serviceId === "hwlab-agent-skills") {
return withServicePublishPolicy({ return withServicePublishPolicy({
serviceId, serviceId,
@@ -6107,27 +6107,6 @@ function liveBuildsFixturePayload() {
source: "unavailable" source: "unavailable"
}, },
revision: "unknown" 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"
} }
] ]
}; };
+1 -1
View File
@@ -194,7 +194,7 @@ async function createDevGateReport(edgeHealth) {
dryRun: { dryRun: {
status: "not_run", status: "not_run",
commands: [ 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: [ evidence: [
"This edge-health task used the dedicated read-only smoke instead of the MVP e2e dry-run." "This edge-health task used the dedicated read-only smoke instead of the MVP e2e dry-run."
-3
View File
@@ -70,7 +70,6 @@ const serviceSpecificPaths = Object.freeze({
"hwlab-device-pod": ["cmd/hwlab-device-pod/", "internal/device-pod/"], "hwlab-device-pod": ["cmd/hwlab-device-pod/", "internal/device-pod/"],
"hwlab-gateway": ["cmd/hwlab-gateway/"], "hwlab-gateway": ["cmd/hwlab-gateway/"],
"hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"], "hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"],
"hwlab-cli": ["tools/hwlab-cli/"],
"hwlab-agent-skills": ["skills/"] "hwlab-agent-skills": ["skills/"]
}); });
@@ -480,7 +479,6 @@ function environmentDigestFromCatalog(catalogRecord) {
function runtimeKindForService(serviceId) { function runtimeKindForService(serviceId) {
if (bunCommandServices.has(serviceId)) return "bun-command"; if (bunCommandServices.has(serviceId)) return "bun-command";
if (serviceId === "hwlab-cloud-web") return "cloud-web"; if (serviceId === "hwlab-cloud-web") return "cloud-web";
if (serviceId === "hwlab-cli") return "cli";
if (serviceId === "hwlab-agent-skills") return "skills-bundle"; if (serviceId === "hwlab-agent-skills") return "skills-bundle";
return "node-command"; return "node-command";
} }
@@ -488,7 +486,6 @@ function runtimeKindForService(serviceId) {
function entrypointForService(serviceId) { function entrypointForService(serviceId) {
if (bunCommandServices.has(serviceId)) return `cmd/${serviceId}/main.ts`; if (bunCommandServices.has(serviceId)) return `cmd/${serviceId}/main.ts`;
if (serviceId === "hwlab-cloud-web") return "web/hwlab-cloud-web/index.html"; 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"; if (serviceId === "hwlab-agent-skills") return "skills/hwlab-agent-runtime/SKILL.md";
return `cmd/${serviceId}/main.mjs`; return `cmd/${serviceId}/main.mjs`;
} }
@@ -552,7 +552,6 @@ function fixtureArtifactJson() {
"hwlab-router", "hwlab-router",
"hwlab-tunnel-client", "hwlab-tunnel-client",
"hwlab-edge-proxy", "hwlab-edge-proxy",
"hwlab-cli",
"hwlab-agent-skills" "hwlab-agent-skills"
]; ];
const deploy = { const deploy = {
+1 -1
View File
@@ -156,7 +156,7 @@ function assertHwlabImage(image, serviceId, label) {
} }
function assertHealthProbe(container, serviceId, label) { function assertHealthProbe(container, serviceId, label) {
if (serviceId === "hwlab-agent-worker" || serviceId === "hwlab-cli") { if (serviceId === "hwlab-agent-worker") {
return; return;
} }
+21 -7
View File
@@ -1,10 +1,24 @@
#!/usr/bin/env node #!/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), { const binDir = path.dirname(fileURLToPath(import.meta.url));
cwd: process.cwd(), const tsEntrypoint = path.join(binDir, "hwlab-cli.ts");
stdout: process.stdout, const repoRoot = path.resolve(binDir, "../../..");
stderr: process.stderr 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;
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bun
import { main } from "../../src/hwlab-cli-lib.ts";
await main();
+124
View File
@@ -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);
});
-312
View File
@@ -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 ?? "<missing>"}${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("<hwlab-api-base-url>");
index += 1;
}
}
return redacted;
}
export function formatRuntime(runtime) {
return formatJson(runtime);
}
-117
View File
@@ -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);
});
+3 -3
View File
@@ -4,10 +4,10 @@
"private": true, "private": true,
"type": "module", "type": "module",
"bin": { "bin": {
"hwlab-cli": "./bin/hwlab-cli.mjs" "hwlab-cli": "./bin/hwlab-cli.ts"
}, },
"scripts": { "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", "check": "node ../../scripts/run-bun.mjs test ../../tools/hwlab-cli/client.test.ts",
"health": "node bin/hwlab-cli.mjs health" "client": "node ../../scripts/run-bun.mjs bin/hwlab-cli.ts client"
} }
} }
+2 -2
View File
@@ -50,7 +50,7 @@ function help() {
profileAuthority: "hwlab-cloud-api", profileAuthority: "hwlab-cloud-api",
localProfileAuthority: false, localProfileAuthority: false,
configuration: { 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" auth: "--session-token, --cookie, --bearer-token, or matching HWLAB_* env"
}, },
usage: [ 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"]); 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 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 authHeaders(parsed: ParsedArgs, env: EnvLike) { const cookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE); const sessionToken = text(parsed.sessionToken ?? env.HWLAB_DEVICE_POD_SESSION_TOKEN ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN); const bearer = text(parsed.bearerToken ?? env.HWLAB_BEARER_TOKEN); return clean({ ...(cookie ? { cookie: cookie.includes("=") ? cookie : `hwlab_session=${encodeURIComponent(cookie)}` } : {}), ...(sessionToken ? { "x-hwlab-session-token": sessionToken } : {}), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }); }
function responsePayload(action: string, response: any, extra: Record<string, unknown> = {}) { const success = response.status >= 200 && response.status < 300 && response.body?.ok !== false; return { ok: success, action, status: success ? "succeeded" : "failed", httpStatus: response.status, ...extra, body: response.body }; } function responsePayload(action: string, response: any, extra: Record<string, unknown> = {}) { const success = response.status >= 200 && response.status < 300 && response.body?.ok !== false; return { ok: success, action, status: success ? "succeeded" : "failed", httpStatus: response.status, ...extra, body: response.body }; }
function withMeta(payload: any, now: () => string) { return { generatedAt: now(), cli: CLI_NAME, version: VERSION, ...payload }; } function withMeta(payload: any, now: () => string) { return { generatedAt: now(), cli: CLI_NAME, version: VERSION, ...payload }; }
+452
View File
@@ -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<string, string | undefined>;
type ParsedArgs = Record<string, unknown> & { _: string[] };
type FetchLike = typeof fetch;
type CliOptions = {
env?: EnvLike;
fetchImpl?: FetchLike;
stdinText?: string;
cwd?: string;
now?: () => string;
sleep?: (ms: number) => Promise<void>;
};
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<string, unknown>) {
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<string, unknown> = {}) {
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<string, unknown> = {}, 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<string, unknown> = {}) { 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<T extends Record<string, any>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== false && item !== null)) as T; }
function parseJson(value: string) { if (!value) return null; try { return JSON.parse(value); } catch { return { rawText: value.slice(0, 2000), parseError: true }; } }
function makeId(prefix: string) { return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; }
function wait(ms: number) { return new Promise<void>((resolve) => setTimeout(resolve, ms)); }