fix: make hwlab cli a short web client
This commit is contained in:
@@ -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`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ G14 CI/CD 加速的第一判定标准不是 PipelineRun 总耗时单点变短,
|
|||||||
- 并发 fan-out:per-service TaskRun 应由 Tekton/k8s scheduler 同时调度,多个 service 的 build/reuse 窗口应接近“最慢单个服务任务耗时”,而不是所有服务串行相加。reuse-only 场景的 fan-out 窗口当前基线约为十几秒;真实组件构建场景仍需按 changed service 单独记录 BuildKit 耗时和 cache hit 情况。
|
- 并发 fan-out:per-service TaskRun 应由 Tekton/k8s scheduler 同时调度,多个 service 的 build/reuse 窗口应接近“最慢单个服务任务耗时”,而不是所有服务串行相加。reuse-only 场景的 fan-out 窗口当前基线约为十几秒;真实组件构建场景仍需按 changed service 单独记录 BuildKit 耗时和 cache hit 情况。
|
||||||
- CD rollout:unchanged service 必须复用 `deploy/artifact-catalog.dev.json` 的 image/digest,pod template 不应因全局 source commit 改变而无意义滚动。手写 manifest(例如 `deepseek-proxy`、device-agent 类辅助 workload)只要复用某个已发布服务镜像,也必须走同一个 catalog image 选择逻辑,不能直接用当前 source commit tag。
|
- CD rollout:unchanged service 必须复用 `deploy/artifact-catalog.dev.json` 的 image/digest,pod template 不应因全局 source commit 改变而无意义滚动。手写 manifest(例如 `deepseek-proxy`、device-agent 类辅助 workload)只要复用某个已发布服务镜像,也必须走同一个 catalog image 选择逻辑,不能直接用当前 source commit tag。
|
||||||
- 运行态验证:GitOps promote 成功只说明 `G14-gitops` 分支更新;最终通过必须看 Argo Application revision、sync 状态、目标 namespace Deployment/StatefulSet ready、公网 health。Argo 还停在旧 revision 时,优先做 `argocd.argoproj.io/refresh=hard` 刷新;不要把已经推送的 `G14-gitops` 分支误判成运行面已滚动。
|
- 运行态验证: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`。 |
|
||||||
|
|||||||
@@ -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 路径不再作为正式默认入口。 |
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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, "\\$&");
|
|
||||||
}
|
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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(", ")
|
|
||||||
);
|
|
||||||
@@ -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"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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"] },
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Executable → Regular
+21
-7
@@ -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;
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { main } from "../../src/hwlab-cli-lib.ts";
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }; }
|
||||||
|
|||||||
@@ -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)); }
|
||||||
Reference in New Issue
Block a user