feat: update caserun aggregation and v02 deploy config

This commit is contained in:
Codex Agent
2026-06-08 12:20:34 +08:00
parent d07565c6f1
commit 916838bde4
43 changed files with 1836 additions and 395 deletions
+7 -7
View File
@@ -1,16 +1,16 @@
# Deploy Contract
`deploy.schema.json` defines the future `deploy/deploy.json` manifest shape.
`deploy.schema.json` defines the future `deploy/deploy.yaml` manifest shape.
The MVP acceptance environment is DEV only. Public endpoint source fields live in
`deploy/deploy.json`: frontend `http://74.48.78.17:16666` and API/edge
`deploy/deploy.yaml`: frontend `http://74.48.78.17:16666` and API/edge
`http://74.48.78.17:16667`. PROD profile fields may be represented in schema
for future compatibility, but PROD deployment is not part of MVP acceptance.
## L5 DEV Skeleton
- `deploy/deploy.json` is the DEV-only deploy manifest.
- `deploy/deploy.json` is the single source for `health.path`,
- `deploy/deploy.yaml` is the DEV-only deploy manifest.
- `deploy/deploy.yaml` is the single source for `health.path`,
`publicEndpoints`, `frp.proxies`, and `k3s.serviceMappings`.
- `deploy/k8s/base` contains parseable k3s resource skeletons for the frozen
HWLAB service IDs rendered from the manifest contract.
@@ -67,9 +67,9 @@ node scripts/deploy-desired-state-plan.mjs --target-tag <tag> --pretty
node scripts/deploy-desired-state-plan.mjs --promotion-commit <sha> --check
```
The desired-state plan reads `deploy/deploy.json`,
The desired-state plan reads `deploy/deploy.yaml`,
`deploy/artifact-catalog.dev.json`, `deploy/k8s/base/workloads.yaml`, and the
optional artifact report snapshot. `deploy/deploy.json` is the human-authored
optional artifact report snapshot. `deploy/deploy.yaml` is the human-authored
runtime config truth source. Published image identity lives in
`G14-gitops:deploy/artifact-catalog.dev.json`; the source branch catalog is only
a seed contract for local checks. GitOps render overlays catalog image fields and
@@ -101,7 +101,7 @@ Next DEV deploy smoke commands, for a separately authorized deployment task:
```sh
npm run check
node -e "JSON.parse(require('node:fs').readFileSync('deploy/deploy.json','utf8'))"
node -e "JSON.parse(require('node:fs').readFileSync('deploy/deploy.yaml','utf8'))"
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl apply --dry-run=server -k deploy/k8s/dev
curl -fsS http://74.48.78.17:16667/health/live
node scripts/dev-edge-health-smoke.mjs --live --report /tmp/hwlab-dev-gate/report.json
+2 -2
View File
@@ -244,8 +244,8 @@
],
"serviceInventory": {
"version": "v2",
"serviceCount": 8,
"requiredServiceCount": 8,
"serviceCount": 5,
"requiredServiceCount": 5,
"disabledServiceCount": 0,
"requiredServiceIds": [
"hwlab-cloud-api",
+91
View File
@@ -136,6 +136,97 @@
"hwlab-edge-proxy": "deploy/runtime/boot/hwlab-edge-proxy.sh",
"hwlab-agent-skills": "deploy/runtime/boot/hwlab-agent-skills.sh"
},
"serviceDeclarations": {
"hwlab-cloud-api": {
"runtimeKind": "bun-command",
"entrypoint": "cmd/hwlab-cloud-api/main.ts",
"artifactKind": "bun-command",
"healthPath": "/health/live",
"healthPort": 6667,
"componentPaths": [
"cmd/hwlab-cloud-api/",
"cmd/hwlab-codex-api-responses-forwarder/",
"cmd/hwlab-deepseek-responses-bridge/",
"deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh",
"deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh",
"internal/cloud/",
"internal/db/",
"internal/audit/",
"internal/agent/agentrun-dispatch.mjs",
"internal/agent/prompts/",
"skills/hwlab-agent-runtime/"
],
"env": {},
"observable": true
},
"hwlab-cloud-web": {
"runtimeKind": "cloud-web",
"entrypoint": "web/hwlab-cloud-web/index.html",
"artifactKind": "cloud-web",
"healthPath": "/health/live",
"healthPort": 8080,
"componentPaths": [
"web/hwlab-cloud-web/",
"internal/dev-entrypoint/cloud-web-runtime.mjs",
"internal/dev-entrypoint/cloud-web-proxy.mjs",
"internal/dev-entrypoint/cloud-web-routes.mjs",
"tools/src/hwlab-cli/trace-renderer.ts"
],
"env": {},
"observable": true
},
"hwlab-gateway": {
"runtimeKind": "bun-command",
"entrypoint": "cmd/hwlab-gateway/main.ts",
"artifactKind": "bun-command",
"healthPath": "/health/live",
"healthPort": 7001,
"componentPaths": ["cmd/hwlab-gateway/", "internal/sim/"],
"env": {},
"observable": false
},
"hwlab-edge-proxy": {
"runtimeKind": "bun-command",
"entrypoint": "cmd/hwlab-edge-proxy/main.ts",
"artifactKind": "bun-command",
"healthPath": "/health",
"healthPort": 6667,
"componentPaths": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"],
"env": {},
"observable": true
},
"hwlab-agent-skills": {
"runtimeKind": "skills-bundle",
"entrypoint": "skills/hwlab-agent-runtime/SKILL.md",
"artifactKind": "skills-bundle",
"healthPath": "/health/live",
"healthPort": 7430,
"componentPaths": ["skills/"],
"env": {},
"observable": true
}
},
"envRecipe": {
"osPackages": ["ca-certificates", "git"],
"bunVersion": "1.3.13",
"launcherPath": "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts",
"runtimeNodeModulesPath": "/opt/hwlab-env/node_modules",
"additionalEnvPaths": [
"internal/dev-entrypoint/artifact-runtime.mjs",
"scripts/artifact-publish.mjs",
"scripts/g14-artifact-publish.mjs",
"scripts/src/dev-artifact-services.mjs"
],
"hwpodAliases": [
{ "command": "hwpod", "script": "tools/hwpod-cli.ts" },
{ "command": "hwpod-ctl", "script": "tools/hwpod-ctl.ts" },
{ "command": "hwpod-compiler", "script": "tools/hwpod-compiler-cli.ts" }
]
},
"bootConfig": {
"template": "default",
"overrides": {}
},
"services": [
{
"serviceId": "hwlab-cloud-api",
+135
View File
@@ -48,6 +48,9 @@
},
"additionalProperties": false
},
"lanes": {
"$ref": "#/$defs/lanes"
},
"services": {
"type": "array",
"minItems": 1,
@@ -303,6 +306,138 @@
},
"additionalProperties": false
},
"lanes": {
"type": "object",
"properties": {
"v02": {
"$ref": "#/$defs/v02Lane"
}
},
"additionalProperties": false
},
"v02Lane": {
"type": "object",
"required": ["name", "sourceBranch", "gitopsBranch", "namespace", "envReuseServices", "bootScripts", "serviceDeclarations", "envRecipe", "bootConfig"],
"properties": {
"name": { "type": "string", "const": "v0.2" },
"sourceBranch": { "type": "string", "const": "v0.2" },
"gitopsBranch": { "type": "string", "const": "v0.2-gitops" },
"namespace": { "type": "string", "const": "hwlab-v02" },
"endpoint": { "type": "string" },
"publicEndpoints": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"artifactCatalog": { "type": "string" },
"runtimePath": { "type": "string" },
"imageTagMode": { "type": "string", "enum": ["short", "full"] },
"sourceRepo": { "type": "string" },
"envReuseServices": {
"type": "array",
"items": { "$ref": "#/$defs/serviceId" },
"uniqueItems": true
},
"bootScripts": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/$defs/repoPath" }
},
"serviceDeclarations": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/$defs/serviceDeclaration" }
},
"envRecipe": { "$ref": "#/$defs/envRecipe" },
"bootConfig": { "$ref": "#/$defs/bootConfig" },
"services": {
"type": "array",
"items": { "$ref": "#/$defs/laneService" }
}
},
"additionalProperties": false
},
"serviceDeclaration": {
"type": "object",
"required": ["runtimeKind", "entrypoint", "componentPaths"],
"properties": {
"runtimeKind": { "type": "string", "enum": ["bun-command", "cloud-web", "skills-bundle", "node-command"] },
"entrypoint": { "$ref": "#/$defs/repoPath" },
"artifactKind": { "type": "string", "enum": ["bun-command", "cloud-web", "skills-bundle", "node-command"] },
"healthPath": { "type": "string", "pattern": "^/" },
"healthPort": { "type": "integer", "minimum": 1, "maximum": 65535 },
"componentPaths": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/repoPath" }
},
"env": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"observable": { "type": "boolean" }
},
"additionalProperties": false
},
"envRecipe": {
"type": "object",
"required": ["osPackages", "bunVersion", "launcherPath", "runtimeNodeModulesPath", "additionalEnvPaths", "hwpodAliases"],
"properties": {
"osPackages": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"bunVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+" },
"launcherPath": { "$ref": "#/$defs/repoPath" },
"runtimeNodeModulesPath": { "type": "string", "pattern": "^/" },
"additionalEnvPaths": {
"type": "array",
"items": { "$ref": "#/$defs/repoPath" },
"uniqueItems": true
},
"hwpodAliases": {
"type": "array",
"items": { "$ref": "#/$defs/hwpodAlias" }
}
},
"additionalProperties": false
},
"hwpodAlias": {
"type": "object",
"required": ["command", "script"],
"properties": {
"command": { "type": "string", "minLength": 1 },
"script": { "$ref": "#/$defs/repoPath" }
},
"additionalProperties": false
},
"bootConfig": {
"type": "object",
"required": ["template"],
"properties": {
"template": { "type": "string", "enum": ["default"] },
"overrides": { "type": "object" }
},
"additionalProperties": false
},
"laneService": {
"type": "object",
"required": ["serviceId"],
"properties": {
"serviceId": { "$ref": "#/$defs/serviceId" },
"env": {
"type": "object",
"additionalProperties": { "type": "string" }
}
},
"additionalProperties": false
},
"repoPath": {
"type": "string",
"minLength": 1,
"not": { "pattern": "(^/|(^|/)\\.\\.(/|$))" }
},
"service": {
"type": "object",
"required": ["serviceId", "namespace", "healthPath", "profile"],
+292
View File
@@ -0,0 +1,292 @@
manifestVersion: v1
environment: dev
namespace: hwlab-dev
endpoint: http://74.48.78.17:16667
health:
method: GET
path: /health/live
responseFormat: json
publicEndpoints:
frontend:
url: http://74.48.78.17:16666
protocol: http
host: 74.48.78.17
port: 16666
serviceId: hwlab-cloud-web
frpProxy: hwlab-dev-cloud-web
api:
url: http://74.48.78.17:16667
protocol: http
host: 74.48.78.17
port: 16667
serviceId: hwlab-edge-proxy
frpProxy: hwlab-dev-edge-proxy
frp:
server:
address: 74.48.78.17
bindPort: 7000
proxies:
- name: hwlab-dev-cloud-web
endpointId: frontend
type: tcp
localServiceId: hwlab-cloud-web
localHost: hwlab-cloud-web.hwlab-dev.svc.cluster.local
localPort: 8080
remotePort: 16666
- name: hwlab-dev-edge-proxy
endpointId: api
type: tcp
localServiceId: hwlab-edge-proxy
localHost: hwlab-edge-proxy.hwlab-dev.svc.cluster.local
localPort: 6667
remotePort: 16667
k3s:
serviceMappings:
- serviceId: hwlab-cloud-api
name: hwlab-cloud-api
namespace: hwlab-dev
port: 6667
targetPort: http
- serviceId: hwlab-cloud-web
name: hwlab-cloud-web
namespace: hwlab-dev
port: 8080
targetPort: http
- serviceId: hwlab-gateway
name: hwlab-gateway
namespace: hwlab-dev
port: 7001
targetPort: http
- serviceId: hwlab-agent-skills
name: hwlab-agent-skills
namespace: hwlab-dev
port: 7430
targetPort: http
- serviceId: hwlab-edge-proxy
name: hwlab-edge-proxy
namespace: hwlab-dev
port: 6667
targetPort: http
profiles:
dev:
name: dev
enabled: true
namespace: hwlab-dev
endpoint: http://74.48.78.17:16667
notes: DEV-only k3s deployment skeleton. D601 reaches the master edge through frp reverse tunnel.
prod:
name: prod
enabled: false
namespace: hwlab-prod
notes: Reserved placeholder only. PROD deployment and acceptance are explicitly out of scope.
lanes:
v02:
name: v0.2
sourceBranch: v0.2
gitopsBranch: v0.2-gitops
namespace: hwlab-v02
endpoint: https://hwlab.74-48-78-17.nip.io
publicEndpoints:
frontend: https://hwlab.74-48-78-17.nip.io
api: https://hwlab.74-48-78-17.nip.io
legacyFrontend: http://74.48.78.17:19666
legacyApi: http://74.48.78.17:19667
artifactCatalog: deploy/artifact-catalog.v02.json
runtimePath: deploy/gitops/g14/runtime-v02
imageTagMode: full
envReuseServices:
- hwlab-cloud-api
- hwlab-cloud-web
- hwlab-gateway
- hwlab-edge-proxy
- hwlab-agent-skills
bootScripts:
hwlab-cloud-api: deploy/runtime/boot/hwlab-cloud-api.sh
hwlab-cloud-web: deploy/runtime/boot/hwlab-cloud-web.sh
hwlab-gateway: deploy/runtime/boot/hwlab-gateway.sh
hwlab-edge-proxy: deploy/runtime/boot/hwlab-edge-proxy.sh
hwlab-agent-skills: deploy/runtime/boot/hwlab-agent-skills.sh
serviceDeclarations:
hwlab-cloud-api:
runtimeKind: bun-command
entrypoint: cmd/hwlab-cloud-api/main.ts
artifactKind: bun-command
healthPath: /health/live
healthPort: 6667
componentPaths:
- cmd/hwlab-cloud-api/
- cmd/hwlab-codex-api-responses-forwarder/
- cmd/hwlab-deepseek-responses-bridge/
- deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh
- deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh
- internal/cloud/
- internal/db/
- internal/audit/
- internal/agent/agentrun-dispatch.mjs
- internal/agent/prompts/
- skills/hwlab-agent-runtime/
env: {}
observable: true
hwlab-cloud-web:
runtimeKind: cloud-web
entrypoint: web/hwlab-cloud-web/index.html
artifactKind: cloud-web
healthPath: /health/live
healthPort: 8080
componentPaths:
- web/hwlab-cloud-web/
- internal/dev-entrypoint/cloud-web-runtime.mjs
- internal/dev-entrypoint/cloud-web-proxy.mjs
- internal/dev-entrypoint/cloud-web-routes.mjs
- tools/src/hwlab-cli/trace-renderer.ts
env: {}
observable: true
hwlab-gateway:
runtimeKind: bun-command
entrypoint: cmd/hwlab-gateway/main.ts
artifactKind: bun-command
healthPath: /health/live
healthPort: 7001
componentPaths:
- cmd/hwlab-gateway/
- internal/sim/
env: {}
observable: false
hwlab-edge-proxy:
runtimeKind: bun-command
entrypoint: cmd/hwlab-edge-proxy/main.ts
artifactKind: bun-command
healthPath: /health
healthPort: 6667
componentPaths:
- cmd/hwlab-edge-proxy/
- internal/dev-entrypoint/http.mjs
env: {}
observable: true
hwlab-agent-skills:
runtimeKind: skills-bundle
entrypoint: skills/hwlab-agent-runtime/SKILL.md
artifactKind: skills-bundle
healthPath: /health/live
healthPort: 7430
componentPaths:
- skills/
env: {}
observable: true
envRecipe:
osPackages:
- ca-certificates
- git
bunVersion: 1.3.13
launcherPath: deploy/runtime/launcher/hwlab-env-reuse-launcher.ts
runtimeNodeModulesPath: /opt/hwlab-env/node_modules
additionalEnvPaths:
- internal/dev-entrypoint/artifact-runtime.mjs
- scripts/artifact-publish.mjs
- scripts/g14-artifact-publish.mjs
- scripts/src/dev-artifact-services.mjs
hwpodAliases:
- command: hwpod
script: tools/hwpod-cli.ts
- command: hwpod-ctl
script: tools/hwpod-ctl.ts
- command: hwpod-compiler
script: tools/hwpod-compiler-cli.ts
bootConfig:
template: default
overrides: {}
services:
- serviceId: hwlab-cloud-api
env:
HWLAB_CLOUD_DB_URL: secretRef:hwlab-cloud-api-v02-db/database-url
HWLAB_CLOUD_DB_CONTRACT: v02-redacted-presence-only
HWLAB_ACCESS_CONTROL_REQUIRED: "1"
HWLAB_BOOTSTRAP_ADMIN_ID: usr_v02_admin
HWLAB_BOOTSTRAP_ADMIN_USERNAME: admin
HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME: HWLAB v0.2 Admin
HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH: secretRef:hwlab-v02-bootstrap-admin/password-hash
HWLAB_BOOTSTRAP_ADMIN_API_KEY_ID: key_master_server_admin
HWLAB_BOOTSTRAP_ADMIN_API_KEY: secretRef:hwlab-v02-master-server-admin-api-key/api-key
HWLAB_KEYCLOAK_ISSUER: https://auth.74-48-78-17.nip.io/realms/hwlab
HWLAB_KEYCLOAK_CLIENT_ID: hwlab-cloud-web
HWLAB_KEYCLOAK_CLIENT_SECRET: secretRef:hwlab-cloud-web-client/client-secret
HWLAB_CODE_AGENT_ADAPTER: agentrun-v01
AGENTRUN_MGR_URL: http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080
HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: G14
HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE: agentrun-v01
HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE: agentrun-v01
HWLAB_CODE_AGENT_AGENTRUN_REPO_URL: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git
services:
- serviceId: hwlab-cloud-api
namespace: hwlab-dev
healthPath: /health/live
profile: dev
replicas: 1
env:
HWLAB_ENVIRONMENT: dev
HWLAB_PUBLIC_ENDPOINT: http://74.48.78.17:16667
HWLAB_CLOUD_API_PORT: "6667"
HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN: unidesk-backend,provider-gateway,microservice-proxy
HWLAB_CLOUD_DB_URL: secretRef:hwlab-cloud-api-dev-db/database-url
HWLAB_CLOUD_DB_SSL_MODE: disable
HWLAB_CLOUD_DB_CONTRACT: dev-redacted-presence-only
HWLAB_CLOUD_RUNTIME_ADAPTER: postgres
HWLAB_CLOUD_RUNTIME_DURABLE: "true"
HWLAB_M3_IO_CONTROL_ENABLED: "false"
HWLAB_CODE_AGENT_PROVIDER: codex-stdio
HWLAB_CODE_AGENT_MODEL: gpt-5.5
HWLAB_CODE_AGENT_TIMEOUT_MS: "1200000"
HWLAB_CODE_AGENT_OPENAI_BASE_URL: http://127.0.0.1:49280/responses
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1"
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: repo-owned
HWLAB_CODE_AGENT_WORKSPACE: /workspace/hwlab
HWLAB_CODE_AGENT_CODEX_WORKSPACE: /workspace/hwlab
HWLAB_CODE_AGENT_CODEX_SANDBOX: danger-full-access
HWLAB_CODE_AGENT_CODEX_COMMAND: /app/node_modules/.bin/codex
CODEX_HOME: /codex-home
NO_PROXY: hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com
no_proxy: hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com
OPENAI_API_KEY: secretRef:hwlab-code-agent-provider/openai-api-key
HWLAB_CODE_AGENT_SKILLS_DIRS: /app/skills:/data/user-skills
HWLAB_PREINSTALLED_SKILLS_DIR: /app/skills
HWLAB_USER_SKILLS_DIR: /data/user-skills
HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: deepseek
HWLAB_CODE_AGENT_DEEPSEEK_MODEL: deepseek-chat
HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL: http://hwlab-deepseek-proxy.hwlab-dev.svc.cluster.local:4000/v1/responses
HWLAB_CODE_AGENT_CODEX_API_MODEL: gpt-5.5
HWLAB_CODE_AGENT_CODEX_API_BASE_URL: http://127.0.0.1:49280/responses
HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL: https://hyueapi.com
HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT: "49280"
- serviceId: hwlab-cloud-web
namespace: hwlab-dev
healthPath: /health/live
profile: dev
replicas: 1
env:
HWLAB_ENVIRONMENT: dev
HWLAB_API_BASE_URL: http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667
HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS: "1260000"
- serviceId: hwlab-gateway
namespace: hwlab-dev
healthPath: /health/live
profile: dev
replicas: 0
env:
HWLAB_GATEWAY_MODE: hardware-boundary
HWLAB_ENVIRONMENT: dev
- serviceId: hwlab-edge-proxy
namespace: hwlab-dev
healthPath: /health/live
profile: dev
replicas: 1
env:
HWLAB_EDGE_LISTEN: 0.0.0.0:6667
HWLAB_EDGE_UPSTREAM: http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667
HWLAB_EDGE_PROXY_TIMEOUT_MS: "1260000"
- serviceId: hwlab-agent-skills
namespace: hwlab-dev
healthPath: /health/live
profile: dev
replicas: 1
env: {}
createdAt: 2026-05-21T00:00:00Z
+1 -1
View File
@@ -9,7 +9,7 @@ The DEV reverse link is D601 to master edge through frp.
- `frps.dev.toml` reserves D601 `16666` / `16667`, G14 DEV `17666` / `17667`, and G14 PROD `18666` / `18667`
as TCP `remotePort` values; do not also bind them as `vhostHTTPPort`, because that collides with
the DEV TCP proxy.
- `deploy/deploy.json` is the source for the public endpoints and FRP proxy
- `deploy/deploy.yaml` is the source for the public endpoints and FRP proxy
mapping. Run `node scripts/deploy-contract-plan.mjs --check` to detect drift
such as public `6666`/`6667` reuse before any future apply command.
- No secret values are stored in this repository. Operators must provide auth
+1 -1
View File
@@ -21,6 +21,6 @@ Health ownership:
PROD is intentionally gated off for this task.
`deploy/deploy.json` is the source for the public endpoints, FRP proxy ports,
`deploy/deploy.yaml` is the source for the public endpoints, FRP proxy ports,
k3s service mappings, and health path. The dry-run renderer validates these
files only and does not perform a real edge apply or restart.
+8 -8
View File
@@ -28,7 +28,7 @@ G14 是 HWLAB 当前 DEV/PROD 原生 k8s 与 GitOps 运行面目标。G14 CI/CD
- 架构迁移时,过时的自检、预检、guard、gate 优先删除,不在旧门禁上叠加例外或复杂度;新门禁只允许覆盖明确高价值风险,必须保持最小、低噪声、易迁移。
- 直接声明 Tekton Pipeline、最小原语校验 task 和 PipelineRun 样板;不再读取 `CI.json` 或生成 `ci-json` step。
- 读取 `deploy/deploy.json``deploy/k8s/*`,生成 Argo CD 可消费的 G14 runtime Kustomize path。
- 读取 `deploy/deploy.yaml``deploy/k8s/*`,生成 Argo CD 可消费的 G14 runtime Kustomize path。
- G14 Tekton 的镜像构建发布入口必须是 `scripts/g14-artifact-publish.mjs`;它只是集群内 Task 的 build/push helper,不做 rollout、不写 D601、不获取 legacy DEV CD Lease。旧 `scripts/dev-artifact-publish.mjs` 入口已删除;`dev-cd-apply``ci-publish` 和旧 `main` JS CD 入口禁止出现在 G14 Pipeline 生成脚本和 G14 验收证据中。
- `g14-contract-check` 在 fresh source clone 内取当前 `HEAD`,把 GitOps 产物渲染到 `mktemp -d` 临时目录,并立刻用同一个 `--source-revision` 执行 `--check`;它校验 render 代码、原生 Tekton 产物生成合同和 forbidden-fragment 护栏,不依赖 source branch 预先存在 `deploy/gitops/g14/source.json`。面向已发布 runtime 的 `source.json` 只存在于 `G14-gitops` 生成分支,作为 promotion evidence。
- 生成 `hwlab-g14-branch-poller` CronJob:它使用 G14 集群内的 Git SSH Secret 轮询 `G14` 分支,按 source commit 创建确定命名的 Tekton PipelineRun。
@@ -73,17 +73,17 @@ npm run g14:gitops:render -- --source-revision <sourceCommit>
## Monorepo 组件计划与兼容 render
HWLAB 是 monorepoG14 CI/CD 加速必须按组件输入判断构建和滚动,并直接依赖内建 component model、`deploy/deploy.json` per-service artifact catalog`CI.json` 已删除,不再作为任何 planner 输入。
HWLAB 是 monorepoG14 CI/CD 加速必须按组件输入判断构建和滚动v0.2 env-reuse 的组件边界与环境配方直接来自 `deploy/deploy.yaml`,运行态镜像身份来自 per-service artifact catalog`CI.json` 已删除,不再作为任何 planner 输入。
- `scripts/g14-ci-plan.mjs` 是只读 planner,默认读取当前 workspace 的 `deploy/deploy.json``deploy/artifact-catalog.dev.json``scripts/src/g14-ci-plan-lib.mjs` 内建 component model,输出 `affectedServices``reusedServices``componentCommitId``componentInputHash``dockerfileHash``baseImageDigest``buildArgsHash` 和原因;它不得修改 deploy、catalog 或 GitOps 文件。Tekton `prepare-source` 会先从 `G14-gitops` 注入上一轮发布态 catalogsource 分支里的 catalog 只作为 seed contract。
- 服务清单兼容顺序固定为:显式 `--services``deploy.services[]``deploy.k3s.serviceMappings[]``internal/protocol.SERVICE_IDS`。因此旧 `deploy/deploy.json` 形态和当前完整 `deploy.services[]` 形态都必须能被 planner 识别
- 组件边界固定由 `scripts/src/g14-ci-plan-lib.mjs` 的内建 service-path model 定义;如需新增或调整 `componentPaths``sharedPaths``runtimeDeps``buildSystemPaths`,直接修改 planner 库和对应测试,不再额外维护 repo-local commands/forbidden skeleton
- `scripts/g14-ci-plan.mjs` 是只读 planner,默认读取当前 workspace 的 `deploy/deploy.yaml``deploy/artifact-catalog.dev.json``deploy/deploy.yaml` 内的 lane service declarations/env recipe,输出 `affectedServices``reusedServices``componentCommitId``componentInputHash``dockerfileHash``baseImageDigest``buildArgsHash` 和原因;它不得修改 deploy、catalog 或 GitOps 文件。Tekton `prepare-source` 会先从 `G14-gitops` 注入上一轮发布态 catalogsource 分支里的 catalog 只作为 seed contract。
- v0.2 服务清单来自显式 `--services``deploy.lanes.v02.envReuseServices`;旧 `deploy.services[]``deploy.k3s.serviceMappings[]``internal/protocol.SERVICE_IDS` 推导路径不再作为 planner 输入
- v0.2 env-reuse 组件边界和环境镜像配方以 `deploy/deploy.yaml``lanes.v02.serviceDeclarations``lanes.v02.envRecipe``lanes.v02.bootConfig` 为单一配置点;新增服务或调整 `componentPaths``runtimeKind``entrypoint`、Bun 版本、系统包、launcher 路径和 HWPOD alias 时先改 deploy 配置与 schema/测试,不再把这些属性硬编码进 planner 或 artifact publish helper
- `hwpod` 是 runner 内的稳定 HWPOD task 入口,由 `tools/hwpod-cli.ts``tools/hwpod-compiler-cli.ts``tools/hwpod-ctl.ts``tools/hwpod-node.ts``skills/hwpod-cli/``skills/hwpod-ctl/` 组成。修改这些路径时,G14/v0.2 CI 必须至少触发携带 `/usr/local/bin/hwpod` 的 runtime skills/env-reuse 重新装配或 rollout,不能全量复用旧 artifact 后只报告 PipelineRun 成功。
- `scripts/g14-artifact-publish.mjs` 默认启用组件级 lazy build:先运行 planner,再只构建/推送 `affectedServices``reusedServices``deploy/artifact-catalog.dev.json` 或 lane catalog 复用已有 sha256 digest。非 env-reuse 服务复用前必须满足 artifact provenance 自证:catalog 有可验证 digest、catalog 的 `sourceCommitId` 在当前 repo 可解析、用该 `sourceCommitId` 的 source tree 重新计算出的 `componentInputHash` 等于 catalog 记录、该 hash 再等于本轮 planner 计算值,且 `dockerfileHash`/`buildArgsHash` 没有不一致。catalog 缺 digest、缺 provenance、source tree 无法解析、catalog hash 与 catalog source tree 不一致、或 catalog hash 与本轮 input 不一致时,planner 必须把该服务列为 affected 并重新发布,不能用旧 guard 阻塞,也不能退回 Docker 或 legacy full-build 路线。
- Artifact catalog 的 per-service provenance 是镜像 digest 的身份证明,不是本轮 planner 状态缓存。reuse 路径只能保留旧 artifact 自身的 `sourceCommitId``componentCommitId``componentInputHash``dockerfileHash``baseImage*``buildArgsHash`;禁止把当前 planner 的 component/build 元数据写入复用的旧 digest,否则下一轮 planner 会把旧镜像误判成已包含新输入,造成 CI/CD false-green。
- `scripts/g14-artifact-publish.mjs` 的 publish report 必须携带 planner 的 per-service 元数据;`scripts/refresh-artifact-catalog.mjs` 默认只预览,只有 G14 Tekton promotion 显式传 `--write` 时,才把生成的 `commitId``image``imageTag``digest``publishState` 和 component provenance 字段写进当前 workspace 的 `deploy/artifact-catalog.dev.json`,随后只提交到 `G14-gitops``deploy/deploy.json` 是人写的 runtime config 真相源,不得被 promotion、refresh 脚本或人工发布流程回写镜像身份字段。
- `scripts/g14-gitops-render.mjs` 只支持混合 desired stateworkload 的 container image、`HWLAB_IMAGE``HWLAB_IMAGE_TAG` 和 pod template `source-commit` 来自 `deploy/artifact-catalog.dev.json` 的 per-service artifact identity;普通 env、replica、healthPath、profile 等配置来自 `deploy/deploy.json`。全局 GitOps metadata 仍记录本次 source commit。`--legacy-source-images``HWLAB_G14_USE_DEPLOY_IMAGES=0` 已废弃,不得把所有 workload image 回退渲染为同一个 source commit tag。
- G14 Tekton promotion 在推送 `G14-gitops` 前,必须用 publish report 显式 `--write` 刷新 workspace 内的 `deploy/artifact-catalog.dev.json`,然后把刷新后的 catalog 和 rendered GitOps desired state 一起提交到 `G14-gitops`。promotion 不得自动修改或推送 `G14` source branch,也不得自动修改 `deploy/deploy.json``deploy/k8s/base/workloads.yaml`,这样人写配置不会和 CI 生成身份反复冲突。
- `scripts/g14-artifact-publish.mjs` 的 publish report 必须携带 planner 的 per-service 元数据;`scripts/refresh-artifact-catalog.mjs` 默认只预览,只有 G14 Tekton promotion 显式传 `--write` 时,才把生成的 `commitId``image``imageTag``digest``publishState` 和 component provenance 字段写进当前 workspace 的 `deploy/artifact-catalog.dev.json`,随后只提交到 `G14-gitops``deploy/deploy.yaml` 是人写的 runtime config 真相源,不得被 promotion、refresh 脚本或人工发布流程回写镜像身份字段。
- `scripts/g14-gitops-render.mjs` 只支持混合 desired stateworkload 的 container image、`HWLAB_IMAGE``HWLAB_IMAGE_TAG` 和 pod template `source-commit` 来自 `deploy/artifact-catalog.dev.json` 的 per-service artifact identity;普通 env、replica、healthPath、profile 等配置来自 `deploy/deploy.yaml`。全局 GitOps metadata 仍记录本次 source commit。`--legacy-source-images``HWLAB_G14_USE_DEPLOY_IMAGES=0` 已废弃,不得把所有 workload image 回退渲染为同一个 source commit tag。
- G14 Tekton promotion 在推送 `G14-gitops` 前,必须用 publish report 显式 `--write` 刷新 workspace 内的 `deploy/artifact-catalog.dev.json`,然后把刷新后的 catalog 和 rendered GitOps desired state 一起提交到 `G14-gitops`。promotion 不得自动修改或推送 `G14` source branch,也不得自动修改 `deploy/deploy.yaml``deploy/k8s/base/workloads.yaml`,这样人写配置不会和 CI 生成身份反复冲突。
- Tekton 并发化只能以 planner 输出作为输入;每个 service 都有独立 TaskRunchanged service 启动 BuildKitunchanged service 只写 reuse result 并复用 catalog digest,且不能改 pod template,避免无意义 rollout。没有完整 per-service desired state 证据时,必须修复 planner/catalog 证据,不能使用 `--full-build``--legacy-source-images`、DIND 或 Docker fallback 回退。
## 加速判定与当前瓶颈
+1 -1
View File
@@ -18,7 +18,7 @@ front-end acceptance path.
- Historical public `:6666` and `:6667` endpoints are not current acceptance
targets; internal k3s services may still use `6667`.
- Runtime intent and artifact identity are reviewed through
`deploy/deploy.json` and `deploy/artifact-catalog.dev.json`.
`deploy/deploy.yaml` and `deploy/artifact-catalog.dev.json`.
- Suspended template Job replacement is tracked by
`pikasTech/HWLAB#63` and must not be confused with M3 loop evidence.
+19 -2
View File
@@ -295,6 +295,22 @@ CaseRun 的 trace 处理必须遵循 [spec-v02-code-agent-trace.md](spec-v02-cod
- `case run`、异步 worker 完成收口和 `case run result` 刷新归档后,默认必须把当前 `runs/<caseId>/<runId>/` 目录自动 `git add``git commit``git push origin HEAD` 到 case registry repo;只允许提交当前 run 目录,不得顺带提交 registry 里其他历史未跟踪 run。`registrySync` 必须写入 result/summary,说明 `pushed``unchanged` 或失败阶段。`--no-case-repo-record` 是完全不记录 registry 的诊断出口;`--no-case-repo-git-sync` / `--no-registry-git-sync` 只用于特殊本地调试,不能作为正常 CaseRun 产物收口方式。
- HWLAB repo 的 harness、`hwpod-cli``hwpod-ctl``hwpod-compiler-cli` 或文档改进可以按风险进入 `v0.2` 分支;单纯文档和轻量 CLI/helper 变更可直接提交,业务代码、运行面或发布链路变更走 PR 工作流。
### CaseRun registry 聚合阅读入口
`hwlab-cli case aggregate <caseId> --run-id <runId> --case-repo /root/hwlab-case-registry` 是已完成 CaseRun 的无服务二次整理入口。它只读取 case registry repo 里已有的 `runs/<caseId>/<runId>/` 产物,不启动新的 CaseRun、不访问硬件、不触发 CI/CD、不追加自动评价或 pass/fail 判定;默认输出 `runs/<caseId>/<runId>/aggregate.md`,并只把这个 markdown 文件 `git add``git commit``git push` 回 case registry repo。若 registry 里存在其他并行 dirty 产物,`case aggregate` 不得顺带提交它们。
`aggregate.md` 是该 run 的主阅读入口,必须按固定顺序聚合运行环境信息、HWPOD 信息、Code Agent 信息、输入 Prompt、低噪声 Trace、final response 和最后 diff。Trace 正文优先使用 `agent-messages.json` 里的共享 Web/CLI renderer rows;普通 message row 直接展示正文,不做折叠,只有工具调用 row 使用 `<details>/<summary>`,且 `<summary>` 本身就是折叠按钮,文案写成 `已运行 <command>`。缺少 rows 时才嵌入已有 `agent-trace.md`。聚合文件可以列原始产物索引和 trace/result/inspect 命令作为审计线索,但不能把 `summary.md``result.json``final-response.md` 或原 trace 文件继续声明为对外主阅读路径。
验收已完成 case04 时,使用现有 registry 产物回放,不重新启动 CaseRun:
```bash
hwlab-cli case aggregate d601-f103-v2-arm2d-integration \
--case-repo /root/hwlab-case-registry \
--run-id <existing-case04-run-id>
```
通过证据至少包括 CLI JSON 输出中的 `action=case.aggregate``autoEvaluation=false``aggregate.rel=runs/<caseId>/<runId>/aggregate.md``registrySync.status`,以及打开 `aggregate.md` 后能在单文件内读到上述七类内容。
当前边界:CaseRun 仍是无服务化短连接 CLI 编排,不负责排队、并发、评分、自动合并、长期 run retention 或 epoch 汇总;这些能力属于后续强化学习 Harness 层。CaseRun 也不负责代替 HWLAB Code Agent 完成研发任务,后续 agent-task CaseRun 必须把 prompt/session/trace/diff/evidence 作为一等输出。AgentRun 私有 GitHub repo git transport 必须具备有界失败和可观测性后,才能把单次 CaseRun 扩展为批量 runner 或 epoch 系统。
## 验收标准
@@ -310,5 +326,6 @@ CaseRun 的 trace 处理必须遵循 [spec-v02-code-agent-trace.md](spec-v02-cod
7. 长任务验收必须覆盖 `hwlab-cli case run start <caseId>` 立即返回,以及 `status/result/logs <runId>` 在 worker 运行中能短查询到阶段、耗时、stdout/stderr byte count、trace/session/conversation/thread 信息和下一条轮询命令;不得把同步命令长时间无输出视为有效 CaseRun 操作体验。
8. `d601-f103-v2-compile` 的真实流程只覆盖当前编排链路和 compile-only smoke;操作员直接运行 `hwpod-cli build`、直接调用 Keil 或只检查 node 侧 job,不等同于 CaseRun 流程跑完。
9. agent-task CaseRun 的真实流程必须证明 `case run` 已创建或调用 Code Agent / AgentRun session,任务 prompt 已进入 agent 上下文,CaseRun 已采集隔离 subject worktree diff,并已继续执行编译、下载或 I/O smoke 等后续动作;当前版本只记录这些事实,不自动判断 agent 是否完成任务。
10. Windows subject worktree 文本编辑必须能在 CRLF 文件中通过 `workspace.insert-after``workspace.replace` 完成一行源码修改,返回 before/after SHA 与 diff 摘要,并保留原文件 CRLF;`workspace.apply-patch` 在 context 不匹配时必须返回可诊断 payload,而不是只给 `context not found`
11. Keil `debug.download` 自动装配必须输出结构化 `cmd.run` argv 步骤;带空格的 `probeName` 在 plan 中必须是单独 argv 元素。`io.uart.read` 必须通过节点本地 `serial-monitor` 读取正在监控的 COM/baud;未监控或工具失败时必须返回包含 `ioProbe`/COM 口/baud/monitor status/启动建议的结构化 blocker details
10. 已完成 run 的 registry 聚合验收必须通过 `hwlab-cli case aggregate <caseId> --run-id <runId>` 输出单个 `aggregate.md`,并证明该文件包含运行环境、HWPOD、Code Agent、Prompt、低噪声 Trace、final response 和最后 diffTrace 中 message 直接展示,工具调用用 `已运行 <command>` summary 折叠。该命令不得启动新 CaseRun,也不得提交当前聚合 markdown 以外的并行 registry dirty 文件
11. Windows subject worktree 文本编辑必须能在 CRLF 文件中通过 `workspace.insert-after``workspace.replace` 完成一行源码修改,返回 before/after SHA 与 diff 摘要,并保留原文件 CRLF;`workspace.apply-patch` 在 context 不匹配时必须返回可诊断 payload,而不是只给 `context not found`
12. Keil `debug.download` 自动装配必须输出结构化 `cmd.run` argv 步骤;带空格的 `probeName` 在 plan 中必须是单独 argv 元素。`io.uart.read` 必须通过节点本地 `serial-monitor` 读取正在监控的 COM/baud;未监控或工具失败时必须返回包含 `ioProbe`/COM 口/baud/monitor status/启动建议的结构化 blocker details。
+7 -7
View File
@@ -93,7 +93,7 @@ devops-infra git mirror 仍是 PipelineRun 和 Argo CD 的集群内读写源。`
`v0.2` source branch 可以包含:
- 源码、测试、文档、人写配置和模板。
- `deploy/deploy.json` 或等价 lane 配置。
- `deploy/deploy.yaml` 或等价 lane 配置。
- k8s 模板、render 脚本、CI/CD helper 和 catalog schema。
`v0.2` source branch 不得跟踪:
@@ -447,7 +447,7 @@ kubectl get pod -n "$ns" -o name | grep -E "registry|hwlab-registry" || true
## Env 容器复用与三变量启动
`v0.2` code-only fast lane 的 runtime desired state 由三类输入组成:可复用 env image digest、自动推导的 code boot metadata、以及既有 service runtime config。开发者仍按当前 DEV/OPS 流程提交 `v0.2` source commit 和维护 `deploy/deploy.json`;发布入口不得要求人工填写 repo、commitId 或 boot script 路径。
`v0.2` code-only fast lane 的 runtime desired state 由三类输入组成:可复用 env image digest、自动推导的 code boot metadata、以及既有 service runtime config。开发者仍按当前 DEV/OPS 流程提交 `v0.2` source commit 和维护 `deploy/deploy.yaml`;发布入口不得要求人工填写 repo、commitId 或 boot script 路径。
code boot metadata 固定映射到三个启动环境变量:
@@ -455,7 +455,7 @@ code boot metadata 固定映射到三个启动环境变量:
| --- | --- | --- |
| `HWLAB_BOOT_REPO` | `v0.2` lane 的 canonical GitHub source repo 配置 | 必须是 canonical GitHub URL;用于身份记录,运行时读取由 resolver 自动分流到 mirror/cache。 |
| `HWLAB_BOOT_COMMIT` | UniDesk `trigger-current` 解析到的 `origin/v0.2` 完整 source commit SHA,也就是 PipelineRun revision | 必须是完整 40 位 commit SHA;禁止 branch、tag、`latest` 或人工覆盖。 |
| `HWLAB_BOOT_SH` | service model 或 `deploy/deploy.json` 中 serviceId 到 boot script 的映射,默认形态为 `deploy/runtime/boot/<serviceId>.sh` | 必须是 repo 内相对路径;禁止绝对路径、`..` 越界和从 env image 中隐式寻找旧脚本。 |
| `HWLAB_BOOT_SH` | service model 或 `deploy/deploy.yaml` 中 serviceId 到 boot script 的映射,默认形态为 `deploy/runtime/boot/<serviceId>.sh` | 必须是 repo 内相对路径;禁止绝对路径、`..` 越界和从 env image 中隐式寻找旧脚本。 |
CI/CD 必须把三变量同时写入 `deploy/artifact-catalog.v02.json`、rendered workload Pod template env/annotation 和 runtime health identity。三变量是由 lane 自动推导的发布事实,不是人工 OPS 参数;如果自动推导缺失或无法证明 `HWLAB_BOOT_COMMIT` 属于 `v0.2` 允许 ancestry,本轮 promotion 必须失败。
@@ -482,7 +482,7 @@ registry 与 git mirror/relay 分属不同基础设施边界。registry 保持
- runtime manifest 必须使用 digest pin 作为部署身份。
- catalog 必须记录 lane/profile、source branch、GitOps branch、source commitId、serviceId、image tag、digest、component identity 和 publish/reuse 状态;启用 env 容器复用时还必须记录 `runtimeMode=env-reuse-git-mirror-checkout``environmentImage``environmentDigest``environmentInputHash``bootRepo``bootCommit``bootSh``codeInputHash` 和三变量写入证据。
- 同一 source commit 对同一 service 应生成同一镜像;lane 差异放在 manifest、env、SecretRef、namespace、FRP 和 DB 配置中,不 bake 进镜像。
- `deploy/deploy.json` 只承载人写 runtime intent,不承载 digest、publish state 或 reuse evidence。
- `deploy/deploy.yaml` 只承载人写 runtime intent,不承载 digest、publish state 或 reuse evidence。
## Kubernetes 与 Argo 边界
@@ -513,7 +513,7 @@ registry 与 git mirror/relay 分属不同基础设施边界。registry 保持
- 自动 CD、手动 trigger 和 once 补偿都必须复用 UniDesk `trigger-current`、定点 `status``git-mirror flush`;不得绕到裸 `kubectl`、Argo、Tekton 或手写 GitHub API。
- 旧 DEV/D601/main gate、fallback、legacy mode 和双路径兼容不得进入 `v0.2` 调用链。
- env 容器复用 fast lane 中,`HWLAB_BOOT_REPO``HWLAB_BOOT_COMMIT``HWLAB_BOOT_SH` 必须由 CI/CD 自动推导并写入 GitOps desired state,不得作为人工发布参数或 runtime 临时 patch。
- `deploy/deploy.json` 只保存人写运行意图,不得被 auto-CD、promotion 或 flush 回写 `commitId`、service `image``HWLAB_COMMIT_ID``HWLAB_IMAGE``HWLAB_IMAGE_TAG` 等发布产物字段。
- `deploy/deploy.yaml` 只保存人写运行意图,不得被 auto-CD、promotion 或 flush 回写 `commitId`、service `image``HWLAB_COMMIT_ID``HWLAB_IMAGE``HWLAB_IMAGE_TAG` 等发布产物字段。
- git mirror/relay 必须来自独立 `devops-infra` 服务;`hwlab-v02` runtime namespace 不部署 mirror、不持有 GitHub deploy key、不在 mirror miss 时直连 GitHub fallback。
- GitOps promotion 必须写入 `devops-infra` 本地 mirror/relay 的 `v0.2-gitops`;除 mirror/relay flush 外,不得在 CI 关键路径直接 push GitHub canonical remote。
- mirror/relay write 必须只允许 allowlist refs,拒绝 non-fast-forward,拒绝越界 changed paths,并在 receive 成功前完成 object closure 校验。
@@ -556,7 +556,7 @@ registry 与 git mirror/relay 分属不同基础设施边界。registry 保持
- `http://74.48.78.17:19667/health/live` 返回 `v0.2` runtime healthpayload 中的 namespace、revision 或 runtime identity 能与 `hwlab-v02`/`v0.2` 对齐。
- `trigger-current` 的异步 job 或 `--wait` 输出必须能显示 trigger 阶段进度;若 control-plane refresh、mirror pre-sync 或 delete/create PipelineRun 卡住,日志必须能定位阶段名和状态。
- 自动 CD 回写 PR 或关联 issue 的 rollout 评论必须包含 source commit、PipelineRun、GitOps revision、planArtifacts build/reuse 摘要、Argo/runtime/public probe 状态和耗时;该评论不能替代需要用户原入口验收的问题关闭证据。
- 自动 CD 过程中 source branch 的 `deploy/deploy.json` 不得出现 `commitId`、service `image``HWLAB_COMMIT_ID``HWLAB_IMAGE``HWLAB_IMAGE_TAG` 回写。
- 自动 CD 过程中 source branch 的 `deploy/deploy.yaml` 不得出现 `commitId`、service `image``HWLAB_COMMIT_ID``HWLAB_IMAGE``HWLAB_IMAGE_TAG` 回写。
- no-op env-reuse fast lane 必须输出完整证据链:`g14-ci-plan` 全复用、`skipped-runtime-unchanged``gitops-commit`/`gitops-push` skipped、`runtime-ready` skipped。
不能只用 PipelineRun `Completed` 或总耗时证明没有退化。
- 真实 rollout 的 `runtime-ready` 必须输出 `started`、周期性 `progress` 和最终 `argo-refresh`/`argo-sync-health`/`workload-ready` 类事件;缺少这些事件时应按可见性回归处理。
@@ -604,7 +604,7 @@ GitOps branch 已更新、source branch render 通过、PipelineRun 名称存在
## T10
阅读 docs/reference/spec-v02-cicd.md,然后用 cli 手动测试以下内容:用一个低风险 base=`v0.2` PR 验证 UniDesk v02 PR monitor 的端到端链路,确认 merge 后自动触发对应 merge head 的 `hwlab-v02-ci-poll-<short12>`,定点 status 通过,`pendingFlush=true` 时自动 flushPR 或关联 issue 评论包含 source commit、PipelineRun、GitOps revision、build/reuse 摘要、Argo/runtime/public probe 状态和耗时,且 `deploy/deploy.json` 没有发布产物回写。
阅读 docs/reference/spec-v02-cicd.md,然后用 cli 手动测试以下内容:用一个低风险 base=`v0.2` PR 验证 UniDesk v02 PR monitor 的端到端链路,确认 merge 后自动触发对应 merge head 的 `hwlab-v02-ci-poll-<short12>`,定点 status 通过,`pendingFlush=true` 时自动 flushPR 或关联 issue 评论包含 source commit、PipelineRun、GitOps revision、build/reuse 摘要、Argo/runtime/public probe 状态和耗时,且 `deploy/deploy.yaml` 没有发布产物回写。
## 规格的实现情况
+1 -1
View File
@@ -121,7 +121,7 @@ hwlab-agent-worker
## T1
阅读 docs/reference/spec-v02-services.md,然后用 CLI 手动测试以下内容:列出 `deploy/deploy.json``deploy/deploy.schema.json``deploy/artifact-catalog.dev.json``deploy/gitops/g14/runtime-v02`,确认 runtime service set 只包含 `hwlab-cloud-api``hwlab-cloud-web``hwlab-gateway``hwlab-edge-proxy``hwlab-agent-skills`
阅读 docs/reference/spec-v02-services.md,然后用 CLI 手动测试以下内容:列出 `deploy/deploy.yaml``deploy/deploy.schema.json``deploy/artifact-catalog.dev.json``deploy/gitops/g14/runtime-v02`,确认 runtime service set 只包含 `hwlab-cloud-api``hwlab-cloud-web``hwlab-gateway``hwlab-edge-proxy``hwlab-agent-skills`
## T2
+22 -4
View File
@@ -288,7 +288,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI
});
let runnerJob = null;
try {
const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities, backendProfile });
const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities, backendProfile, params });
runnerJob = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(reusable.mapping.runId)}/runner-jobs`, {
method: "POST",
body: runnerJobInput,
@@ -386,7 +386,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI
message: "AgentRun command " + commandId + " created; hwlab-cloud-api will start a runner Job explicitly without relying on scheduler automation.",
runId, commandId, backendProfile, waitingFor: "agentrun-runner-job-create", valuesPrinted: false,
});
const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities, backendProfile });
const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities, backendProfile, params });
try {
runnerJob = await agentRunJson(fetchImpl, managerUrl, "/api/v1/runs/" + encodeURIComponent(runId) + "/runner-jobs", { method: "POST", body: runnerJobInput, timeoutMs });
} catch (error) {
@@ -856,6 +856,14 @@ function rejectRemovedResourceWorkspaceFiles(value) {
throw adapterError("legacy_workspace_files_removed", "workspaceFiles/resourceWorkspaceFiles are removed; use AgentRun ResourceBundleRef kind=gitbundle with bundles[]");
}
function adapterError(code, message, details = {}) {
return Object.assign(new Error(message), {
code,
statusCode: 400,
details
});
}
function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }) {
const prompt = String(params.message ?? params.prompt ?? "").trim();
const threadId = safeOpaqueId(params.threadId);
@@ -911,8 +919,8 @@ async function resolveOwnerApiKey({ params, options, now }) {
if (!key) return "";
return text(key.displaySecret ?? "");
}
function buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities = null, backendProfile }) {
const baseTransient = buildAgentRunTransientEnv(env, { providerProfile: backendProfile, parentTraceId: traceId });
function buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities = null, backendProfile, params = {} }) {
const baseTransient = buildAgentRunTransientEnv(env, { providerProfile: backendProfile, parentTraceId: traceId, hwpodSpecContent: params.hwpodSpecContent });
const hwpodAllowed = toolCapabilityAllowed(toolCapabilities, "hwpod");
const transientEnv = ownerApiKey && hwpodAllowed
? baseTransient.concat([{ name: "HWLAB_API_KEY", value: ownerApiKey, sensitive: true }])
@@ -934,6 +942,7 @@ function buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, too
function buildAgentRunTransientEnv(env = process.env, options = {}) {
const providerProfile = firstNonEmpty(options.providerProfile);
const parentTraceId = firstNonEmpty(options.parentTraceId);
const hwpodSpecContent = hwpodSpecContentForEnv(options.hwpodSpecContent);
const entries = [];
for (const name of [
"HWLAB_RUNTIME_API_URL",
@@ -949,9 +958,18 @@ function buildAgentRunTransientEnv(env = process.env, options = {}) {
}
if (providerProfile) entries.push({ name: "HWLAB_CODE_AGENT_PROVIDER_PROFILE", value: providerProfile, sensitive: false });
if (parentTraceId) entries.push({ name: "HWLAB_CODE_AGENT_PARENT_TRACE_ID", value: parentTraceId, sensitive: false });
if (hwpodSpecContent) entries.push({ name: "HWPOD_SPEC_CONTENT", value: `base64:${Buffer.from(hwpodSpecContent, "utf8").toString("base64")}`, sensitive: false });
return entries;
}
function hwpodSpecContentForEnv(value) {
const content = typeof value === "string" ? value.trim() : "";
if (!content) return "";
if (Buffer.byteLength(content, "utf8") > 64 * 1024) throw adapterError("hwpod_spec_too_large", "hwpodSpecContent must be 64 KiB or smaller");
if (!/^apiVersion:/mu.test(content) || !/\nkind:\s*Hwpod\b/mu.test(content)) throw adapterError("invalid_hwpod_spec_content", "hwpodSpecContent must be a Hwpod YAML document");
return content;
}
function agentRunToolCredentials(env = process.env, toolCapabilities = null) {
const namespace = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_TOOL_SECRET_NAMESPACE, env.HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE, DEFAULT_RUNNER_NAMESPACE);
const githubSecretName = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME, "agentrun-v01-tool-github-pr");
-11
View File
@@ -369,17 +369,6 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/runner-jobs") {
const secondRunnerJob = body.commandId === "cmd_hwlab_adapter_second";
assert.ok(body.commandId === "cmd_hwlab_adapter" || secondRunnerJob);
assert.deepEqual(body.transientEnv.map((entry) => entry.name), [
"HWLAB_RUNTIME_API_URL",
"HWLAB_RUNTIME_WEB_URL",
"HWLAB_RUNTIME_NAMESPACE",
"HWLAB_RUNTIME_LANE",
"HWLAB_RUNTIME_ENDPOINT_LOCKED",
"HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME",
"UNIDESK_MAIN_SERVER_IP",
"HWLAB_CODE_AGENT_PROVIDER_PROFILE",
"HWLAB_CODE_AGENT_PARENT_TRACE_ID"
]);
const transientEnv = Object.fromEntries(body.transientEnv.map((entry) => [entry.name, entry.value]));
assert.equal(transientEnv.HWLAB_RUNTIME_API_URL, "http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667");
assert.equal(transientEnv.HWLAB_RUNTIME_WEB_URL, "http://hwlab-cloud-web.hwlab-v02.svc.cluster.local:8080");
+16 -1
View File
@@ -11,7 +11,8 @@
"@openai/codex": "^0.128.0",
"fzstd": "0.1.1",
"pg": "^8.21.0",
"playwright": "1.59.1"
"playwright": "1.59.1",
"yaml": "^2.8.3"
}
},
"node_modules/@openai/codex": {
@@ -330,6 +331,20 @@
"engines": {
"node": ">=0.4"
}
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
+2 -1
View File
@@ -45,6 +45,7 @@
"@openai/codex": "^0.128.0",
"fzstd": "0.1.1",
"pg": "^8.21.0",
"playwright": "1.59.1"
"playwright": "1.59.1",
"yaml": "^2.8.3"
}
}
+112 -41
View File
@@ -22,6 +22,7 @@ import {
} from "./src/dev-artifact-services.mjs";
import { probeRegistryCapabilities } from "./src/registry-capabilities.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs";
import { readStructuredFile } from "./src/structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const cliEntrypoint = process.env.HWLAB_ARTIFACT_PUBLISH_ENTRYPOINT || "scripts/artifact-publish.mjs";
@@ -35,7 +36,7 @@ const v02EnvReuseRuntimeMode = "env-reuse-git-mirror-checkout";
const defaultBuildkitCommand = process.env.HWLAB_BUILDKIT_BUILDCTL || "buildctl-daemonless.sh";
const defaultBuildkitAddr = process.env.HWLAB_BUILDKIT_ADDR || null;
const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json";
const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.json";
const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.yaml";
const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]);
const shaDigestPattern = /^sha256:[a-f0-9]{64}$/u;
@@ -66,18 +67,6 @@ const servicePorts = new Map([
["hwlab-agent-skills", 7430]
]);
const v02RuntimeServiceIds = Object.freeze([
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-gateway",
"hwlab-edge-proxy",
"hwlab-agent-skills"
]);
function serviceIdsForLane(lane) {
return lane === "v02" ? v02RuntimeServiceIds : SERVICE_IDS;
}
function parseArgs(argv) {
const args = {
mode: "preflight",
@@ -109,7 +98,7 @@ function parseArgs(argv) {
else if (arg === "--publish") args.mode = "publish";
else if (arg === "--lane") args.lane = readOption(argv, ++index, arg);
else if (arg === "--catalog-path") args.catalogPath = readOption(argv, ++index, arg);
else if (arg === "--deploy-json") args.deployPath = readOption(argv, ++index, arg);
else if (arg === "--deploy-config") args.deployPath = readOption(argv, ++index, arg);
else if (arg === "--image-tag-mode") args.imageTagMode = readOption(argv, ++index, arg);
else if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg);
else if (arg === "--base-image") args.baseImage = readOption(argv, ++index, arg);
@@ -133,7 +122,7 @@ function parseArgs(argv) {
assert.ok(["g14", "v02"].includes(args.lane), `unknown artifact lane ${args.lane}`);
assert.ok(["short", "full"].includes(args.imageTagMode), `unknown image tag mode ${args.imageTagMode}`);
if (!args.servicesExplicit) args.services = [...serviceIdsForLane(args.lane)];
if (!args.servicesExplicit && args.lane !== "v02") args.services = [...SERVICE_IDS];
return args;
}
@@ -210,11 +199,11 @@ function printHelp() {
"options:",
" --lane LANE g14 or v02; default: g14",
` --catalog-path PATH default: ${defaultCatalogPath}`,
` --deploy-json PATH default: ${defaultDeployPath}`,
` --deploy-config PATH default: ${defaultDeployPath}`,
" --image-tag-mode MODE short or full; v02 uses full source commit IDs",
" --registry-prefix PREFIX default: 127.0.0.1:5000/hwlab",
" --base-image IMAGE override HWLAB_DEV_BASE_IMAGE for this run; must already be local",
" --services LIST comma-separated service IDs; default: all frozen DEV services",
" --services LIST comma-separated service IDs; v02 default comes from deploy.lanes.v02.envReuseServices",
" --affected-only default and only supported scope: build/publish affected services and reuse unchanged catalog digests",
" --buildkit-command PATH buildctl-daemonless.sh/buildctl command for buildkit backend",
" --buildkit-addr ADDR optional buildctl --addr endpoint for a sidecar BuildKit daemon",
@@ -228,13 +217,13 @@ function printHelp() {
].join("\n"));
}
async function readJson(relativePath) {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
async function readConfig(relativePath) {
return readStructuredFile(repoRoot, relativePath);
}
async function readJsonIfPresent(relativePath, fallback = null) {
async function readConfigIfPresent(relativePath, fallback = null) {
try {
return await readJson(relativePath);
return await readConfig(relativePath);
} catch (error) {
if (error?.code === "ENOENT") return fallback;
throw error;
@@ -593,6 +582,11 @@ function assertV02Contracts(catalog, deployManifest) {
assert.ok(lane && typeof lane === "object", "deploy.lanes.v02 must exist for v02 artifact publish");
assert.equal(lane.namespace, "hwlab-v02", "deploy.lanes.v02.namespace must be hwlab-v02");
assert.ok(isHttpEndpoint(lane.endpoint), "deploy.lanes.v02.endpoint must be a non-empty http(s) URL");
const declaredServiceIds = new Set(v02ServiceIdsFromDeclarations(deployManifest));
assert.ok(
v02ServiceIdsFromDeploy(deployManifest).every((serviceId) => declaredServiceIds.has(serviceId)),
"deploy.lanes.v02.serviceDeclarations must cover envReuseServices"
);
assert.equal(catalog.environment, "v02", "catalog.environment must be v02");
assert.equal(catalog.profile, "v02", "catalog.profile must be v02");
assert.equal(catalog.namespace, "hwlab-v02", "catalog.namespace must be hwlab-v02");
@@ -600,7 +594,7 @@ function assertV02Contracts(catalog, deployManifest) {
assert.deepEqual(catalog.allowedProfiles, ["v02"], "catalog.allowedProfiles must only allow v02");
assert.deepEqual(catalog.forbiddenProfiles, ["dev", "prod"], "catalog.forbiddenProfiles must forbid dev/prod");
const catalogIds = (catalog.services ?? []).map((service) => service.serviceId);
assert.deepEqual(catalogIds, serviceIdsForLane("v02"), "v02 catalog services must match retained service IDs");
assert.deepEqual(catalogIds, v02ServiceIdsFromDeploy(deployManifest), "v02 catalog services must match deploy.lanes.v02.envReuseServices");
for (const service of catalog.services ?? []) {
assert.equal(service.profile, "v02", `${service.serviceId}.profile must be v02`);
assert.equal(service.namespace, "hwlab-v02", `${service.serviceId}.namespace must be hwlab-v02`);
@@ -642,7 +636,7 @@ function artifactCatalogSkeleton({ args, deployManifest, commitId }) {
},
allowedProfiles: [artifactEnvironment(args)],
forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"],
services: serviceIdsForLane(args.lane).map((serviceId) => artifactCatalogSkeletonService({ args, deployManifest, serviceId, tag, commitId }))
services: serviceIdsForArtifactLane(args, deployManifest).map((serviceId) => artifactCatalogSkeletonService({ args, deployManifest, serviceId, tag, commitId }))
};
}
@@ -688,20 +682,55 @@ function artifactCatalogSkeletonService({ args, deployManifest, serviceId, tag,
function envReuseServiceIdsForLane(deployManifest, lane) {
if (lane !== "v02") return new Set();
const configured = deployManifest?.lanes?.v02?.envReuseServices;
const allowed = new Set(serviceIdsForLane(lane));
return new Set((Array.isArray(configured) ? configured : []).filter((serviceId) => allowed.has(serviceId)));
return new Set(normalizeServiceIdList(configured));
}
function serviceIdsForArtifactLane(args, deployManifest) {
if (args.lane !== "v02") return [...SERVICE_IDS];
return v02ServiceIdsFromDeploy(deployManifest);
}
function applyDeployDefaultServices(args, deployManifest) {
if (args.servicesExplicit) return;
args.services = serviceIdsForArtifactLane(args, deployManifest);
}
function v02ServiceIdsFromDeploy(deployManifest) {
const serviceIds = normalizeServiceIdList(deployManifest?.lanes?.v02?.envReuseServices);
assert.ok(serviceIds.length > 0, "deploy.lanes.v02.envReuseServices must not be empty");
return serviceIds;
}
function v02ServiceIdsFromDeclarations(deployManifest) {
return normalizeServiceIdList(Object.keys(deployManifest?.lanes?.v02?.serviceDeclarations ?? {}));
}
function normalizeServiceIdList(value) {
return uniquePreserveOrder((Array.isArray(value) ? value : []).map((item) => String(item ?? "").trim()).filter(Boolean));
}
function uniquePreserveOrder(values) {
const seen = new Set();
const result = [];
for (const value of values) {
if (seen.has(value)) continue;
seen.add(value);
result.push(value);
}
return result;
}
function bootShForService(deployManifest, serviceId) {
const value = deployManifest?.lanes?.v02?.bootScripts?.[serviceId] || `deploy/runtime/boot/${serviceId}.sh`;
const value = deployManifest?.lanes?.v02?.bootScripts?.[serviceId];
assert.ok(value, `deploy.lanes.v02.bootScripts.${serviceId} is required`);
const text = String(value).replaceAll("\\", "/").replace(/^\.\//u, "");
assert.ok(text && !text.startsWith("/") && !text.split("/").includes(".."), `${serviceId} boot script must be repo-relative`);
return text;
}
function normalizeCatalogForLane(catalog, lane) {
function normalizeCatalogForLane(catalog, lane, deployManifest) {
if (!catalog || lane !== "v02") return catalog;
const allowed = new Set(serviceIdsForLane(lane));
const allowed = new Set(v02ServiceIdsFromDeploy(deployManifest));
return {
...catalog,
services: (catalog.services ?? []).filter((service) => allowed.has(service.serviceId))
@@ -826,28 +855,69 @@ function dockerfile(baseImage, port, labels = []) {
].join("\n");
}
function envReuseDockerfile(baseImage, labels = []) {
const osPackageScript = "set -eu; apt-get update; apt-get install -y --no-install-recommends ca-certificates git; rm -rf /var/lib/apt/lists/*";
const dependencyScript = "set -eu; if [ -f package-lock.json ]; then npm ci --omit=dev --include=optional --ignore-scripts; else npm install --omit=dev --include=optional --ignore-scripts; fi; if command -v bun >/dev/null 2>&1; then bun_path=\"$(command -v bun)\"; if [ \"$bun_path\" != \"/usr/local/bin/bun\" ]; then ln -sf \"$bun_path\" /usr/local/bin/bun; fi; fi; if [ ! -x /usr/local/bin/bun ]; then bun_pkg=\"\"; case \"$(uname -m)\" in x86_64|amd64) bun_pkg=\"@oven/bun-linux-x64@1.3.13\" ;; aarch64|arm64) bun_pkg=\"@oven/bun-linux-aarch64@1.3.13\" ;; *) echo \"unsupported bun architecture: $(uname -m)\" >&2; false ;; esac; npm install --no-save --omit=dev --include=optional --ignore-scripts \"$bun_pkg\"; bun_bin=\"node_modules/${bun_pkg%@*}/bin/bun\"; test -x \"$bun_bin\"; ln -sf \"/opt/hwlab-env/$bun_bin\" /usr/local/bin/bun; fi";
const readinessScript = "set -eu; command -v node >/dev/null; command -v npm >/dev/null; command -v git >/dev/null; command -v sh >/dev/null; test -d /opt/hwlab-env/node_modules; test -x /usr/local/bin/bun; /usr/local/bin/bun --version >/tmp/hwlab-env-bun-version.txt; printf '%s\\n' '#!/usr/bin/env sh' 'exec node /workspace/hwlab-boot/repo/scripts/run-bun.mjs /workspace/hwlab-boot/repo/tools/hwpod-cli.ts \"$@\"' > /usr/local/bin/hwpod; chmod 755 /usr/local/bin/hwpod; printf '%s\\n' '#!/usr/bin/env sh' 'exec node /workspace/hwlab-boot/repo/scripts/run-bun.mjs /workspace/hwlab-boot/repo/tools/hwpod-ctl.ts \"$@\"' > /usr/local/bin/hwpod-ctl; chmod 755 /usr/local/bin/hwpod-ctl; printf '%s\\n' '#!/usr/bin/env sh' 'exec node /workspace/hwlab-boot/repo/scripts/run-bun.mjs /workspace/hwlab-boot/repo/tools/hwpod-compiler-cli.ts \"$@\"' > /usr/local/bin/hwpod-compiler; chmod 755 /usr/local/bin/hwpod-compiler";
function envReuseDockerfile(baseImage, labels = [], recipe) {
const normalizedRecipe = normalizeEnvReuseRecipe(recipe);
const osPackageScript = `set -eu; apt-get update; apt-get install -y --no-install-recommends ${normalizedRecipe.osPackages.map(shellQuote).join(" ")}; rm -rf /var/lib/apt/lists/*`;
const dependencyScript = `set -eu; if [ -f package-lock.json ]; then npm ci --omit=dev --include=optional --ignore-scripts; else npm install --omit=dev --include=optional --ignore-scripts; fi; if command -v bun >/dev/null 2>&1; then bun_path=\"$(command -v bun)\"; if [ \"$bun_path\" != \"/usr/local/bin/bun\" ]; then ln -sf \"$bun_path\" /usr/local/bin/bun; fi; fi; if [ ! -x /usr/local/bin/bun ]; then bun_pkg=\"\"; case \"$(uname -m)\" in x86_64|amd64) bun_pkg=\"@oven/bun-linux-x64@${normalizedRecipe.bunVersion}\" ;; aarch64|arm64) bun_pkg=\"@oven/bun-linux-aarch64@${normalizedRecipe.bunVersion}\" ;; *) echo \"unsupported bun architecture: $(uname -m)\" >&2; false ;; esac; npm install --no-save --omit=dev --include=optional --ignore-scripts \"$bun_pkg\"; bun_bin=\"node_modules/\${bun_pkg%@*}/bin/bun\"; test -x \"$bun_bin\"; ln -sf \"${normalizedRecipe.runtimeNodeModulesPath}/$bun_bin\" /usr/local/bin/bun; fi`;
const aliasScript = normalizedRecipe.hwpodAliases.map((alias) => `printf '%s\\n' '#!/usr/bin/env sh' 'exec node /workspace/hwlab-boot/repo/scripts/run-bun.mjs /workspace/hwlab-boot/repo/${alias.script} \"$@\"' > /usr/local/bin/${alias.command}; chmod 755 /usr/local/bin/${alias.command}`).join("; ");
const readinessScript = `set -eu; command -v node >/dev/null; command -v npm >/dev/null; command -v git >/dev/null; command -v sh >/dev/null; test -d ${shellQuote(normalizedRecipe.runtimeNodeModulesPath)}; test -x /usr/local/bin/bun; /usr/local/bin/bun --version >/tmp/hwlab-env-bun-version.txt; ${aliasScript}`;
return [
`FROM ${baseImage}`,
"WORKDIR /opt/hwlab-env",
`WORKDIR ${normalizedRecipe.runtimeNodeModulesPath.replace(/\/node_modules$/u, "")}`,
...labels.map(([name, value]) => `LABEL ${name}=${dockerfileQuote(value)}`),
"COPY package.json ./package.json",
"COPY package-lock.json* ./",
`RUN ${osPackageScript}`,
`RUN ${dependencyScript}`,
"COPY deploy/runtime/launcher/hwlab-env-reuse-launcher.ts /usr/local/bin/hwlab-env-reuse-launcher.ts",
`COPY ${normalizedRecipe.launcherPath} /usr/local/bin/hwlab-env-reuse-launcher.ts`,
`RUN ${readinessScript}`,
"ENV HWLAB_RUNTIME_MODE=env-reuse-git-mirror-checkout",
"ENV HWLAB_RUNTIME_NODE_MODULES=/opt/hwlab-env/node_modules",
`ENV HWLAB_RUNTIME_NODE_MODULES=${normalizedRecipe.runtimeNodeModulesPath}`,
"ENV PATH=/usr/local/bin:$PATH",
"CMD [\"/usr/local/bin/bun\", \"/usr/local/bin/hwlab-env-reuse-launcher.ts\"]",
""
].join("\n");
}
function normalizeEnvReuseRecipe(recipe) {
assert.ok(recipe && typeof recipe === "object", "envRecipe must be provided by deploy.lanes.v02.envRecipe");
const normalized = {
osPackages: normalizeStringList(recipe.osPackages),
bunVersion: String(recipe.bunVersion ?? "").trim(),
launcherPath: normalizeRepoPath(recipe.launcherPath),
runtimeNodeModulesPath: normalizeAbsolutePath(recipe.runtimeNodeModulesPath),
hwpodAliases: normalizeHwpodAliases(recipe.hwpodAliases)
};
assert.ok(normalized.osPackages.length > 0, "envRecipe.osPackages must not be empty");
assert.match(normalized.bunVersion, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u, "envRecipe.bunVersion must be a semver-like version");
assert.ok(normalized.launcherPath && !normalized.launcherPath.split("/").includes(".."), "envRecipe.launcherPath must be repo-relative");
assert.ok(normalized.runtimeNodeModulesPath, "envRecipe.runtimeNodeModulesPath must be absolute");
assert.ok(normalized.hwpodAliases.length > 0, "envRecipe.hwpodAliases must not be empty");
return normalized;
}
function normalizeStringList(value) {
const source = Array.isArray(value) ? value : [];
return source.map((item) => String(item ?? "").trim()).filter(Boolean);
}
function normalizeRepoPath(value) {
return String(value ?? "").replaceAll("\\", "/").replace(/^\.\//u, "").replace(/^\/+/, "").trim();
}
function normalizeAbsolutePath(value) {
const text = String(value ?? "").trim();
return text.startsWith("/") && !text.includes("..") ? text : null;
}
function normalizeHwpodAliases(value) {
const source = Array.isArray(value) ? value : [];
return source.map((item) => ({
command: String(item?.command ?? "").trim(),
script: normalizeRepoPath(item?.script)
})).filter((item) => item.command && item.script);
}
function imageRef(registryPrefix, serviceId, tag) {
return `${registryPrefix}/${serviceId}:${tag}`;
}
@@ -1071,7 +1141,7 @@ async function buildEnvReuseServiceWithBuildkit({ args, service, ref, buildCreat
const buildkitAddrArgs = args.buildkitAddr ? ["--addr", args.buildkitAddr] : [];
const startedAt = Date.now();
try {
await writeFile(dockerfilePath, envReuseDockerfile(args.baseImage, labels));
await writeFile(dockerfilePath, envReuseDockerfile(args.baseImage, labels, service.envReuseRecipe));
const argsList = [
...buildkitAddrArgs,
"build",
@@ -2076,14 +2146,15 @@ async function main() {
args.baseImage = baseImagePreflight.localTag;
}
const [deployManifest, commitId, remoteUrl] = await Promise.all([
readJson(args.deployPath),
readConfig(args.deployPath),
gitValue(["rev-parse", "HEAD"]),
gitValue(["remote", "get-url", "origin"]).catch(() => "git@github.com:pikasTech/HWLAB.git")
]);
let catalog = await readJsonIfPresent(args.catalogPath, null);
applyDeployDefaultServices(args, deployManifest);
let catalog = await readConfigIfPresent(args.catalogPath, null);
const catalogWasMissing = !catalog;
if (!catalog && args.lane === "v02") catalog = artifactCatalogSkeleton({ args, deployManifest, commitId });
catalog = normalizeCatalogForLane(catalog, args.lane);
catalog = normalizeCatalogForLane(catalog, args.lane, deployManifest);
assert.ok(catalog, `${args.catalogPath} is required for ${args.lane} artifact publish`);
const shortCommit = imageTagForCommit(args, commitId);
let effectiveCatalogPath = args.catalogPath;
@@ -2098,11 +2169,11 @@ async function main() {
repoRoot,
lane: args.lane,
targetRef: "HEAD",
deployJsonPath: args.deployPath,
deployConfigPath: args.deployPath,
artifactCatalogPath: effectiveCatalogPath,
registryPrefix: args.registryPrefix,
baseImage: args.baseImage ?? undefined,
services: args.services
...(args.servicesExplicit ? { services: args.services } : {})
});
const services = enrichServicesWithCiPlan(await resolveServices(args.services, catalog), ciPlan);
const catalogByServiceId = new Map((catalog?.services ?? []).map((service) => [service.serviceId, service]));
+6 -5
View File
@@ -7,6 +7,7 @@ import test from "node:test";
import { promisify } from "node:util";
import { buildDesiredStatePlan } from "./src/deploy-desired-state-plan.mjs";
import { writeStructuredFile } from "./src/structured-config.mjs";
const execFileAsync = promisify(execFile);
@@ -316,7 +317,7 @@ async function makeFixture({
}
]
};
await writeFile(path.join(root, "deploy/deploy.json"), `${JSON.stringify(deploy, null, 2)}\n`);
await writeStructuredFile(root, "deploy/deploy.yaml", deploy);
await writeFile(path.join(root, "deploy/artifact-catalog.dev.json"), `${JSON.stringify(catalog, null, 2)}\n`);
await writeFile(path.join(root, "deploy/k8s/base/workloads.yaml"), `${JSON.stringify(workloads, null, 2)}\n`);
return root;
@@ -463,7 +464,7 @@ test("blocks when cloud-api DB SSL mode drifts back to require", async () => {
assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "cloud_api_db_contract_mismatch"));
});
test("blocks when generated runtime identity leaks into deploy.json", async () => {
test("blocks when generated runtime identity leaks into deploy.yaml", async () => {
const repoRoot = await makeFixture({
serviceId: "hwlab-cloud-web",
deployEnvMirrors: true,
@@ -474,7 +475,7 @@ test("blocks when generated runtime identity leaks into deploy.json", async () =
assert.equal(plan.summary.blockers, 3);
assert.ok(plan.diagnostics.some((diagnostic) =>
diagnostic.code === "generated_artifact_identity" &&
diagnostic.path === "deploy/deploy.json.services.hwlab-cloud-web.env.HWLAB_COMMIT_ID"
diagnostic.path === "deploy/deploy.yaml.services.hwlab-cloud-web.env.HWLAB_COMMIT_ID"
));
assert.ok(plan.diagnostics.some((diagnostic) =>
diagnostic.code === "generated_artifact_identity" &&
@@ -541,7 +542,7 @@ test("target check accepts accepted first-parent main on the checked-out merge c
await git(repoRoot, ["checkout", "-b", "desired-state-refresh"]);
for (const relativePath of [
"deploy/deploy.json",
"deploy/deploy.yaml",
"deploy/artifact-catalog.dev.json",
"deploy/k8s/base/workloads.yaml"
]) {
@@ -600,7 +601,7 @@ test("ignores generated workload mirror drift because render owns runtime identi
assert.deepEqual(plan.diagnostics, []);
});
test("blocks on skills commit mirror leak in deploy.json", async () => {
test("blocks on skills commit mirror leak in deploy.yaml", async () => {
const repoRoot = await makeFixture({ deployEnvMirrors: true, deploySkillsCommitId: "badcafe" });
const plan = await buildDesiredStatePlan({ repoRoot });
assert.equal(plan.status, "blocked");
+5 -4
View File
@@ -12,6 +12,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs";
import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs";
import { readStructuredFile } from "./src/structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultReportPath = tempReportPath("dev-m3-hardware-loop.json");
@@ -382,9 +383,9 @@ function serviceByName(services) {
async function collectReadOnlySupplementalEvidence() {
const [deploy, workloads, services] = await Promise.all([
readFile(path.join(repoRoot, "deploy/deploy.json"), "utf8").then(JSON.parse),
readFile(path.join(repoRoot, deployWorkloadsPath), "utf8").then(JSON.parse),
readFile(path.join(repoRoot, deployServicesPath), "utf8").then(JSON.parse)
readStructuredFile(repoRoot, "deploy/deploy.yaml"),
readStructuredFile(repoRoot, deployWorkloadsPath),
readStructuredFile(repoRoot, deployServicesPath)
]);
const targets = ["hwlab-box-simu", "hwlab-gateway-simu", "hwlab-patch-panel"];
const observed = listItems(workloads)
@@ -468,7 +469,7 @@ async function collectReadOnlySupplementalEvidence() {
id: "deploy-skeleton-m3-cardinality",
status: manifestReadyForM3 ? "manifest-ready" : "gap",
blockerClass: manifestReadyForM3 ? null : "runtime_blocker",
source: ["deploy/deploy.json", deployWorkloadsPath, deployServicesPath],
source: ["deploy/deploy.yaml", deployWorkloadsPath, deployServicesPath],
summary: manifestReadyForM3
? "Checked-in DEV deploy skeleton declares distinct indexed M3 identities for two box simulators, two gateway simulators, one patch panel, and the M3 DO1 -> DI1 endpoint map."
: "Read-only static comparison found the checked-in DEV deploy skeleton does not declare distinct indexed M3 simulator identities and patch-panel M3 wiring; proving live DEV still requires a separate deploy/runtime task or authorized runtime observation.",
+4 -4
View File
@@ -17,7 +17,7 @@ function parseArgs(argv) {
const arg = argv[index];
if (arg === "--base-ref") parsed.baseRef = readOption(argv, ++index, arg);
else if (arg === "--target-ref") parsed.targetRef = readOption(argv, ++index, arg);
else if (arg === "--deploy-json") parsed.deployJsonPath = readOption(argv, ++index, arg);
else if (arg === "--deploy-config") parsed.deployConfigPath = readOption(argv, ++index, arg);
else if (arg === "--artifact-catalog") parsed.artifactCatalogPath = readOption(argv, ++index, arg);
else if (arg === "--lane") parsed.lane = readOption(argv, ++index, arg);
else if (arg === "--registry-prefix") parsed.registryPrefix = readOption(argv, ++index, arg);
@@ -38,13 +38,13 @@ function readOption(argv, index, name) {
function usage() {
return [
"usage: node scripts/g14-ci-plan.mjs [--base-ref REF] [--target-ref REF] [--pretty]",
"usage: node scripts/g14-ci-plan.mjs [--lane v02] [--base-ref REF] [--target-ref REF] [--pretty]",
"",
"Plan G14 monorepo image builds without mutating CI/CD state.",
"The planner reads current deploy/deploy.json and the built-in G14 CI component model by default.",
"For v02 env reuse, service component boundaries and env recipe must come from deploy/deploy.yaml.",
"",
"examples:",
" node scripts/g14-ci-plan.mjs --base-ref HEAD~1 --target-ref HEAD --pretty",
" node scripts/g14-ci-plan.mjs --lane v02 --base-ref HEAD~1 --target-ref HEAD --pretty",
" node scripts/g14-ci-plan.mjs --services hwlab-cloud-api,hwlab-cloud-web --pretty"
].join("\n");
}
+184 -39
View File
@@ -10,9 +10,11 @@ import {
classifyGlobalChange,
componentModelsForServices,
createG14CiPlan,
envReuseRecipeForLane,
matchingPaths,
packageRuntimeFieldsChanged
} from "./src/g14-ci-plan-lib.mjs";
import { readStructuredFile, writeStructuredFile } from "./src/structured-config.mjs";
const execFileAsync = promisify(execFile);
@@ -30,12 +32,12 @@ test("G14 CI planner maps bridge changes to cloud-api only", async () => {
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "change bridge"]);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]);
assert.equal(plan.reusedServices.includes("hwlab-cloud-web"), true);
const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
assert.equal(cloudApi.affected, true);
assert.deepEqual(cloudApi.reason, ["component-path-changed"]);
assert.deepEqual(cloudApi.reason, ["code-input-changed", "component-path-changed"]);
});
test("G14 CI planner treats docs-only changes as no image build", async () => {
@@ -45,7 +47,7 @@ test("G14 CI planner treats docs-only changes as no image build", async () => {
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "change docs"]);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, []);
assert.equal(plan.imageBuildRequired, false);
assert.equal(plan.changedPathSummary.docsOnly, true);
@@ -58,10 +60,11 @@ test("planner rebuilds when catalog digest is missing instead of blocking reuse"
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "change docs"]);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]);
assert.equal(plan.imageBuildRequired, true);
assert.equal(plan.services[0].reason.includes("catalog-digest-missing"), true);
assert.equal(plan.services.every((service) => service.envChanged === true), true);
assert.equal(plan.services.every((service) => service.reuse.status === "not-reused"), true);
});
test("v02 planner rolls cloud-api service code changes without rebuilding service image", async () => {
@@ -140,8 +143,9 @@ test("artifact reuse paths preserve catalog provenance instead of current planne
assert.doesNotMatch(gitopsRender, /"component-input-hash": planned\.componentInputHash \|\| service\.componentInputHash \|\| ""/u);
});
test("component model uses built-in service paths", () => {
const models = componentModelsForServices(["hwlab-cloud-api"]);
test("component model uses service paths declared in deploy json", () => {
const deploy = createDeployFixture();
const models = componentModelsForServices(["hwlab-cloud-api"], deploy.lanes.v02);
assert.deepEqual(models[0].componentPaths, [
"cmd/hwlab-cloud-api/",
"cmd/hwlab-codex-api-responses-forwarder/",
@@ -159,6 +163,50 @@ test("component model uses built-in service paths", () => {
assert.deepEqual(matchingPaths(["cmd/hwlab-cloud-api/main.ts"], models[0].componentPaths), ["cmd/hwlab-cloud-api/main.ts"]);
});
test("v02 planner rejects missing service declarations instead of falling back to an internal model", async () => {
const repo = await createFixtureRepo();
const deployPath = path.join(repo, "deploy/deploy.yaml");
const deploy = await readStructuredFile(repo, deployPath);
delete deploy.lanes.v02.serviceDeclarations;
await writeStructuredFile(repo, deployPath, deploy);
await git(repo, ["add", "deploy/deploy.yaml"]);
await git(repo, ["commit", "-m", "remove v02 declarations"]);
await assert.rejects(
() => createG14CiPlan({
repoRoot: repo,
lane: "v02",
baseRef: "HEAD~1",
targetRef: "HEAD",
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
services: ["hwlab-cloud-api"]
}),
/deploy\.lanes\.v02\.serviceDeclarations is required/u
);
});
test("v02 planner rejects missing boot script declarations instead of guessing paths", async () => {
const repo = await createFixtureRepo();
const deployPath = path.join(repo, "deploy/deploy.yaml");
const deploy = await readStructuredFile(repo, deployPath);
delete deploy.lanes.v02.bootScripts["hwlab-cloud-api"];
await writeStructuredFile(repo, deployPath, deploy);
await git(repo, ["add", "deploy/deploy.yaml"]);
await git(repo, ["commit", "-m", "remove v02 cloud-api boot script"]);
await assert.rejects(
() => createG14CiPlan({
repoRoot: repo,
lane: "v02",
baseRef: "HEAD~1",
targetRef: "HEAD",
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
services: ["hwlab-cloud-api"]
}),
/deploy\.lanes\.v02\.bootScripts\.hwlab-cloud-api is required/u
);
});
test("v02 planner keeps hwpod-spec changes out of runtime service env reuse", async () => {
const repo = await createFixtureRepo();
await writeFile(path.join(repo, ".hwlab/hwpod-spec.yaml"), "kind: Hwpod\nmetadata:\n name: changed-preinstalled\nspec:\n nodeBinding:\n nodeId: node-changed\n workspace:\n path: F:\\\\Work\\\\D601-HWLAB\n targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n", "utf8");
@@ -232,11 +280,11 @@ test("planner scopes cloud-web runtime changes to cloud-web", async () => {
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "change cloud web runtime"]);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-web"]);
assert.equal(plan.services.some((service) => service.serviceId === "hwlab-agent-worker"), false);
const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web");
assert.deepEqual(cloudWeb.reason, ["component-path-changed"]);
assert.deepEqual(cloudWeb.reason, ["code-input-changed", "component-path-changed"]);
});
test("v02 planner treats shared trace renderer changes as cloud-web code rollout inputs", async () => {
@@ -312,7 +360,7 @@ test("planner ignores package script cleanup for runtime images", async () => {
await git(repo, ["commit", "-m", "change package scripts"]);
assert.equal(await packageRuntimeFieldsChanged(repo, "HEAD~1", "HEAD"), false);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, []);
assert.deepEqual(plan.buildServices, []);
assert.equal(plan.imageBuildRequired, false);
@@ -340,24 +388,11 @@ test("planner treats dependency changes as runtime image inputs", async () => {
await git(repo, ["commit", "-m", "change dependencies"]);
assert.equal(await packageRuntimeFieldsChanged(repo, "HEAD~1", "HEAD"), true);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]);
assert.equal(plan.imageBuildRequired, true);
});
test("planner remains compatible with deploy.k3s.serviceMappings shape", async () => {
const repo = await createFixtureRepo({ deployServices: false, k3sServiceMappings: true });
await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge v2');\n");
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "change bridge"]);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
assert.equal(plan.compatibility.currentRenderCompatible, true);
assert.equal(plan.compatibility.serviceIdSource, "deploy.k3s.serviceMappings");
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]);
assert.equal(plan.inputs.serviceCount, 2);
});
test("global change classifier distinguishes gitops-only and test-only", () => {
assert.equal(classifyGlobalChange(["deploy/gitops/g14/runtime-dev/workloads.yaml"]).gitopsOnly, true);
assert.equal(classifyGlobalChange(["cmd/hwlab-cloud-api/runtime-options.test.ts"]).testOnly, true);
@@ -556,10 +591,27 @@ test("v02 planner scopes hwpod skill bundle changes to the skills service", asyn
assert.deepEqual(skillsService.reason, ["code-input-changed", "component-path-changed"]);
});
test("v02 planner reads env reuse declarations and recipe from deploy json", async () => {
const deploy = createDeployFixture();
deploy.lanes.v02.serviceDeclarations["hwlab-cloud-web"].componentPaths.push("custom/web-plugin/");
deploy.lanes.v02.envRecipe.bunVersion = "1.3.14";
deploy.lanes.v02.envRecipe.additionalEnvPaths.push("custom/env-tool.mjs");
const models = componentModelsForServices(["hwlab-cloud-web"], deploy.lanes.v02);
assert.equal(models[0].runtimeKind, "cloud-web");
assert.equal(models[0].entrypoint, "web/hwlab-cloud-web/index.html");
assert.equal(models[0].componentPaths.includes("custom/web-plugin/"), true);
assert.equal(models[0].buildSystemPaths.includes("custom/env-tool.mjs"), true);
const recipe = envReuseRecipeForLane(deploy, "v02");
assert.equal(recipe.bunVersion, "1.3.14");
assert.equal(recipe.launcherInputPaths.includes("deploy/runtime/launcher/hwlab-env-reuse-launcher.ts"), true);
assert.equal(recipe.launcherInputPaths.includes("custom/env-tool.mjs"), true);
});
async function createFixtureRepo(options = {}) {
const deployServices = options.deployServices !== false;
const k3sServiceMappings = options.k3sServiceMappings === true;
const serviceIds = options.serviceIds ?? ["hwlab-cloud-api", "hwlab-cloud-web"];
const laneServiceIds = options.laneServiceIds ?? V02_RUNTIME_SERVICE_IDS;
const repo = await mkdtemp(path.join(os.tmpdir(), "hwlab-g14-ci-plan-"));
await mkdir(path.join(repo, "cmd/hwlab-cloud-api"), { recursive: true });
await mkdir(path.join(repo, "cmd/hwlab-codex-api-responses-forwarder"), { recursive: true });
@@ -578,7 +630,7 @@ async function createFixtureRepo(options = {}) {
await mkdir(path.join(repo, "deploy/runtime/boot"), { recursive: true });
await mkdir(path.join(repo, "deploy/runtime/launcher"), { recursive: true });
await mkdir(path.join(repo, "deploy"), { recursive: true });
await writeFile(path.join(repo, "deploy/deploy.json"), JSON.stringify(createDeployFixture({ deployServices, k3sServiceMappings, serviceIds }), null, 2));
await writeStructuredFile(repo, "deploy/deploy.yaml", createDeployFixture({ serviceIds: laneServiceIds }));
const catalogMode = options.catalog ?? "ready";
await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module" }, null, 2));
await writeFile(path.join(repo, "package-lock.json"), JSON.stringify({ lockfileVersion: 3 }, null, 2));
@@ -642,43 +694,136 @@ async function refreshFixtureCatalogForHead(repo) {
const services = ["hwlab-cloud-api", "hwlab-cloud-web"];
const plan = await createG14CiPlan({
repoRoot: repo,
lane: "v02",
baseRef: "HEAD",
targetRef: "HEAD",
artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
services
});
const componentInputHashes = Object.fromEntries(plan.services.map((service) => [service.serviceId, service.componentInputHash]));
const catalog = createCatalogFixture("ready", { sourceCommitId, componentInputHashes });
const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(plan, { sourceCommitId, serviceIds: services }));
await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(catalog, null, 2));
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(catalog, null, 2));
await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json"]);
await git(repo, ["commit", "-m", "refresh fixture artifact catalogs"]);
}
function createDeployFixture({ deployServices, k3sServiceMappings, serviceIds = ["hwlab-cloud-api", "hwlab-cloud-web"] }) {
function planV02CoreServices(repo, options) {
return createG14CiPlan({
repoRoot: repo,
lane: "v02",
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
services: ["hwlab-cloud-api", "hwlab-cloud-web"],
...options
});
}
function createDeployFixture({ serviceIds = V02_RUNTIME_SERVICE_IDS } = {}) {
const deploy = {
lanes: {
v02: {
sourceRepo: "git@github.com:pikasTech/HWLAB.git",
envReuseServices: [...V02_RUNTIME_SERVICE_IDS],
envReuseServices: [...serviceIds],
bootScripts: {
"hwlab-cloud-api": "deploy/runtime/boot/hwlab-cloud-api.sh",
"hwlab-cloud-web": "deploy/runtime/boot/hwlab-cloud-web.sh",
"hwlab-gateway": "deploy/runtime/boot/hwlab-gateway.sh",
"hwlab-edge-proxy": "deploy/runtime/boot/hwlab-edge-proxy.sh",
"hwlab-agent-skills": "deploy/runtime/boot/hwlab-agent-skills.sh"
},
serviceDeclarations: {
"hwlab-cloud-api": {
runtimeKind: "bun-command",
entrypoint: "cmd/hwlab-cloud-api/main.ts",
artifactKind: "bun-command",
healthPath: "/health/live",
healthPort: 6667,
componentPaths: [
"cmd/hwlab-cloud-api/",
"cmd/hwlab-codex-api-responses-forwarder/",
"cmd/hwlab-deepseek-responses-bridge/",
"deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh",
"deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh",
"internal/cloud/",
"internal/db/",
"internal/audit/",
"internal/agent/agentrun-dispatch.mjs",
"internal/agent/prompts/",
"skills/hwlab-agent-runtime/"
],
env: {},
observable: true
},
"hwlab-cloud-web": {
runtimeKind: "cloud-web",
entrypoint: "web/hwlab-cloud-web/index.html",
artifactKind: "cloud-web",
healthPath: "/health/live",
healthPort: 8080,
componentPaths: [
"web/hwlab-cloud-web/",
"internal/dev-entrypoint/cloud-web-runtime.mjs",
"internal/dev-entrypoint/cloud-web-proxy.mjs",
"internal/dev-entrypoint/cloud-web-routes.mjs",
"tools/src/hwlab-cli/trace-renderer.ts"
],
env: {},
observable: true
},
"hwlab-gateway": {
runtimeKind: "bun-command",
entrypoint: "cmd/hwlab-gateway/main.ts",
artifactKind: "bun-command",
healthPath: "/health/live",
healthPort: 7001,
componentPaths: ["cmd/hwlab-gateway/", "internal/sim/"],
env: {},
observable: false
},
"hwlab-edge-proxy": {
runtimeKind: "bun-command",
entrypoint: "cmd/hwlab-edge-proxy/main.ts",
artifactKind: "bun-command",
healthPath: "/health",
healthPort: 6667,
componentPaths: ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"],
env: {},
observable: true
},
"hwlab-agent-skills": {
runtimeKind: "skills-bundle",
entrypoint: "skills/hwlab-agent-runtime/SKILL.md",
artifactKind: "skills-bundle",
healthPath: "/health/live",
healthPort: 7430,
componentPaths: ["skills/"],
env: {},
observable: true
}
},
envRecipe: {
osPackages: ["ca-certificates", "git"],
bunVersion: "1.3.13",
launcherPath: "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts",
runtimeNodeModulesPath: "/opt/hwlab-env/node_modules",
additionalEnvPaths: [
"internal/dev-entrypoint/artifact-runtime.mjs",
"scripts/artifact-publish.mjs",
"scripts/g14-artifact-publish.mjs",
"scripts/src/dev-artifact-services.mjs"
],
hwpodAliases: [
{ command: "hwpod", script: "tools/hwpod-cli.ts" },
{ command: "hwpod-ctl", script: "tools/hwpod-ctl.ts" },
{ command: "hwpod-compiler", script: "tools/hwpod-compiler-cli.ts" }
]
},
bootConfig: {
template: "default",
overrides: {}
}
}
}
};
if (deployServices) {
deploy.services = serviceIds.map((serviceId) => ({ serviceId }));
}
if (k3sServiceMappings) {
deploy.k3s = {
serviceMappings: serviceIds.map((serviceId) => ({ serviceId, name: serviceId }))
};
}
return deploy;
}
+88 -59
View File
@@ -7,6 +7,8 @@ import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { readStructuredFile } from "./src/structured-config.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultOutDir = "deploy/gitops/g14";
@@ -37,21 +39,9 @@ const defaultServiceIds = Object.freeze([
"hwlab-edge-proxy",
"hwlab-agent-skills"
]);
const v02RuntimeServiceIds = Object.freeze([
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-gateway",
"hwlab-edge-proxy",
"hwlab-agent-skills"
]);
const v02ObservableServices = Object.freeze([
{ serviceId: "hwlab-cloud-api", port: 6667, healthPath: "/health/live" },
{ serviceId: "hwlab-cloud-web", port: 8080, healthPath: "/health/live" },
{ serviceId: "hwlab-edge-proxy", port: 6667, healthPath: "/health" },
{ serviceId: "hwlab-agent-skills", port: 7430, healthPath: "/health/live" },
const v02ExtraObservableServices = Object.freeze([
{ serviceId: "hwlab-deepseek-proxy", port: 4000, healthPath: "/health/live" }
]);
const v02ObservableServiceIds = new Set(v02ObservableServices.map((item) => item.serviceId));
const v02RemovedServiceIds = new Set([
"hwlab-agent-mgr",
"hwlab-agent-worker",
@@ -275,7 +265,7 @@ function usage() {
return [
"usage: node scripts/g14-gitops-render.mjs [options]",
"",
"Render G14-only Kubernetes-native CI/CD manifests from built-in Tekton CI primitives, deploy/deploy.json, and deploy/k8s/*.",
"Render G14-only Kubernetes-native CI/CD manifests from built-in Tekton CI primitives, deploy/deploy.yaml, and deploy/k8s/*.",
"",
"options:",
" --lane LANE g14 or v02; default: g14",
@@ -304,7 +294,7 @@ function generatedPath(filePath) {
}
async function readJson(relativePath) {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
return readStructuredFile(repoRoot, relativePath);
}
async function readJsonIfPresent(relativePath, fallback = null) {
@@ -441,7 +431,8 @@ function v02EnvReuseEnabled(catalog, serviceId, envReuseServiceIds = null) {
}
function bootShForService(deployService, serviceId) {
const value = deployService?.bootSh || deployService?.bootScript || `deploy/runtime/boot/${serviceId}.sh`;
assert.ok(deployService?.bootSh, `deploy.lanes.v02.bootScripts.${serviceId} is required`);
const value = deployService.bootSh;
const text = String(value).replaceAll("\\", "/").replace(/^\.\//u, "");
assert.ok(text && !text.startsWith("/") && !text.split("/").includes(".."), `${serviceId} boot script must be repo-relative`);
return text;
@@ -450,7 +441,7 @@ function bootShForService(deployService, serviceId) {
function envReuseServiceIdsForLane(deploy, lane) {
if (lane !== "v02") return new Set();
const configured = deploy?.lanes?.v02?.envReuseServices;
const allowed = new Set(serviceIdsForLane(lane));
const allowed = new Set(serviceIdsForLane(lane, deploy));
return new Set((Array.isArray(configured) ? configured : []).filter((serviceId) => allowed.has(serviceId)));
}
@@ -575,20 +566,56 @@ function artifactEndpoint(args) {
return args.lane === "v02" ? defaultV02RuntimeEndpoint : defaultRuntimeEndpoint;
}
function serviceIdsForLane(lane) {
return lane === "v02" ? v02RuntimeServiceIds : defaultServiceIds;
function serviceIdsForLane(lane, deploy = null) {
return lane === "v02" ? v02ServiceIdsFromDeploy(deploy) : defaultServiceIds;
}
function serviceIdsForProfile(profile) {
return profile === "v02" ? v02RuntimeServiceIds : defaultServiceIds;
function serviceIdsForProfile(profile, deploy = null) {
return profile === "v02" ? v02ServiceIdsFromDeploy(deploy) : defaultServiceIds;
}
function v02ObservableService(serviceId) {
return v02ObservableServices.find((item) => item.serviceId === serviceId) ?? null;
function v02ServiceIdsFromDeploy(deploy) {
const serviceIds = uniquePreserveOrder((Array.isArray(deploy?.lanes?.v02?.envReuseServices) ? deploy.lanes.v02.envReuseServices : [])
.map((item) => String(item ?? "").trim())
.filter(Boolean));
assert.ok(serviceIds.length > 0, "deploy.lanes.v02.envReuseServices must not be empty");
return serviceIds;
}
function servicesParamForLane(lane) {
return serviceIdsForLane(lane).join(",");
function v02ObservableServicesForDeploy(deploy) {
const declarations = deploy?.lanes?.v02?.serviceDeclarations ?? {};
const serviceIds = new Set(v02ServiceIdsFromDeploy(deploy));
const declared = Object.entries(declarations)
.filter(([serviceId, declaration]) => serviceIds.has(serviceId) && declaration?.observable !== false && Number.isInteger(declaration?.healthPort))
.map(([serviceId, declaration]) => ({
serviceId,
port: declaration.healthPort,
healthPath: declaration.healthPath || "/health/live"
}));
return [...declared, ...v02ExtraObservableServices];
}
function v02ObservableService(deploy, serviceId) {
return v02ObservableServicesForDeploy(deploy).find((item) => item.serviceId === serviceId) ?? null;
}
function v02ObservableServiceIdsForDeploy(deploy) {
return new Set(v02ObservableServicesForDeploy(deploy).map((item) => item.serviceId));
}
function servicesParamForLane(lane, deploy = null) {
return serviceIdsForLane(lane, deploy).join(",");
}
function uniquePreserveOrder(values) {
const seen = new Set();
const result = [];
for (const value of values) {
if (seen.has(value)) continue;
seen.add(value);
result.push(value);
}
return result;
}
function isV02RemovedServiceId(serviceId) {
@@ -616,7 +643,7 @@ function artifactCatalogSkeleton({ args, deploy, source }) {
},
allowedProfiles: [environment],
forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"],
services: serviceIdsForLane(args.lane).map((serviceId) => artifactCatalogSkeletonService({ args, deploy, source, serviceId, environment, namespace, tag }))
services: serviceIdsForLane(args.lane, deploy).map((serviceId) => artifactCatalogSkeletonService({ args, deploy, source, serviceId, environment, namespace, tag }))
};
}
@@ -817,7 +844,7 @@ function removeV02RepoOwnedCodeAgentPodConfig(podSpec) {
}
function deployServicesForProfile(deploy, profile) {
const allowed = new Set(serviceIdsForProfile(profile));
const allowed = new Set(serviceIdsForProfile(profile, deploy));
const services = new Map((deploy.services ?? [])
.filter((service) => allowed.has(service.serviceId))
.map((service) => [service.serviceId, cloneJson(service)]));
@@ -850,8 +877,8 @@ function v02MetricsSidecarAnnotations(metricsSidecarSha256) {
return metricsSidecarSha256 ? { "hwlab.pikastech.local/metrics-sidecar-sha256": metricsSidecarSha256 } : {};
}
function v02MetricsSidecarContainer({ serviceId, namespace, gitopsTarget }) {
const service = v02ObservableService(serviceId);
function v02MetricsSidecarContainer({ deploy, serviceId, namespace, gitopsTarget }) {
const service = v02ObservableService(deploy, serviceId);
assert.ok(service, `unknown v0.2 observable service ${serviceId}`);
const extraMetricsEnv = serviceId === "hwlab-cloud-api"
? [
@@ -904,6 +931,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch =
const gitopsTarget = gitopsTargetForProfile(profile);
const digestPin = profile === "v02";
const envReuseServiceIds = envReuseServiceIdsForLane(deploy, profile);
const v02ObservableServiceIds = profile === "v02" ? v02ObservableServiceIdsForDeploy(deploy) : new Set();
const stableRuntimeLabels = {
"app.kubernetes.io/part-of": "hwlab",
"hwlab.pikastech.local/environment": profileLabel,
@@ -948,7 +976,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch =
label(item.metadata, v02MetricsLabels(templateServiceId));
label(podTemplate.metadata, v02MetricsLabels(templateServiceId));
annotate(podTemplate.metadata, v02MetricsSidecarAnnotations(metricsSidecarSha256));
upsertV02MetricsSidecar(podTemplate.spec, { serviceId: templateServiceId, namespace, gitopsTarget });
upsertV02MetricsSidecar(podTemplate.spec, { deploy, serviceId: templateServiceId, namespace, gitopsTarget });
}
if ((item.kind === "Deployment" || item.kind === "StatefulSet") && item.spec?.selector?.matchLabels) {
item.spec.selector.matchLabels = {
@@ -1097,7 +1125,8 @@ function transformListNamespace(value, namespace, labels, annotations) {
return result;
}
function transformServices({ services, namespace, labels, annotations, profile = "dev" }) {
function transformServices({ services, namespace, labels, annotations, profile = "dev", deploy = null }) {
const v02ObservableServiceIds = profile === "v02" ? v02ObservableServiceIdsForDeploy(deploy) : new Set();
const result = transformListNamespace(services, namespace, labels, annotations);
if (result.kind === "List") {
result.items = (result.items ?? []).filter((item) => !(profile === "v02" && isV02RemovedServiceId(serviceIdForWorkload(item, null))));
@@ -1111,13 +1140,13 @@ function transformServices({ services, namespace, labels, annotations, profile =
return result;
}
function observabilityManifest({ namespace, labels, annotations, metricsSidecarScript }) {
function observabilityManifest({ deploy, namespace, labels, annotations, metricsSidecarScript }) {
const baseLabels = {
...labels,
"app.kubernetes.io/part-of": "hwlab",
"hwlab.pikastech.local/monitoring": "enabled"
};
const serviceMonitors = v02ObservableServices.map((service) => ({
const serviceMonitors = v02ObservableServicesForDeploy(deploy).map((service) => ({
apiVersion: "monitoring.coreos.com/v1",
kind: "ServiceMonitor",
metadata: {
@@ -1463,7 +1492,7 @@ function prepareSourceScript() {
" git_timed catalog-fetch 90 git fetch --depth 1 gitops-catalog \"$(params.gitops-branch)\" >/dev/null || true",
" if git cat-file -e FETCH_HEAD:\"$catalog_path\" 2>/dev/null; then",
" git show FETCH_HEAD:\"$catalog_path\" > /tmp/hwlab-gitops-artifact-catalog.json",
" if node -e 'const fs=require(\"fs\"); const ids=(doc)=>(doc.services||[]).map((s)=>s.serviceId); const selected=(process.argv[3]||\"\").split(\",\").filter(Boolean); const gitops=JSON.parse(fs.readFileSync(process.argv[1],\"utf8\")); const deploy=JSON.parse(fs.readFileSync(process.argv[2],\"utf8\")); const expected=selected.length ? selected : ids(deploy); if (JSON.stringify(ids(gitops)) !== JSON.stringify(expected)) process.exit(42);' /tmp/hwlab-gitops-artifact-catalog.json deploy/deploy.json \"$(params.services)\"; then",
" if node --input-type=module -e 'import fs from \"node:fs\"; import { readStructuredFile } from \"./scripts/src/structured-config.mjs\"; const ids=(doc)=>(doc.services||[]).map((s)=>s.serviceId); const selected=(process.argv[3]||\"\").split(\",\").filter(Boolean); const gitops=JSON.parse(fs.readFileSync(process.argv[1],\"utf8\")); const deploy=await readStructuredFile(process.cwd(),process.argv[2]); const expected=selected.length ? selected : ids(deploy); if (JSON.stringify(ids(gitops)) !== JSON.stringify(expected)) process.exit(42);' /tmp/hwlab-gitops-artifact-catalog.json deploy/deploy.yaml \"$(params.services)\"; then",
" cp /tmp/hwlab-gitops-artifact-catalog.json \"$catalog_path\"",
" echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"loaded\",\"branch\":\"'\"$(params.gitops-branch)\"'\"}'",
" else",
@@ -1476,11 +1505,11 @@ function prepareSourceScript() {
" echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"seed\",\"reason\":\"gitops-branch-missing\"}'",
"fi",
"if [ ! -s \"$catalog_path\" ] && [ \"$(params.lane)\" = \"v02\" ]; then",
" node - \"$catalog_path\" \"$(params.revision)\" \"$(params.registry-prefix)\" \"$(params.image-tag-mode)\" \"$(params.services)\" <<'NODE'",
"const fs = require('node:fs');",
"const path = require('node:path');",
" node --input-type=module - \"$catalog_path\" \"$(params.revision)\" \"$(params.registry-prefix)\" \"$(params.image-tag-mode)\" \"$(params.services)\" <<'NODE'",
"import fs from 'node:fs';",
"import { readStructuredFile } from './scripts/src/structured-config.mjs';",
"const [catalogPath, revision, registryPrefix, imageTagMode, selectedServices] = process.argv.slice(2);",
"const deploy = JSON.parse(fs.readFileSync('deploy/deploy.json', 'utf8'));",
"const deploy = await readStructuredFile(process.cwd(), 'deploy/deploy.yaml');",
"const tag = imageTagMode === 'full' ? revision : revision.slice(0, 7);",
"const namespace = 'hwlab-v02';",
"const selected = (selectedServices || '').split(',').filter(Boolean);",
@@ -1551,7 +1580,7 @@ echo '{"event":"ci-base-image","phase":"plan-artifacts","image":"${ciToolsRunner
for tool in node git; do command -v "$tool" >/dev/null 2>&1 || { echo '{"event":"ci-base-image","phase":"plan-artifacts","ok":false,"reason":"missing-tool","tool":"'"$tool"'"}'; exit 31; }; done
cd /workspace/source/repo
test "$(git rev-parse HEAD)" = "$(params.revision)"
node scripts/g14-ci-plan.mjs --lane "$(params.lane)" --target-ref HEAD --deploy-json deploy/deploy.json --artifact-catalog "$(params.catalog-path)" --registry-prefix "$(params.registry-prefix)" --services "$(params.services)" > /workspace/source/ci-plan.json
node scripts/g14-ci-plan.mjs --lane "$(params.lane)" --target-ref HEAD --deploy-config deploy/deploy.yaml --artifact-catalog "$(params.catalog-path)" --registry-prefix "$(params.registry-prefix)" --services "$(params.services)" > /workspace/source/ci-plan.json
node - <<'NODE'
const fs = require("node:fs");
const plan = JSON.parse(fs.readFileSync("/workspace/source/ci-plan.json", "utf8"));
@@ -1615,7 +1644,7 @@ cd /workspace/source/repo
test "$(git rev-parse HEAD)" = "$(params.revision)"
export HWLAB_ARTIFACT_LANE="$(params.lane)"
export HWLAB_ARTIFACT_CATALOG_PATH="$(params.catalog-path)"
export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.json"
export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.yaml"
export HWLAB_ARTIFACT_IMAGE_TAG_MODE="$(params.image-tag-mode)"
mkdir -p /workspace/source/service-results
if [ -s /workspace/source/affected-services.json ] && ! node -e 'const fs=require("node:fs"); const p=JSON.parse(fs.readFileSync("/workspace/source/affected-services.json","utf8")); const service=process.argv[1]; const item=(p.entries||[]).find((entry)=>entry.serviceId===service); process.exit(item && item.affected ? 0 : 1);' "$(params.service-id)"; then
@@ -1694,7 +1723,7 @@ if [ "$buildkit_ready" != "1" ]; then
echo '{"event":"buildkit-sidecar-not-ready","serviceId":"'"$(params.service-id)"'","addr":"'"$HWLAB_BUILDKIT_ADDR"'"}' >&2
exit 1
fi
node scripts/g14-artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-json deploy/deploy.json --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --report "/workspace/source/service-results/$(params.service-id).json" --tekton-results-dir /tekton/results --quiet-build --concurrency 1 --services "$(params.service-id)" --buildkit-command /workspace/buildkit-bin/buildctl --buildkit-addr "$HWLAB_BUILDKIT_ADDR"
node scripts/g14-artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --report "/workspace/source/service-results/$(params.service-id).json" --tekton-results-dir /tekton/results --quiet-build --concurrency 1 --services "$(params.service-id)" --buildkit-command /workspace/buildkit-bin/buildctl --buildkit-addr "$HWLAB_BUILDKIT_ADDR"
`;
}
@@ -1712,10 +1741,10 @@ export HWLAB_DEV_REGISTRY_K8S_NATIVE=1
if [ -n "$(params.base-image)" ]; then export HWLAB_DEV_BASE_IMAGE="$(params.base-image)"; fi
export HWLAB_ARTIFACT_LANE="$(params.lane)"
export HWLAB_ARTIFACT_CATALOG_PATH="$(params.catalog-path)"
export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.json"
export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.yaml"
export HWLAB_ARTIFACT_IMAGE_TAG_MODE="$(params.image-tag-mode)"
node scripts/g14-artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-json deploy/deploy.json --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --report /workspace/source/dev-artifacts.json --quiet-build --concurrency 1 --services "$(params.services)" --external-service-report-dir /workspace/source/service-results
node scripts/refresh-artifact-catalog.mjs --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-json deploy/deploy.json --image-tag-mode "$(params.image-tag-mode)" --target-ref HEAD --publish-report /workspace/source/dev-artifacts.json --no-write
node scripts/g14-artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --report /workspace/source/dev-artifacts.json --quiet-build --concurrency 1 --services "$(params.services)" --external-service-report-dir /workspace/source/service-results
node scripts/refresh-artifact-catalog.mjs --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --target-ref HEAD --publish-report /workspace/source/dev-artifacts.json --no-write
`;
}
@@ -1862,7 +1891,7 @@ NODE
}
if [ -s /workspace/source/dev-artifacts.json ]; then
node scripts/refresh-artifact-catalog.mjs --lane "$lane" --catalog-path "$catalog_path" --deploy-json deploy/deploy.json --image-tag-mode "$(params.image-tag-mode)" --target-ref "$(params.revision)" --publish-report /workspace/source/dev-artifacts.json --write
node scripts/refresh-artifact-catalog.mjs --lane "$lane" --catalog-path "$catalog_path" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --target-ref "$(params.revision)" --publish-report /workspace/source/dev-artifacts.json --write
fi
gitops_render_started_ms="$(ci_now_ms)"
node scripts/g14-gitops-render.mjs --lane "$lane" --catalog-path "$catalog_path" --image-tag-mode "$(params.image-tag-mode)" --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --use-deploy-images
@@ -2961,8 +2990,8 @@ function runtimeReadyTask({ profile = "dev" } = {}) {
};
}
function imagePublishTaskSet(args = { lane: "g14" }) {
const serviceIds = serviceIdsForLane(args.lane);
function imagePublishTaskSet(args = { lane: "g14" }, deploy = null) {
const serviceIds = serviceIdsForLane(args.lane, deploy);
const buildTasks = serviceIds.map((serviceId) => perServiceBuildTask(serviceId, {
runAfter: ["plan-artifacts"]
}));
@@ -2973,7 +3002,7 @@ function imagePublishTaskSet(args = { lane: "g14" }) {
];
}
function tektonPipeline(args = { lane: "g14" }) {
function tektonPipeline(args = { lane: "g14" }, deploy = null) {
const settings = ciLaneSettings(args);
const runtimePath = `deploy/gitops/g14/${runtimePathForProfile(settings.profile)}`;
return {
@@ -3005,7 +3034,7 @@ function tektonPipeline(args = { lane: "g14" }) {
{ name: "runtime-path", type: "string", default: runtimePath },
{ name: "revision", type: "string", description: "Full git commit SHA; the only source truth for image tags." },
{ name: "registry-prefix", type: "string", default: defaultRegistryPrefix },
{ name: "services", type: "string", default: servicesParamForLane(args.lane) },
{ name: "services", type: "string", default: servicesParamForLane(args.lane, deploy) },
{ name: "base-image", type: "string", default: defaultDevBaseImage }
],
workspaces: [
@@ -3049,7 +3078,7 @@ function tektonPipeline(args = { lane: "g14" }) {
]
},
...primitiveValidationTasks.map(primitiveValidationTask),
...imagePublishTaskSet(args),
...imagePublishTaskSet(args, deploy),
{
name: "gitops-promote",
runAfter: ["collect-artifacts"],
@@ -3203,7 +3232,7 @@ function tektonPollerCronJob(args) {
{ name: "SOURCE_BRANCH", value: args.sourceBranch },
{ name: "GITOPS_BRANCH", value: args.gitopsBranch },
{ name: "REGISTRY_PREFIX", value: args.registryPrefix },
{ name: "SERVICES", value: servicesParamForLane(args.lane) },
{ name: "SERVICES", value: servicesParamForLane(args.lane, deploy) },
{ name: "BASE_IMAGE", value: defaultDevBaseImage }
],
volumeMounts: [{ name: "git-ssh", mountPath: "/workspace/git-ssh", readOnly: true }],
@@ -4204,7 +4233,7 @@ function deepSeekProxyManifest({ profile = "dev", source, sourceBranch = "G14",
{ name: "moonbridge-config", mountPath: "/config", readOnly: true },
{ name: "moonbridge-data", mountPath: "/data" }
]
}, ...(profile === "v02" ? [v02MetricsSidecarContainer({ serviceId: "hwlab-deepseek-proxy", namespace, gitopsTarget: gitopsTargetForProfile(profile) })] : [])],
}, ...(profile === "v02" ? [v02MetricsSidecarContainer({ deploy, serviceId: "hwlab-deepseek-proxy", namespace, gitopsTarget: gitopsTargetForProfile(profile) })] : [])],
volumes: [
{ name: "moonbridge-scripts", configMap: { name: "hwlab-deepseek-proxy-config" } },
{ name: "moonbridge-config", emptyDir: {} },
@@ -4736,11 +4765,11 @@ This directory is generated by \`scripts/g14-gitops-render.mjs --lane v02\` from
}
return `# HWLAB G14 GitOps CI/CD
This directory is generated from built-in Tekton-native CI primitive declarations plus human-authored \`deploy/deploy.json\`, CI-authored \`deploy/artifact-catalog.dev.json\`, and \`deploy/k8s/*\` by \`scripts/g14-gitops-render.mjs\`.
This directory is generated from built-in Tekton-native CI primitive declarations plus human-authored \`deploy/deploy.yaml\`, CI-authored \`deploy/artifact-catalog.dev.json\`, and \`deploy/k8s/*\` by \`scripts/g14-gitops-render.mjs\`.
- Target: G14 k3s only.
- CI: Tekton Pipeline \`hwlab-ci/hwlab-g14-ci-image-publish\`.
- Artifact contract: runtime images come from \`deploy/artifact-catalog.dev.json\`; \`deploy/deploy.json\` only carries human-authored config such as env and topology.
- Artifact contract: runtime images come from \`deploy/artifact-catalog.dev.json\`; \`deploy/deploy.yaml\` only carries human-authored config such as env and topology.
- Branch split: source branch \`${args.sourceBranch}\` remains the watched code branch; generated desired state is promoted to \`${args.gitopsBranch}\`.
- CD: Argo CD Applications \`argocd/hwlab-g14-dev\` and \`argocd/hwlab-g14-prod\` consume \`${args.gitopsBranch}:deploy/gitops/g14/runtime-dev\` and \`runtime-prod\`.
- Public preview: FRP exposes G14 DEV web \`${args.webEndpoint}\` / edge \`${args.runtimeEndpoint}\` through \`hwlab-dev/hwlab-g14-frpc\`, and G14 PROD web \`${args.prodWebEndpoint}\` / edge \`${args.prodRuntimeEndpoint}\` through \`hwlab-prod/hwlab-g14-prod-frpc\`; D601 keeps \`:16666/:16667\`.
@@ -4753,7 +4782,7 @@ This directory is generated from built-in Tekton-native CI primitive declaration
async function plannedFiles(args) {
const [deploy, namespaceTemplate, config, services, health, workloads] = await Promise.all([
readJson("deploy/deploy.json"),
readJson("deploy/deploy.yaml"),
readJson("deploy/k8s/base/namespace.yaml"),
readJson("deploy/k8s/base/code-agent-codex-config.yaml"),
readJson("deploy/k8s/base/services.yaml"),
@@ -4805,7 +4834,7 @@ async function plannedFiles(args) {
if (args.useDeployImages) {
sourceDescriptor.renderMode = {
imageSource: args.catalogPath,
configSource: "deploy/deploy.json",
configSource: "deploy/deploy.yaml",
mixedDesiredState: true
};
}
@@ -4814,7 +4843,7 @@ async function plannedFiles(args) {
putJson("registry/registry.yaml", registryManifest());
if (args.lane === "v02") putJson("devops-infra/git-mirror.yaml", devopsInfraGitMirrorManifest());
putJson(`${settings.tektonDir}/rbac.yaml`, tektonRbac(args));
putJson(`${settings.tektonDir}/pipeline.yaml`, tektonPipeline(args));
putJson(`${settings.tektonDir}/pipeline.yaml`, tektonPipeline(args, deploy));
if (args.lane !== "v02") {
putJson(`${settings.tektonDir}/poller.yaml`, tektonPollerCronJob(args));
putJson(`${settings.tektonDir}/control-plane-reconciler.yaml`, tektonControlPlaneReconcilerCronJob(args));
@@ -4836,13 +4865,13 @@ async function plannedFiles(args) {
putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent }));
putJson(`${runtimePath}/namespace.yaml`, runtimeNamespace);
if (profile !== "v02") putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations));
putJson(`${runtimePath}/services.yaml`, transformServices({ services, namespace, labels: profileLabels, annotations, profile }));
putJson(`${runtimePath}/services.yaml`, transformServices({ services, namespace, labels: profileLabels, annotations, profile, deploy }));
putJson(`${runtimePath}/health-contract.yaml`, transformHealthContract(health, namespace, profileLabels, annotations, endpoints.runtimeEndpoint, endpoints.webEndpoint, profile));
putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, catalog: artifactCatalog, source, sourceBranch: args.sourceBranch, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, sourceBranch: args.sourceBranch, sourceRepo: args.sourceRepo, deploy, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
if (profile === "v02") putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ migrationSql, source }));
if (profile === "v02") putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ source }));
if (profile === "v02") putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ namespace, labels: profileLabels, annotations, metricsSidecarScript }));
if (profile === "v02") putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ deploy, namespace, labels: profileLabels, annotations, metricsSidecarScript }));
if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages }));
putJson(`${runtimePath}/g14-frpc.yaml`, g14FrpcManifest({ profile, source }));
}
+41 -28
View File
@@ -12,12 +12,13 @@ import {
serviceInventoryFromServices
} from "./src/dev-artifact-services.mjs";
import { tempReportPath } from "./src/report-paths.mjs";
import { readStructuredFile } from "./src/structured-config.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultLane = process.env.HWLAB_ARTIFACT_LANE || process.env.HWLAB_GITOPS_LANE || "g14";
const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json";
const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.json";
const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.yaml";
const defaultPublishReportPath = tempReportPath("dev-artifacts.json");
const defaultRegistryPrefix = process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab";
const digestPattern = /^sha256:[a-f0-9]{64}$/;
@@ -26,16 +27,27 @@ const commitPattern = /^[a-f0-9]{7,40}$/;
const publishReadyStatuses = new Set(["published", "reused"]);
const v02EnvReuseRuntimeMode = "env-reuse-git-mirror-checkout";
const v02RuntimeServiceIds = Object.freeze([
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-gateway",
"hwlab-edge-proxy",
"hwlab-agent-skills"
]);
function serviceIdsForLane(lane, deploy = null) {
return lane === "v02" ? v02ServiceIdsFromDeploy(deploy) : SERVICE_IDS;
}
function serviceIdsForLane(lane) {
return lane === "v02" ? v02RuntimeServiceIds : SERVICE_IDS;
function v02ServiceIdsFromDeploy(deploy) {
const serviceIds = uniquePreserveOrder((Array.isArray(deploy?.lanes?.v02?.envReuseServices) ? deploy.lanes.v02.envReuseServices : [])
.map((item) => String(item ?? "").trim())
.filter(Boolean));
assert.ok(serviceIds.length > 0, "deploy.lanes.v02.envReuseServices must not be empty");
return serviceIds;
}
function uniquePreserveOrder(values) {
const seen = new Set();
const result = [];
for (const value of values) {
if (seen.has(value)) continue;
seen.add(value);
result.push(value);
}
return result;
}
function parseArgs(argv) {
@@ -58,7 +70,7 @@ function parseArgs(argv) {
args.lane = readOption(argv, ++index, arg);
} else if (arg === "--catalog-path") {
args.catalogPath = readOption(argv, ++index, arg);
} else if (arg === "--deploy-json") {
} else if (arg === "--deploy-config") {
args.deployPath = readOption(argv, ++index, arg);
} else if (arg === "--image-tag-mode") {
args.imageTagMode = readOption(argv, ++index, arg);
@@ -101,7 +113,7 @@ function usage() {
"usage: node scripts/refresh-artifact-catalog.mjs --target-ref REF (--blocked|--publish-report PATH) [--write|--no-write]",
"",
"Preview or explicitly write DEV artifact catalog identity without faking digest evidence.",
"Default mode is read-only. --write is reserved for G14 Tekton GitOps promotion and never writes deploy/deploy.json.",
"Default mode is read-only. --write is reserved for G14 Tekton GitOps promotion and never writes deploy/deploy.yaml.",
"",
"examples:",
" node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --blocked --no-write",
@@ -110,14 +122,13 @@ function usage() {
].join("\n");
}
async function readJson(relativePath) {
const filePath = path.isAbsolute(relativePath) ? relativePath : path.join(repoRoot, relativePath);
return JSON.parse(await readFile(filePath, "utf8"));
async function readConfig(relativePath) {
return readStructuredFile(repoRoot, relativePath);
}
async function readJsonIfPresent(relativePath, fallback = null) {
async function readConfigIfPresent(relativePath, fallback = null) {
try {
return await readJson(relativePath);
return await readConfig(relativePath);
} catch (error) {
if (error?.code === "ENOENT") return fallback;
throw error;
@@ -212,7 +223,7 @@ function artifactCatalogSkeleton({ args, deploy, target, registryPrefix = defaul
},
allowedProfiles: [environment],
forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"],
services: serviceIdsForLane(args.lane).map((serviceId) => catalogSkeletonService({ args, serviceId, target, registryPrefix, environment, namespace }))
services: serviceIdsForLane(args.lane, deploy).map((serviceId) => catalogSkeletonService({ args, serviceId, target, registryPrefix, environment, namespace }))
};
}
@@ -291,7 +302,9 @@ function assertV02DeployAndCatalog(deploy, catalog) {
assert.ok(lane && typeof lane === "object", "deploy.lanes.v02 must exist for v02 catalog refresh");
assert.equal(lane.namespace, "hwlab-v02", "deploy.lanes.v02.namespace must be hwlab-v02");
assert.ok(isHttpEndpoint(lane.endpoint), "deploy.lanes.v02.endpoint must be a non-empty http(s) URL");
assert.deepEqual((deploy.services ?? []).map((service) => service.serviceId), SERVICE_IDS, "deploy services must cover frozen service IDs in order");
const serviceIds = serviceIdsForLane("v02", deploy);
assert.ok(serviceIds.every((serviceId) => lane.serviceDeclarations?.[serviceId]), "deploy.lanes.v02.serviceDeclarations must cover envReuseServices");
assert.ok(serviceIds.every((serviceId) => lane.bootScripts?.[serviceId]), "deploy.lanes.v02.bootScripts must cover envReuseServices");
assert.equal(catalog.environment, "v02", "catalog environment must be v02");
assert.equal(catalog.profile, "v02", "catalog profile must be v02");
@@ -299,12 +312,12 @@ function assertV02DeployAndCatalog(deploy, catalog) {
if (catalog.endpoint != null) assert.ok(isHttpEndpoint(catalog.endpoint), "catalog endpoint must be a non-empty http(s) URL");
assert.deepEqual(catalog.allowedProfiles, ["v02"], "catalog allowedProfiles must only allow v02");
assert.deepEqual(catalog.forbiddenProfiles, ["dev", "prod"], "catalog forbiddenProfiles must forbid dev/prod");
assert.deepEqual((catalog.services ?? []).map((service) => service.serviceId), serviceIdsForLane("v02"), "catalog services must cover retained v0.2 service IDs in order");
assert.deepEqual((catalog.services ?? []).map((service) => service.serviceId), serviceIds, "catalog services must cover deploy.lanes.v02.envReuseServices in order");
}
function normalizeCatalogForLane(catalog, lane) {
function normalizeCatalogForLane(catalog, lane, deploy) {
if (!catalog || lane !== "v02") return catalog;
const allowed = new Set(serviceIdsForLane(lane));
const allowed = new Set(serviceIdsForLane(lane, deploy));
return {
...catalog,
services: (catalog.services ?? []).filter((service) => allowed.has(service.serviceId))
@@ -529,16 +542,16 @@ async function main() {
const target = await resolveTarget(args.targetRef, args.imageTagMode);
const [deploy, publishReport] = await Promise.all([
readJson(args.deployPath),
args.publishReportPath ? readJson(args.publishReportPath) : Promise.resolve(null)
readConfig(args.deployPath),
args.publishReportPath ? readConfig(args.publishReportPath) : Promise.resolve(null)
]);
let catalog = await readJsonIfPresent(args.catalogPath, null);
let catalog = await readConfigIfPresent(args.catalogPath, null);
if (!catalog && args.lane === "v02") catalog = artifactCatalogSkeleton({ args, deploy, target, registryPrefix: publishReport?.artifactPublish?.registryPrefix ?? defaultRegistryPrefix });
catalog = normalizeCatalogForLane(catalog, args.lane);
catalog = normalizeCatalogForLane(catalog, args.lane, deploy);
assert.ok(catalog, `${args.catalogPath} is required for ${args.lane} catalog refresh`);
assertLaneDeployAndCatalog(args, deploy, catalog);
const serviceIds = serviceIdsForLane(args.lane);
const serviceIds = serviceIdsForLane(args.lane, deploy);
const publishRecords = publishReport ? publishRecordsFromReport(publishReport, target, serviceIds) : null;
const serviceInventory = publishReport?.artifactPublish?.serviceInventory ?? serviceInventoryFromServices(
await resolveDevArtifactServices(repoRoot, serviceIds, SERVICE_IDS)
@@ -572,7 +585,7 @@ async function main() {
endpoint: catalog.endpoint ?? null,
imageTagMode: args.imageTagMode,
wrote: args.write ? [args.catalogPath] : [],
deployTruthPolicy: "deploy.json is human-authored runtime config; artifact promotion must not rewrite it",
deployTruthPolicy: "deploy.yaml is human-authored runtime config; artifact promotion must not rewrite it",
ciPublished: Boolean(publishRecords),
registryVerified: Boolean(publishRecords),
publishedCount,
+6 -5
View File
@@ -7,6 +7,7 @@ import test from "node:test";
import { promisify } from "node:util";
import { SERVICE_IDS } from "../internal/protocol/index.mjs";
import { writeStructuredFile } from "./src/structured-config.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
@@ -154,7 +155,7 @@ test("refresh is read-only by default", async () => {
const payload = JSON.parse(result.stdout);
assert.equal(payload.status, "published");
assert.deepEqual(payload.wrote, []);
assert.equal(payload.deployTruthPolicy, "deploy.json is human-authored runtime config; artifact promotion must not rewrite it");
assert.equal(payload.deployTruthPolicy, "deploy.yaml is human-authored runtime config; artifact promotion must not rewrite it");
});
test("refresh keeps reused service image tags from the publish report", async () => {
@@ -327,9 +328,9 @@ test("v02 refresh accepts current runtime service full image tags", async () =>
test("v02 refresh follows the current deploy endpoint instead of the retired 19667 freeze", async () => {
const commitId = await git(["rev-parse", "HEAD"]);
const tempDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-refresh-v02-https-endpoint-"));
const deployPath = path.join(tempDir, "deploy.json");
const deployPath = path.join(tempDir, "deploy.yaml");
const catalogPath = path.join(tempDir, "artifact-catalog.v02.json");
await writeFile(deployPath, `${JSON.stringify({
await writeStructuredFile(tempDir, deployPath, {
environment: "dev",
namespace: "hwlab-dev",
endpoint: "http://74.48.78.17:16667",
@@ -341,7 +342,7 @@ test("v02 refresh follows the current deploy endpoint instead of the retired 196
}
},
services: SERVICE_IDS.map((serviceId) => ({ serviceId }))
}, null, 2)}\n`);
});
await writeFile(catalogPath, `${JSON.stringify({
catalogVersion: "v1",
kind: "hwlab-artifact-catalog",
@@ -374,7 +375,7 @@ test("v02 refresh follows the current deploy endpoint instead of the retired 196
"full",
"--target-ref",
"HEAD",
"--deploy-json",
"--deploy-config",
deployPath,
"--catalog-path",
catalogPath,
@@ -8,6 +8,7 @@ import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs";
import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT } from "../../internal/protocol/index.mjs";
import { buildDesiredStatePlan } from "./deploy-desired-state-plan.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
import { readStructuredFile } from "./structured-config.mjs";
const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const cloudWebSourceRoot = "web/hwlab-cloud-web/src";
@@ -154,7 +155,7 @@ export function evaluateArtifactRuntimeReadiness({
pass: desiredState.status !== "blocked" && desiredState.status !== "missing",
type: "contract_blocker",
summary: `Desired-state planner status=${desiredState.status}; convergence=${desiredState.targetConvergence.state}.`,
nextTask: "Repair deploy/deploy.json, deploy/artifact-catalog.dev.json, and deploy/k8s/base/workloads.yaml before any apply."
nextTask: "Repair deploy/deploy.yaml, deploy/artifact-catalog.dev.json, and deploy/k8s/base/workloads.yaml before any apply."
});
addCheck(checks, {
id: "desired-state-target-match",
@@ -1246,8 +1247,7 @@ function nextCommands({ canPublish, canApply, blockedReasons }) {
}
async function readJson(repoRoot, relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
return readStructuredFile(repoRoot, relativePath);
}
async function readOptionalJson(repoRoot, relativePath) {
+29
View File
@@ -0,0 +1,29 @@
import {
parseStructuredConfig,
readStructuredFile,
readStructuredFileIfPresent,
stringifyStructuredConfig,
writeStructuredFile
} from "./structured-config.mjs";
export const DEFAULT_DEPLOY_CONFIG_PATH = "deploy/deploy.yaml";
export {
parseStructuredConfig,
readStructuredFile,
readStructuredFileIfPresent,
stringifyStructuredConfig,
writeStructuredFile
};
export async function readDeployConfig(repoRoot, relativePath = DEFAULT_DEPLOY_CONFIG_PATH) {
return readStructuredFile(repoRoot, relativePath);
}
export async function readDeployConfigIfPresent(repoRoot, relativePath = DEFAULT_DEPLOY_CONFIG_PATH, fallback = null) {
return readStructuredFileIfPresent(repoRoot, relativePath, fallback);
}
export async function writeDeployConfig(repoRoot, relativePath = DEFAULT_DEPLOY_CONFIG_PATH, value) {
return writeStructuredFile(repoRoot, relativePath, value);
}
+4 -3
View File
@@ -7,6 +7,7 @@ import {
codeAgentSecretRefPlaceholder
} from "../../internal/cloud/code-agent-contract.ts";
import { HWLAB_M3_IO_API_BASE_URL_ENV } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
import { readStructuredFile } from "./structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const expectedPorts = Object.freeze({ frontend: 16666, api: 16667 });
@@ -30,7 +31,7 @@ function parseArgs(argv) {
}
async function readJson(relativePath) {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
return readStructuredFile(repoRoot, relativePath);
}
function stripTomlComment(line) {
@@ -736,7 +737,7 @@ function validateCodeAgentProxyTimeoutArtifacts(ctx, workloads) {
async function buildPlan() {
const ctx = { checks: 0, diagnostics: [] };
const [manifest, services, workloads, healthContract, masterEdge, frpc, frps] = await Promise.all([
readJson("deploy/deploy.json"),
readJson("deploy/deploy.yaml"),
readJson("deploy/k8s/base/services.yaml"),
readJson("deploy/k8s/base/workloads.yaml"),
readJson("deploy/k8s/dev/health-contract.yaml"),
@@ -753,7 +754,7 @@ async function buildPlan() {
kind: "hwlab-deploy-contract-plan",
mode: "dry-run",
status: ctx.diagnostics.length === 0 ? "pass" : "blocked",
source: "deploy/deploy.json",
source: "deploy/deploy.yaml",
dryRunOnly: true,
safety: {
frpApply: false,
+6 -6
View File
@@ -10,11 +10,12 @@ import {
} from "../../internal/cloud/code-agent-contract.ts";
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.ts";
import { tempReportPath } from "./report-paths.mjs";
import { readStructuredFile } from "./structured-config.mjs";
const execFileAsync = promisify(execFile);
const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const deployPath = "deploy/deploy.json";
const deployPath = "deploy/deploy.yaml";
const catalogPath = "deploy/artifact-catalog.dev.json";
const workloadsPath = "deploy/k8s/base/workloads.yaml";
const artifactReportPath = tempReportPath("dev-artifacts.json");
@@ -99,8 +100,7 @@ function usage() {
}
async function readJson(repoRoot, relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
return readStructuredFile(repoRoot, relativePath);
}
async function readOptionalJson(repoRoot, relativePath) {
@@ -753,7 +753,7 @@ export async function buildDesiredStatePlan(options = {}) {
});
if (Object.hasOwn(deploy, "commitId")) {
addMismatch(ctx, "generated_artifact_identity", `${deployPath}.commitId`, "deploy.json is human-authored config; generated commit identity must stay in artifact catalog", "absent", deploy.commitId);
addMismatch(ctx, "generated_artifact_identity", `${deployPath}.commitId`, "deploy.yaml is human-authored config; generated commit identity must stay in artifact catalog", "absent", deploy.commitId);
}
if (!commitPattern.test(String(catalog.commitId ?? ""))) {
addMismatch(ctx, "invalid_commit", `${catalogPath}.commitId`, "artifact catalog commitId must be a short or full lowercase Git SHA", "7-40 lowercase hex", catalog.commitId);
@@ -949,7 +949,7 @@ export async function buildDesiredStatePlan(options = {}) {
prod: false,
devLiveClaim: false
},
checkSemantics: "--check exits non-zero for missing/invalid desired-state sources, generated artifact identity leaking into deploy.json, invalid target tags, partial target drift, or catalog identity mismatch with an explicit --target-ref, --target-tag, or --promotion-commit. When the checked target ref is the current checked-out merge commit, the comparable current-main target is its accepted first parent because a commit cannot embed its own content hash. Without --check, a uniform older catalog under --target-ref/--target-tag is a read-only promotion plan.",
checkSemantics: "--check exits non-zero for missing/invalid desired-state sources, generated artifact identity leaking into deploy.yaml, invalid target tags, partial target drift, or catalog identity mismatch with an explicit --target-ref, --target-tag, or --promotion-commit. When the checked target ref is the current checked-out merge commit, the comparable current-main target is its accepted first parent because a commit cannot embed its own content hash. Without --check, a uniform older catalog under --target-ref/--target-tag is a read-only promotion plan.",
summary: {
desiredCommitId,
desiredImageTag,
@@ -974,7 +974,7 @@ export async function buildDesiredStatePlan(options = {}) {
humanAuthoredTruth: [deployPath, workloadsPath],
artifactIdentityTruth: [catalogPath],
nonAuthoritativeEvidence: [artifactReportPath],
note: "This planner is read-only. deploy.json is human-authored config; artifact-catalog carries generated image identity and is written by G14 Tekton promotion only to G14-gitops. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence."
note: "This planner is read-only. deploy.yaml is human-authored config; artifact-catalog carries generated image identity and is written by G14 Tekton promotion only to G14-gitops. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence."
},
cloudApiDb: cloudApiDb ?? {
status: "blocked",
+3 -2
View File
@@ -16,6 +16,7 @@ import {
import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs";
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
import { readStructuredFile } from "./structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const defaultReportPath = tempReportPath("dev-edge-health.json");
@@ -270,7 +271,7 @@ function requireDevEndpoint() {
async function inspectContracts() {
const frpc = await readText("deploy/frp/frpc.dev.toml");
const frps = await readText("deploy/frp/frps.dev.toml");
const deploy = await readJson("deploy/deploy.json");
const deploy = await readJson("deploy/deploy.yaml");
const master = await readJson("deploy/master-edge/health-contract.json");
const frpsUsesVhostEdgePort = /vhostHTTPPort\s*=\s*(16667|6667)/u.test(frps);
@@ -1189,7 +1190,7 @@ function parseJsonOrNull(text) {
}
async function readJson(relativePath) {
return JSON.parse(await readText(relativePath));
return readStructuredFile(repoRoot, relativePath);
}
async function readText(relativePath) {
+6 -6
View File
@@ -18,6 +18,7 @@ import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs";
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs";
import { probeRegistryCapabilities } from "./registry-capabilities.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
import { readStructuredFile } from "./structured-config.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
@@ -145,8 +146,7 @@ async function commandExists(command) {
}
async function readJson(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
return readStructuredFile(repoRoot, relativePath);
}
async function readOptionalJson(relativePath) {
@@ -430,7 +430,7 @@ async function artifactIdentityFor({ deploy, catalog, artifactReport, targetComm
},
targetCoverage,
deployManifest: {
path: "deploy/deploy.json",
path: "deploy/deploy.yaml",
identitySource: "human-authored-config",
artifactIdentity: false,
matchesSource: true,
@@ -546,7 +546,7 @@ function assertFrpStatic(frpc, frps, masterEdge) {
async function loadContracts() {
return Promise.all([
readJson("deploy/deploy.json"),
readJson("deploy/deploy.yaml"),
readJson("deploy/artifact-catalog.dev.json"),
readJson("deploy/k8s/base/namespace.yaml"),
readJson("deploy/k8s/base/workloads.yaml"),
@@ -871,7 +871,7 @@ function validateCloudApiDbContract(reporter, deploy, workloads, services) {
type: "contract_blocker",
scope: "cloud-api-db-env-contract",
summary: `cloud-api DEV DB contract is incomplete; blocked by ${missing.join(", ")}.`,
nextTask: "Repair deploy/deploy.json and deploy/k8s/base/workloads.yaml so they expose the required DB env names, redacted Secret reference, and HWLAB_CLOUD_DB_SSL_MODE=disable. Do not make cloud-api-db a hard readiness gate unless this repo also owns its Service/Endpoint rollout contract."
nextTask: "Repair deploy/deploy.yaml and deploy/k8s/base/workloads.yaml so they expose the required DB env names, redacted Secret reference, and HWLAB_CLOUD_DB_SSL_MODE=disable. Do not make cloud-api-db a hard readiness gate unless this repo also owns its Service/Endpoint rollout contract."
});
}
@@ -1317,7 +1317,7 @@ function validateCodeAgentProviderContract(reporter, deploy, workloads) {
type: "agent_blocker",
scope: "code-agent-provider-env-contract",
summary: `cloud-api DEV Code Agent provider contract is incomplete; missing ${missing.join(", ")}.`,
nextTask: "Repair deploy/deploy.json and deploy/k8s/base/workloads.yaml so hwlab-cloud-api has OPENAI_API_KEY from hwlab-code-agent-provider/openai-api-key and HWLAB_CODE_AGENT_OPENAI_BASE_URL through DEV egress/proxy; do not print the Secret value."
nextTask: "Repair deploy/deploy.yaml and deploy/k8s/base/workloads.yaml so hwlab-cloud-api has OPENAI_API_KEY from hwlab-code-agent-provider/openai-api-key and HWLAB_CODE_AGENT_OPENAI_BASE_URL through DEV egress/proxy; do not print the Secret value."
});
}
+139 -104
View File
@@ -1,28 +1,15 @@
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { SERVICE_IDS } from "../../internal/protocol/index.mjs";
import { readStructuredFileIfPresent } from "./structured-config.mjs";
const execFileAsync = promisify(execFile);
export const G14_CI_PLAN_VERSION = "v1";
export const V02_ENV_REUSE_RUNTIME_MODE = "env-reuse-git-mirror-checkout";
export const DEFAULT_BUILD_SYSTEM_PATHS = Object.freeze([
"internal/dev-entrypoint/artifact-runtime.mjs",
"scripts/artifact-publish.mjs",
"scripts/g14-artifact-publish.mjs",
"scripts/src/dev-artifact-services.mjs"
]);
export const DEFAULT_ENV_REUSE_LAUNCHER_PATHS = Object.freeze([
"deploy/runtime/launcher/",
"scripts/artifact-publish.mjs"
]);
export const DEFAULT_RUNTIME_DEP_PATHS = Object.freeze([
"package.json",
"package-lock.json"
@@ -56,65 +43,35 @@ export const DEFAULT_GITOPS_ONLY_PATHS = Object.freeze([
"scripts/g14-gitops-render.mjs"
]);
const serviceSpecificPaths = Object.freeze({
"hwlab-cloud-api": [
"cmd/hwlab-cloud-api/",
"cmd/hwlab-codex-api-responses-forwarder/",
"cmd/hwlab-deepseek-responses-bridge/",
"deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh",
"deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh",
"internal/cloud/",
"internal/db/",
"internal/audit/",
"internal/agent/agentrun-dispatch.mjs",
"internal/agent/prompts/",
"skills/hwlab-agent-runtime/"
],
"hwlab-cloud-web": [
"web/hwlab-cloud-web/",
"internal/dev-entrypoint/cloud-web-runtime.mjs",
"internal/dev-entrypoint/cloud-web-proxy.mjs",
"internal/dev-entrypoint/cloud-web-routes.mjs",
"tools/src/hwlab-cli/trace-renderer.ts"
],
"hwlab-gateway": ["cmd/hwlab-gateway/", "internal/sim/"],
"hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"],
"hwlab-agent-skills": ["skills/"]
});
const bunCommandServices = new Set([
"hwlab-cloud-api",
"hwlab-codex-api-responses-forwarder",
"hwlab-deepseek-responses-bridge",
"hwlab-gateway",
"hwlab-edge-proxy"
]);
export async function createG14CiPlan(options = {}) {
const repoRoot = options.repoRoot ?? process.cwd();
const deployJsonPath = options.deployJsonPath ?? "deploy/deploy.json";
const deployConfigPath = options.deployConfigPath ?? "deploy/deploy.yaml";
const targetRef = options.targetRef ?? "HEAD";
const baseRef = options.baseRef ?? await defaultBaseRef(repoRoot, targetRef);
const lane = options.lane ?? (options.artifactCatalogPath?.includes(".v02.") ? "v02" : "g14");
const lane = options.lane ?? "v02";
if (lane !== "v02") throw new Error(`unsupported CI plan lane ${lane}; v02 env reuse is configured from deploy/deploy.yaml`);
const artifactCatalogPath = options.artifactCatalogPath ?? (lane === "v02" ? "deploy/artifact-catalog.v02.json" : "deploy/artifact-catalog.dev.json");
const registryPrefix = options.registryPrefix ?? process.env.HWLAB_DEV_REGISTRY_PREFIX ?? process.env.HWLAB_DEV_REGISTRY ?? "127.0.0.1:5000/hwlab";
const baseImage = options.baseImage ?? process.env.HWLAB_DEV_BASE_IMAGE ?? "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim";
const [deployJson, artifactCatalog] = await Promise.all([
readJsonIfPresent(repoRoot, deployJsonPath, {}),
readJsonIfPresent(repoRoot, artifactCatalogPath, null)
readStructuredFileIfPresent(repoRoot, deployConfigPath, {}),
readStructuredFileIfPresent(repoRoot, artifactCatalogPath, null)
]);
const sourceCommitId = await gitValue(repoRoot, ["rev-parse", targetRef]);
const shortCommitId = await gitValue(repoRoot, ["rev-parse", "--short=7", targetRef]);
const changedPaths = await changedPathsBetween(repoRoot, baseRef, targetRef);
const normalizedChangedPaths = changedPaths.map(normalizeRepoPath).filter(Boolean);
const semanticRuntimeDepPaths = await runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef, normalizedChangedPaths);
const serviceIdResolution = resolveServiceIds({ deployJson, options });
const componentModels = componentModelsForServices(serviceIdResolution.serviceIds);
const laneConfig = laneDeployConfig(deployJson, lane);
assertV02EnvReuseConfig(laneConfig, lane);
const serviceIdResolution = resolveServiceIds({ deployJson, options, laneConfig, lane });
const envReuseRecipe = envReuseRecipeForLane(deployJson, lane);
const componentModels = componentModelsForServices(serviceIdResolution.serviceIds, laneConfig);
const envReuseServices = enabledEnvReuseServices(deployJson, lane, serviceIdResolution.serviceIds);
const globalChange = classifyGlobalChange(normalizedChangedPaths);
const dockerfileHash = await hashGitPaths(repoRoot, targetRef, [
...DEFAULT_BUILD_SYSTEM_PATHS,
...envReuseRecipe.additionalEnvPaths,
...DEFAULT_RUNTIME_DEP_PATHS
], { semanticPackageJson: true });
const catalogByServiceId = new Map((artifactCatalog?.services ?? []).map((service) => [service.serviceId, service]));
@@ -130,7 +87,7 @@ export async function createG14CiPlan(options = {}) {
const bootSh = envReuse ? bootShForService(deployJson, model.serviceId) : null;
const envInputPaths = envReuse ? uniqueSorted([
...model.runtimeDeps,
...DEFAULT_ENV_REUSE_LAUNCHER_PATHS.map(normalizeRepoPath)
...envReuseRecipe.launcherInputPaths
]) : [];
const codeInputPaths = envReuse ? uniqueSorted([
...model.componentPaths,
@@ -215,10 +172,14 @@ export async function createG14CiPlan(options = {}) {
serviceId: model.serviceId,
runtimeKind: model.runtimeKind,
entrypoint: model.entrypoint,
artifactKind: model.artifactKind,
healthPath: model.healthPath,
healthPort: model.healthPort,
componentPaths: model.componentPaths,
sharedPaths: model.sharedPaths,
runtimeDeps: model.runtimeDeps,
buildSystemPaths: model.buildSystemPaths,
envReuseRecipe: envReuse ? envReuseRecipe.publicRecipe : null,
componentCommitId,
componentInputHash,
catalogSourceCommitId: catalogSourceCommitId || null,
@@ -263,7 +224,7 @@ export async function createG14CiPlan(options = {}) {
baseRef,
targetRef,
compatibility: {
deployJson: deployJsonPath,
deployConfig: deployConfigPath,
artifactCatalog: artifactCatalog ? artifactCatalogPath : null,
lane,
ciContractSource: "scripts/g14-gitops-render.mjs",
@@ -271,6 +232,8 @@ export async function createG14CiPlan(options = {}) {
currentRenderCompatible: true,
plannerMutatesDeployJson: false,
serviceIdSource: serviceIdResolution.source,
serviceDeclarationSource: lane === "v02" ? `deploy.lanes.${lane}.serviceDeclarations` : "not-applicable",
envRecipeSource: lane === "v02" ? `deploy.lanes.${lane}.envRecipe` : "not-applicable",
v02EnvReuseServiceIds: [...envReuseServices],
defaultComponentLazyBuild: true,
envReuseCodeOnlyFastLane: true,
@@ -302,20 +265,123 @@ export async function createG14CiPlan(options = {}) {
};
}
export function componentModelsForServices(serviceIds) {
export function componentModelsForServices(serviceIds, laneConfig = null) {
return serviceIds.map((serviceId) => {
const declaration = serviceDeclarationFor(laneConfig, serviceId);
return {
serviceId,
runtimeKind: runtimeKindForService(serviceId),
entrypoint: entrypointForService(serviceId),
componentPaths: uniqueSorted((serviceSpecificPaths[serviceId] ?? [`cmd/${serviceId}/`]).map(normalizeRepoPath)),
runtimeKind: declaration.runtimeKind,
entrypoint: declaration.entrypoint,
artifactKind: declaration.artifactKind,
healthPath: declaration.healthPath,
healthPort: declaration.healthPort,
componentPaths: uniqueSorted(declaration.componentPaths.map(normalizeRepoPath)),
sharedPaths: uniqueSorted(DEFAULT_SHARED_RUNTIME_PATHS.map(normalizeRepoPath)),
runtimeDeps: uniqueSorted(DEFAULT_RUNTIME_DEP_PATHS.map(normalizeRepoPath)),
buildSystemPaths: uniqueSorted(DEFAULT_BUILD_SYSTEM_PATHS.map(normalizeRepoPath))
buildSystemPaths: uniqueSorted(envReuseRecipeForLaneConfig(laneConfig).additionalEnvPaths)
};
});
}
function serviceDeclarationFor(laneConfig, serviceId) {
const configured = laneConfig?.serviceDeclarations?.[serviceId];
const runtimeKind = requireDeclarationString(configured?.runtimeKind, `serviceDeclarations.${serviceId}.runtimeKind`);
const entrypoint = requireDeclarationString(configured?.entrypoint, `serviceDeclarations.${serviceId}.entrypoint`);
const artifactKind = normalizeDeclarationString(configured?.artifactKind) || runtimeKind;
if (!Array.isArray(configured?.componentPaths) || configured.componentPaths.length === 0) {
throw new Error(`deploy.lanes.v02.serviceDeclarations.${serviceId}.componentPaths is required`);
}
return {
runtimeKind,
entrypoint,
artifactKind,
healthPath: normalizeDeclarationString(configured?.healthPath) || "/health/live",
healthPort: Number.isInteger(configured?.healthPort) ? configured.healthPort : null,
componentPaths: configured.componentPaths
};
}
function requireDeclarationString(value, label) {
const text = normalizeDeclarationString(value);
if (!text) throw new Error(`deploy.lanes.v02.${label} is required`);
return text;
}
function normalizeDeclarationString(value) {
const text = String(value ?? "").trim();
return text || null;
}
function laneDeployConfig(deployJson, lane) {
return deployJson?.lanes?.[lane] ?? null;
}
export function envReuseRecipeForLane(deployJson, lane = "v02") {
return envReuseRecipeForLaneConfig(laneDeployConfig(deployJson, lane));
}
function envReuseRecipeForLaneConfig(laneConfig) {
const configured = laneConfig?.envRecipe;
if (!configured) throw new Error("deploy.lanes.v02.envRecipe is required");
const osPackages = normalizeStringList(configured.osPackages, []);
const bunVersion = requireDeclarationString(configured.bunVersion, "envRecipe.bunVersion");
const launcherPath = normalizeRepoPath(requireDeclarationString(configured.launcherPath, "envRecipe.launcherPath"));
const runtimeNodeModulesPath = normalizeAbsolutePath(configured.runtimeNodeModulesPath);
if (!runtimeNodeModulesPath) throw new Error("deploy.lanes.v02.envRecipe.runtimeNodeModulesPath must be absolute");
const additionalEnvPaths = uniqueSorted(normalizeStringList(configured.additionalEnvPaths, []).map(normalizeRepoPath));
const launcherInputPaths = uniqueSorted([
launcherPath,
...additionalEnvPaths
]);
const hwpodAliases = normalizeHwpodAliases(configured.hwpodAliases);
if (osPackages.length === 0) throw new Error("deploy.lanes.v02.envRecipe.osPackages is required");
if (additionalEnvPaths.length === 0) throw new Error("deploy.lanes.v02.envRecipe.additionalEnvPaths is required");
if (hwpodAliases.length === 0) throw new Error("deploy.lanes.v02.envRecipe.hwpodAliases is required");
return {
osPackages,
bunVersion,
launcherPath,
runtimeNodeModulesPath,
additionalEnvPaths,
launcherInputPaths,
hwpodAliases,
publicRecipe: {
osPackages,
bunVersion,
launcherPath,
runtimeNodeModulesPath,
additionalEnvPaths,
hwpodAliases
}
};
}
function normalizeStringList(value, fallback) {
const list = Array.isArray(value) ? value : fallback;
return list.map((item) => String(item ?? "").trim()).filter(Boolean);
}
function normalizeAbsolutePath(value) {
const text = String(value ?? "").trim();
return text.startsWith("/") && !text.includes("..") ? text : null;
}
function normalizeHwpodAliases(value) {
const list = Array.isArray(value) ? value : [];
return list.map((item) => ({
command: normalizeDeclarationString(item?.command),
script: normalizeRepoPath(item?.script)
})).filter((item) => item.command && item.script);
}
function assertV02EnvReuseConfig(laneConfig, lane) {
if (lane !== "v02") return;
if (!laneConfig?.serviceDeclarations || Object.keys(laneConfig.serviceDeclarations).length === 0) {
throw new Error("deploy.lanes.v02.serviceDeclarations is required for v02 env reuse planning");
}
if (!laneConfig?.envRecipe) throw new Error("deploy.lanes.v02.envRecipe is required for v02 env reuse planning");
}
export async function runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef, changedPaths) {
const result = new Set();
for (const filePath of changedPaths) {
@@ -366,7 +432,8 @@ export function enabledEnvReuseServices(deployJson, lane, serviceIds) {
export function bootShForService(deployJson, serviceId) {
const configured = deployJson?.lanes?.v02?.bootScripts?.[serviceId];
const bootSh = normalizeRepoPath(configured || `deploy/runtime/boot/${serviceId}.sh`);
if (!configured) throw new Error(`deploy.lanes.v02.bootScripts.${serviceId} is required`);
const bootSh = normalizeRepoPath(configured);
if (!bootSh || bootSh.startsWith("/") || bootSh.split("/").includes("..")) {
throw new Error(`invalid v02 boot script for ${serviceId}: ${configured}`);
}
@@ -469,46 +536,28 @@ export async function lastCommitForPaths(repoRoot, targetRef, paths) {
return value.trim() || null;
}
function resolveServiceIds({ deployJson, options }) {
function resolveServiceIds({ options, laneConfig, lane }) {
const allowedServiceIds = knownServiceIds(laneConfig, lane);
if (Array.isArray(options.services) && options.services.length > 0) {
const serviceIds = uniqueSorted(options.services.map(String));
const allowedServiceIds = knownServiceIds(deployJson);
const unknownServiceIds = serviceIds.filter((serviceId) => !allowedServiceIds.has(serviceId));
if (unknownServiceIds.length > 0) {
throw new Error(`unknown service IDs for G14 CI plan: ${unknownServiceIds.join(", ")}`);
}
return { source: "cli-services", serviceIds };
}
const deployServices = Array.isArray(deployJson?.services) ? deployJson.services.map((service) => service?.serviceId).filter(Boolean) : [];
if (deployServices.length > 0) return { source: "deploy.services", serviceIds: uniquePreserveOrder(deployServices) };
const mappingServices = Array.isArray(deployJson?.k3s?.serviceMappings)
? deployJson.k3s.serviceMappings.map((service) => service?.serviceId).filter(Boolean)
: [];
if (mappingServices.length > 0) return { source: "deploy.k3s.serviceMappings", serviceIds: uniquePreserveOrder(mappingServices) };
return { source: "internal.protocol.SERVICE_IDS", serviceIds: [...SERVICE_IDS] };
const configured = Array.isArray(laneConfig?.envReuseServices) ? laneConfig.envReuseServices.filter(Boolean) : [];
if (configured.length === 0) throw new Error("deploy.lanes.v02.envReuseServices is required for v02 CI planning");
return { source: "deploy.lanes.v02.envReuseServices", serviceIds: uniquePreserveOrder(configured) };
}
function knownServiceIds(deployJson) {
const values = new Set(SERVICE_IDS);
for (const service of deployJson?.services ?? []) {
if (service?.serviceId) values.add(service.serviceId);
}
for (const service of deployJson?.k3s?.serviceMappings ?? []) {
if (service?.serviceId) values.add(service.serviceId);
}
function knownServiceIds(laneConfig, lane) {
if (lane !== "v02") return new Set();
const values = new Set(Object.keys(laneConfig?.serviceDeclarations ?? {}));
for (const serviceId of laneConfig?.envReuseServices ?? []) values.add(serviceId);
return values;
}
async function readJsonIfPresent(repoRoot, relativePath, fallback) {
try {
const filePath = path.isAbsolute(relativePath) ? relativePath : path.join(repoRoot, relativePath);
return JSON.parse(await readFile(filePath, "utf8"));
} catch (error) {
if (error?.code === "ENOENT") return fallback;
throw error;
}
}
async function defaultBaseRef(repoRoot, targetRef) {
const parent = await gitValue(repoRoot, ["rev-parse", `${targetRef}^`]).catch(() => "");
return parent || targetRef;
@@ -650,20 +699,6 @@ function environmentDigestFromCatalog(catalogRecord) {
return catalogRecord?.environmentDigest ?? (catalogRecord?.environmentImage ? catalogRecord?.digest : null) ?? null;
}
function runtimeKindForService(serviceId) {
if (bunCommandServices.has(serviceId)) return "bun-command";
if (serviceId === "hwlab-cloud-web") return "cloud-web";
if (serviceId === "hwlab-agent-skills") return "skills-bundle";
return "node-command";
}
function entrypointForService(serviceId) {
if (bunCommandServices.has(serviceId)) return `cmd/${serviceId}/main.ts`;
if (serviceId === "hwlab-cloud-web") return "web/hwlab-cloud-web/index.html";
if (serviceId === "hwlab-agent-skills") return "skills/hwlab-agent-runtime/SKILL.md";
return `cmd/${serviceId}/main.mjs`;
}
function digestFromImageReference(image) {
const match = String(image ?? "").match(/@(sha256:[a-f0-9]{64})$/u);
return match ? match[1] : null;
+3 -2
View File
@@ -26,6 +26,7 @@ import {
HWLAB_M3_IO_SKILL_NAME
} from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
import { readStructuredFile } from "./structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const defaultJsonReportPath = tempReportPath("rpt-004-mvp-e2e.json");
@@ -734,7 +735,7 @@ async function buildExpectedArtifactState({ root, expectedCommit, fsJson }) {
const readJson = typeof fsJson === "function"
? (relativePath) => fsJson(relativePath)
: (relativePath) => readJsonOptional(root, relativePath);
const deploy = await readJson("deploy/deploy.json");
const deploy = await readJson("deploy/deploy.yaml");
const catalog = await readJson("deploy/artifact-catalog.dev.json");
const artifactReport = await readJson(defaultArtifactReportPath);
const deployCommit = sanitizeCommit(deploy?.commitId);
@@ -1568,7 +1569,7 @@ function runGit(root, args) {
async function readJsonOptional(root, relativePath) {
try {
return JSON.parse(await readFile(path.join(root, relativePath), "utf8"));
return await readStructuredFile(root, relativePath);
} catch {
return null;
}
+1 -1
View File
@@ -576,7 +576,7 @@ function fixtureArtifactJson() {
}
};
return async (relativePath) => {
if (relativePath === "deploy/deploy.json") return deploy;
if (relativePath === "deploy/deploy.yaml") return deploy;
if (relativePath === "deploy/artifact-catalog.dev.json") return catalog;
if (relativePath === "/tmp/hwlab-dev-gate/dev-artifacts.json") return artifactReport;
return null;
+45
View File
@@ -0,0 +1,45 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import * as YAML from "yaml";
export function structuredConfigFormatForPath(relativePath) {
const extension = path.extname(relativePath).toLowerCase();
if (extension === ".json") return "json";
if (extension === ".yaml" || extension === ".yml") return "yaml";
throw new Error(`unsupported structured config extension for ${relativePath}`);
}
export function parseStructuredConfig(text, relativePath = "<structured-config>") {
const format = structuredConfigFormatForPath(relativePath);
if (format === "json") return JSON.parse(text);
return YAML.parse(text);
}
export function stringifyStructuredConfig(value, relativePath = "<structured-config>") {
const format = structuredConfigFormatForPath(relativePath);
if (format === "json") return `${JSON.stringify(value, null, 2)}\n`;
const text = YAML.stringify(value, { indent: 2, lineWidth: 0 });
return text.endsWith("\n") ? text : `${text}\n`;
}
export async function readStructuredFile(repoRoot, relativePath) {
const filePath = path.isAbsolute(relativePath) ? relativePath : path.join(repoRoot, relativePath);
const raw = await readFile(filePath, "utf8");
return parseStructuredConfig(raw, relativePath);
}
export async function readStructuredFileIfPresent(repoRoot, relativePath, fallback = null) {
try {
return await readStructuredFile(repoRoot, relativePath);
} catch (error) {
if (error?.code === "ENOENT") return fallback;
throw error;
}
}
export async function writeStructuredFile(repoRoot, relativePath, value) {
const filePath = path.isAbsolute(relativePath) ? relativePath : path.join(repoRoot, relativePath);
const raw = stringifyStructuredConfig(value, relativePath);
await writeFile(filePath, raw, "utf8");
}
+3 -3
View File
@@ -5,11 +5,12 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";
import { readStructuredFile } from "./src/structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const catalogPath = "deploy/artifact-catalog.dev.json";
const deployPath = "deploy/deploy.json";
const deployPath = "deploy/deploy.yaml";
const healthContractPath = "deploy/k8s/dev/health-contract.yaml";
const commitPattern = /^[a-f0-9]{7,40}$/;
const digestPattern = /^sha256:[a-f0-9]{64}$/;
@@ -61,8 +62,7 @@ const forbiddenDeployArtifactEnv = new Set([
]);
async function readJSON(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
return readStructuredFile(repoRoot, relativePath);
}
function assertUnique(name, values) {
+5 -12
View File
@@ -14,11 +14,11 @@ import {
import { HWLAB_M3_IO_API_BASE_URL_ENV } from "../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
import {
ENVIRONMENT_DEV,
SERVICE_IDS,
TABLES,
validateRequest,
validateResponse
} from "../internal/protocol/index.mjs";
import { readStructuredFile } from "./src/structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -53,8 +53,7 @@ async function parseJSONSchemaFiles(dir) {
}
async function parseDeployManifest(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
const doc = JSON.parse(raw);
const doc = await readStructuredFile(repoRoot, relativePath);
assert.equal(doc.manifestVersion, "v1", `${relativePath} manifestVersion`);
assert.equal(doc.environment, ENVIRONMENT_DEV, `${relativePath} environment`);
assert.equal(Object.hasOwn(doc, "commitId"), false, `${relativePath} commitId must stay in artifact catalog`);
@@ -63,8 +62,7 @@ async function parseDeployManifest(relativePath) {
}
async function parseK8sList(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
const doc = JSON.parse(raw);
const doc = await readStructuredFile(repoRoot, relativePath);
assert.equal(doc.kind, "List", `${relativePath} kind`);
assert.ok(Array.isArray(doc.items), `${relativePath} items`);
return doc;
@@ -75,14 +73,10 @@ function assertUnique(name, values) {
}
function assertCommonSchema(commonSchema) {
const schemaServiceIds = commonSchema.$defs.serviceId.enum;
assert.deepEqual(schemaServiceIds, SERVICE_IDS, "common service ids must match runtime constants");
assert.deepEqual(commonSchema.$defs.environment.enum, [ENVIRONMENT_DEV]);
}
function assertDeploySchema(deploySchema) {
const serviceIds = deploySchema.$defs.serviceId.enum;
assert.deepEqual(serviceIds, SERVICE_IDS, "deploy service ids must match runtime constants");
assert.equal(deploySchema.properties.endpoint.const, "http://74.48.78.17:16667");
assert.ok(deploySchema.required.includes("health"), "deploy schema requires health source");
assert.ok(deploySchema.required.includes("publicEndpoints"), "deploy schema requires public endpoint source");
@@ -151,14 +145,13 @@ function assertEnvelopeValidation() {
const schemas = await parseJSONSchemaFiles("protocol/schemas");
const deploySchemas = await parseJSONSchemaFiles("deploy");
const deployManifest = await parseDeployManifest("deploy/deploy.json");
const deployManifest = await parseDeployManifest("deploy/deploy.yaml");
const k8sServices = await parseK8sList("deploy/k8s/base/services.yaml");
const k8sWorkloads = await parseK8sList("deploy/k8s/base/workloads.yaml");
const docsByName = new Map([...schemas, ...deploySchemas].map((item) => [path.basename(item.relativePath), item.doc]));
assert.ok(schemas.length >= 13, "expected protocol schemas");
assert.ok(TABLES.includes("patch_panel_status"), "frozen tables must include patch_panel_status");
assertUnique("service ids", SERVICE_IDS);
assertUnique("tables", TABLES);
assertCommonSchema(docsByName.get("common.json"));
assertDeploySchema(docsByName.get("deploy.schema.json"));
@@ -263,7 +256,7 @@ assertCodeAgentProviderWorkloadContract(cloudApiWorkloadEnv);
assertDbForbiddenInvalidHostContract();
assertValidationDoesNotExposeSecretValues(cloudApi.env, cloudApiWorkloadEnv);
console.log(`validated ${schemas.length + deploySchemas.length} JSON files and ${SERVICE_IDS.length} service ids`);
console.log(`validated ${schemas.length + deploySchemas.length} JSON files`);
function listItems(document) {
return document?.kind === "List" ? document.items ?? [] : [document].filter(Boolean);
+9 -8
View File
@@ -4,6 +4,8 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { readStructuredFile } from "./src/structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const namespace = "hwlab-dev";
const m3EndpointMap = Object.freeze({
@@ -110,8 +112,7 @@ const expectedInstanceServices = Object.freeze([
]);
async function readJson(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
return readStructuredFile(repoRoot, relativePath);
}
function listItems(document) {
@@ -216,14 +217,14 @@ function assertDevK8sInstanceServices(services) {
}
function assertDeployManifestCardinality(deploy) {
assert.equal(deploy.environment, "dev", "deploy/deploy.json environment");
assert.equal(deploy.namespace, namespace, "deploy/deploy.json namespace");
assert.equal(deploy.profiles?.prod?.enabled, false, "deploy/deploy.json prod disabled");
assert.equal(deploy.environment, "dev", "deploy/deploy.yaml environment");
assert.equal(deploy.namespace, namespace, "deploy/deploy.yaml namespace");
assert.equal(deploy.profiles?.prod?.enabled, false, "deploy/deploy.yaml prod disabled");
const byId = new Map((deploy.services ?? []).map((service) => [service.serviceId, service]));
for (const [serviceId, requirement] of Object.entries(expected)) {
const service = byId.get(serviceId);
assert.ok(service, `deploy/deploy.json missing ${serviceId}`);
assert.ok(service, `deploy/deploy.yaml missing ${serviceId}`);
assert.equal(service.namespace, namespace, `${serviceId} deploy namespace`);
assert.equal(service.profile, "dev", `${serviceId} deploy profile`);
assert.equal(service.replicas, requirement.replicas, `${serviceId} deploy replicas`);
@@ -250,7 +251,7 @@ function assertDeployManifestInstanceMappings(deploy) {
const byName = new Map(mappings.map((mapping) => [mapping.name, mapping]));
for (const expectedService of expectedInstanceServices) {
const mapping = byName.get(expectedService.name);
assert.ok(mapping, `deploy/deploy.json missing k3s serviceMapping ${expectedService.name}`);
assert.ok(mapping, `deploy/deploy.yaml missing k3s serviceMapping ${expectedService.name}`);
assert.equal(mapping.serviceId, expectedService.serviceId, `${expectedService.name} serviceId`);
assert.equal(mapping.namespace, namespace, `${expectedService.name} namespace`);
assert.equal(mapping.port, expectedService.port, `${expectedService.name} port`);
@@ -303,7 +304,7 @@ function assertM3RouteConfig(value, label) {
export async function readDevM3CardinalityInputs() {
const [deploy, workloads, services, topology] = await Promise.all([
readJson("deploy/deploy.json"),
readJson("deploy/deploy.yaml"),
readJson("deploy/k8s/base/workloads.yaml"),
readJson("deploy/k8s/base/services.yaml"),
readJson("fixtures/mvp/m3-hardware-loop/topology.json")
+1 -1
View File
@@ -37,7 +37,7 @@ async function main() {
assertIncludes(doc, "2 x hwlab-gateway-simu", "m3 runbook");
assertIncludes(doc, "1 x hwlab-patch-panel", "m3 runbook");
assertIncludes(doc, "DO1 -> patch-panel -> DI1", "m3 runbook");
assertIncludes(doc, "deploy/deploy.json", "m3 runbook");
assertIncludes(doc, "deploy/deploy.yaml", "m3 runbook");
assertIncludes(doc, "deploy/artifact-catalog.dev.json", "m3 runbook");
assertIncludes(doc, "pikasTech/HWLAB#63", "m3 runbook");
assertIncludes(doc, "Do not promote SOURCE / LOCAL / DRY-RUN / fixture output to `DEV-LIVE`.", "m3 runbook");
+117
View File
@@ -483,6 +483,8 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit
assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat"), true);
const agentChatRequest = requests.find((item) => item.url === "http://web.test/v1/agent/chat");
assert.equal(Object.hasOwn(agentChatRequest.body, "workspaceFiles"), false);
assert.equal(typeof agentChatRequest.body.hwpodSpecContent, "string");
assert.match(agentChatRequest.body.hwpodSpecContent, /HWLAB-CASE-F103.*caserun-run-agent-flow/u);
assert.doesNotMatch(agentChatRequest.body.message, /path: "F:\\Work\\HWLAB-CASE-F103\\\.worktree\\caserun-run-agent-flow"/u);
assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat/result/" + result.payload.agent.traceId), true);
assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat/trace/" + result.payload.agent.traceId), false);
@@ -638,6 +640,121 @@ test("case run status reports latest run stage when async control stage is stale
}
});
test("case aggregate writes one registry markdown entry without grading or broad registry commits", async () => {
const root = await mkdtempCaseRoot();
const caseRepo = path.join(root, "hwlab-case-registry");
const cwd = path.join(root, "hwlab");
const caseId = "d601-f103-v2-arm2d-integration";
const runId = "case04-completed-run";
const registryRunDir = path.join(caseRepo, "runs", caseId, runId);
try {
await mkdir(path.join(caseRepo, ".git"), { recursive: true });
await writeFile(path.join(caseRepo, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
await mkdir(path.join(registryRunDir, ".hwlab"), { recursive: true });
await writeJsonFile(path.join(registryRunDir, "run.json"), {
caseId,
runId,
runDir: "/root/hwlab-v02/.state/hwlab-cli/caserun/case04-completed-run",
caseRepo,
apiUrl: "http://74.48.78.17:19667",
compileOnly: true,
createdAt: "2026-06-08T00:00:00.000Z",
completedAt: "2026-06-08T00:10:00.000Z",
subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline", worktreePath: "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-case04" },
agent: { providerProfile: "dsflash-go", projectId: "prj_hwpod_workbench", conversationId: "cnv_case04", sessionId: "ses_case04", threadId: "thread-case04", traceId: "trc_case04", traceLookup: { commands: { trace: "hwlab-cli client agent trace trc_case04 --render web", result: "hwlab-cli client agent result trc_case04", inspect: "hwlab-cli client agent inspect --trace-id trc_case04" } } },
_control: { status: "completed", completedAt: "2026-06-08T00:10:00.000Z" }
});
await writeJsonFile(path.join(registryRunDir, "evidence.json"), {
contractVersion: "hwpod-case-run-evidence-v1",
caseId,
runId,
compileOnly: true,
autoEvaluation: false,
status: "recorded",
subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline", worktreePath: "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-case04" },
agent: { providerProfile: "dsflash-go", projectId: "prj_hwpod_workbench", conversationId: "cnv_case04", sessionId: "ses_case04", threadId: "thread-case04", traceId: "trc_case04" },
traceLookup: { strategy: "id_plus_existing_cli", commands: { trace: "hwlab-cli client agent trace trc_case04 --render web", result: "hwlab-cli client agent result trc_case04", inspect: "hwlab-cli client agent inspect --trace-id trc_case04" } },
agentTrace: { traceId: "trc_case04", source: "hwlab-cli.client.agent.trace", sourceEventCount: 3, renderedRowCount: 3, hwpodCommandCount: 2, hwpodBuildCommandCount: 1, agentRun: { runId: "run_agent_case04", commandId: "cmd_case04" } },
agentDiff: { statusShort: " M projects/01_baseline/User/main.c\n", diffStat: " main.c | 3 +++\n", diffPatchSha256: "diff-sha", sourceRootChangedAfterPrepare: false, sourceRootChangedAfterAgent: true },
hwpod: { source: "case-run-runner-post-agent-compile-check", exitCode: 0 },
keilJob: { jobId: "job-case04", status: "completed" },
artifacts: [],
decisions: { runnerPostAgentCompileCheck: "recorded" }
});
await writeJsonFile(path.join(registryRunDir, "agent-messages.json"), {
renderer: "tools/src/hwlab-cli/trace-renderer:traceDisplayRows",
sourceEventCount: 3,
renderedRowCount: 3,
rows: [
{ rowId: "row-1", seq: 1, header: "请求接受", body: "CaseRun started" },
{ rowId: "row-2", seq: 2, header: "ok commandExecution", body: "hwpod build --spec .hwlab/hwpod-spec.yaml\nexitCode=0" },
{ rowId: "row-3", seq: 3, header: "助手最终消息", terminal: true, bodyFormat: "markdown", body: "Build completed" }
]
});
await writeJsonFile(path.join(registryRunDir, "artifact-manifest.json"), {
contractVersion: "hwpod-case-run-artifact-manifest-v1",
caseId,
runId,
traceLookup: { commands: { trace: "hwlab-cli client agent trace trc_case04 --render web", result: "hwlab-cli client agent result trc_case04", inspect: "hwlab-cli client agent inspect --trace-id trc_case04" } },
subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline", worktreePath: "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-case04" },
agent: { providerProfile: "dsflash-go", projectId: "prj_hwpod_workbench", conversationId: "cnv_case04", sessionId: "ses_case04", threadId: "thread-case04", traceId: "trc_case04" },
trace: { traceId: "trc_case04", source: "hwlab-cli.client.agent.trace", hwpodCommandCount: 2, hwpodBuildCommandCount: 1, agentRun: { runId: "run_agent_case04", commandId: "cmd_case04" } },
diff: { path: "agent-diff.patch", sha256: "diff-sha", statusShort: " M projects/01_baseline/User/main.c\n", diffStat: " main.c | 3 +++\n", sourceRootChangedAfterPrepare: false, sourceRootChangedAfterAgent: true },
readableAgent: { renderer: "tools/src/hwlab-cli/trace-renderer:traceDisplayRows", finalResponse: { present: true, text: "Build completed" }, traceRender: { sourceEventCount: 3, renderedRowCount: 3 } },
keilJob: { jobId: "job-case04", status: "completed" },
files: [{ path: "evidence.json", bytes: 10, sha256: "evidence-sha" }]
});
await writeFile(path.join(registryRunDir, "agent-prompt.md"), "Implement ARM-2D integration\n", "utf8");
await writeFile(path.join(registryRunDir, "final-response.md"), "# CaseRun Final Response\n\nBuild completed\n", "utf8");
await writeFile(path.join(registryRunDir, "agent-diff.patch"), "diff --git a/main.c b/main.c\n+ d601_arm2d_demo_show();\n", "utf8");
await writeFile(path.join(registryRunDir, ".hwlab", "hwpod-spec.yaml"), hwpodSpecText(), "utf8");
await mkdir(cwd, { recursive: true });
const gitCommands: string[][] = [];
const result = await runHwlabCli(["case", "aggregate", caseId, "--case-repo", caseRepo, "--run-id", runId], {
cwd,
now: () => "2026-06-08T00:11:00.000Z",
runProcess: async (command) => {
gitCommands.push(command);
const rel = `runs/${caseId}/${runId}/aggregate.md`;
if (command[1] === "status") return { command, exitCode: 0, stdout: `?? ${rel}\n M runs/${caseId}/${runId}/result.json\n`, stderr: "" };
if (command[1] === "add") return { command, exitCode: 0, stdout: "", stderr: "" };
if (command[1] === "diff") return { command, exitCode: 1, stdout: "", stderr: "" };
if (command[1] === "commit") return { command, exitCode: 0, stdout: "[main abcdef1] data: aggregate caserun case04-completed-run\n", stderr: "" };
if (command[1] === "push") return { command, exitCode: 0, stdout: "", stderr: "To github.com:pikasTech/hwlab-case-registry.git\n" };
return { command, exitCode: 0, stdout: "", stderr: "" };
}
});
assert.equal(result.exitCode, 0);
assert.equal(result.payload.action, "case.aggregate");
assert.equal(result.payload.autoEvaluation, false);
assert.equal(result.payload.aggregate.registrySync.status, "pushed");
assert.equal(result.payload.aggregate.registrySync.path, `runs/${caseId}/${runId}/aggregate.md`);
assert.deepEqual(gitCommands.find((command) => command[1] === "add")?.slice(-2), ["--", `runs/${caseId}/${runId}/aggregate.md`]);
assert.deepEqual(gitCommands.find((command) => command[1] === "commit")?.slice(-2), ["--", `runs/${caseId}/${runId}/aggregate.md`]);
const aggregate = await readFile(path.join(registryRunDir, "aggregate.md"), "utf8");
assert.match(aggregate, /## /u);
assert.match(aggregate, /## HWPOD /u);
assert.match(aggregate, /## Code Agent /u);
assert.match(aggregate, /## Prompt/u);
assert.match(aggregate, /## Trace/u);
assert.doesNotMatch(aggregate, / 2\. ok commandExecution/u);
assert.match(aggregate, /<details>\n<summary> hwpod build --spec \.hwlab\/hwpod-spec\.yaml<\/summary>/u);
assert.match(aggregate, /<summary> hwpod build --spec \.hwlab\/hwpod-spec\.yaml<\/summary>[\s\S]*```text\nhwpod build --spec \.hwlab\/hwpod-spec\.yaml\nexitCode=0[\s\S]*<\/details>\n\nBuild completed\n\n## Final Response/u);
assert.doesNotMatch(aggregate, /<summary>.*/u);
assert.match(aggregate, /## Final Response/u);
assert.match(aggregate, /## Diff/u);
assert.match(aggregate, /Implement ARM-2D integration/u);
assert.match(aggregate, /Build completed/u);
assert.match(aggregate, /d601_arm2d_demo_show/u);
assert.match(aggregate, /autoEvaluation: false/u);
assert.doesNotMatch(aggregate, /pass\/fail|score|/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("case run status keeps worker control stage ahead of prepared worktree evidence", async () => {
const root = await mkdtempCaseRoot();
const cwd = path.join(root, "hwlab");
+39
View File
@@ -44,6 +44,45 @@ test("hwpod-ctl initializes, validates, edits, and binds workspace-local hwpod-s
await rm(root, { recursive: true, force: true });
}
});
test("hwpod cli materializes workspace spec from env", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-env-spec-"));
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
const content = [
"apiVersion: hwlab.dev/v0alpha1",
"kind: Hwpod",
"metadata:",
" uid: D601-F103-V2",
" name: d601-f103-v2",
"spec:",
" targetDevice:",
" board: D601-F103-V2",
" mcu: STM32F103",
" workspace:",
" path: F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-env-spec",
" debugProbe:",
" type: daplink",
" ioProbe:",
" uart:",
" id: uart/1",
" nodeBinding:",
" nodeId: node-d601-f103-v2",
""
].join("\n");
try {
const encoded = `base64:${Buffer.from(content, "utf8").toString("base64")}`;
const validate = await runHwpodCtl(["spec", "validate", "--spec", specPath], { now: () => NOW, env: { HWPOD_SPEC_CONTENT: encoded } });
assert.equal(validate.exitCode, 0);
assert.equal(validate.payload.document.metadata.name, "d601-f103-v2");
assert.match(await readFile(specPath, "utf8"), /caserun-env-spec/u);
const plan = await runHwpodCli(["workspace", "ls", ".", "--spec", specPath, "--dry-run"], { now: () => NOW, env: { HWPOD_SPEC_CONTENT: encoded } });
assert.equal(plan.exitCode, 0);
assert.equal(plan.payload.plan.resourceHints.workspacePath, "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-env-spec");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("hwpod-compiler-cli compiles workspace-local spec into node ops", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-compiler-"));
const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml");
+268 -11
View File
@@ -1,10 +1,10 @@
import { spawn } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { closeSync, openSync } from "node:fs";
import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "./hwlab-cli/trace-renderer.ts";
import { renderTraceRowsMarkdown, traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "./hwlab-cli/trace-renderer.ts";
import { readHwpodSpec } from "./hwpod-harness-lib.ts";
const DEFAULT_API_URL = "http://74.48.78.17:19667";
@@ -206,6 +206,7 @@ type ArchivedAgentTraceSnapshot = {
export async function caseCommand(context: CaseContext) {
const subcommand = context.rest[0] || "help";
if (["help", "--help", "-h"].includes(subcommand) || context.parsed.help === true) return caseHelp();
if (subcommand === "aggregate") return aggregateCaseRun(context);
if (subcommand === "prepare") return prepareCaseRun(context);
if (subcommand === "build") return buildCaseRun(context);
if (subcommand === "collect") return collectCaseRun(context);
@@ -239,6 +240,7 @@ export function caseHelp() {
`case prepare CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
`case build CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-dir DIR]`,
`case collect CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --run-dir DIR`,
`case aggregate CASE_ID --run-id RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--output aggregate.md]`,
`case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile PROFILE]`,
`case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
`case run status RUN_ID [--state-dir .state/hwlab-cli/caserun]`,
@@ -248,12 +250,33 @@ export function caseHelp() {
notes: [
"case build remains a compile-only HWPOD smoke stage.",
"case run prepares the subject worktree, submits a prompt to HWLAB Code Agent, records agent provenance and workspace diff, then runs compile validation.",
"case aggregate is a service-free post-processing command: it reads an existing case registry run and writes one aggregate markdown entry without pass/fail grading.",
"case run start/status/result/logs is the cli-spec async control surface for long CaseRun flows; start returns immediately and status/result/logs are short polling commands.",
"This version records raw stage evidence only; it does not auto-grade agent output, diff contents, or Keil evidence."
]
});
}
export async function aggregateCaseRun(context: CaseContext) {
const caseRepo = await resolveCaseRepo(context);
const aggregate = await writeCaseAggregateFromRegistry(context, caseRepo, { sync: true });
return ok("case.aggregate", {
caseId: aggregate.caseId,
runId: aggregate.runId,
caseRepo,
caseRepoRunDir: aggregate.caseRepoRunDir,
aggregate: {
path: aggregate.path,
rel: aggregate.rel,
bytes: aggregate.bytes,
sha256: aggregate.sha256,
registrySync: aggregate.registrySync
},
aggregationOnly: true,
autoEvaluation: false
}, "completed");
}
export async function runCaseRun(context: CaseContext) {
const prepared = await prepareCaseRun(context, "run");
const agentStage = await runAgentTaskStage(context, prepared.run);
@@ -726,7 +749,8 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) {
const threadId = session.threadId;
const traceId = text(context.parsed.traceId) || `trc_case_${slug(run.caseId)}_${randomUUID().replace(/-/gu, "")}`;
run = await updateRun(context, run, { stage: "agent-submitting", agent: agentFailureStage({ stageStatus: "session_created", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, sessionResponse: compactObject(session.body) }) });
const accepted = await submitAgentTask(context, { baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, prompt });
const hwpodSpecContent = await readFile(run.specPath, "utf8");
const accepted = await submitAgentTask(context, { baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, prompt, hwpodSpecContent });
if (!accepted.ok) {
const agent = agentFailureStage({ stageStatus: "submit_failed", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, accepted: accepted.body, error: accepted.error, sessionResponse: session.body });
const nextRun = await updateRun(context, run, { agent });
@@ -857,7 +881,7 @@ async function createAgentSession(context: CaseContext, input: { baseUrl: string
};
}
async function submitAgentTask(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId: string; threadId: string; traceId: string; prompt: string }) {
async function submitAgentTask(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId: string; threadId: string; traceId: string; prompt: string; hwpodSpecContent?: string }) {
return caseFetchJson(context, `${input.baseUrl}/v1/agent/chat`, {
method: "POST",
headers: { ...caseAgentHeaders(context), "x-trace-id": input.traceId, prefer: "respond-async", "x-hwlab-short-connection": "1" },
@@ -868,6 +892,7 @@ async function submitAgentTask(context: CaseContext, input: { baseUrl: string; p
threadId: input.threadId,
traceId: input.traceId,
providerProfile: input.providerProfile,
hwpodSpecContent: input.hwpodSpecContent,
shortConnection: true,
projectId: input.projectId,
updatedByClient: "hwlab-cli.case-run"
@@ -1745,6 +1770,223 @@ function markdownTableText(value: unknown, maxBytes = 200) {
return clipText(value, maxBytes).replace(/\|/gu, "\\|").replace(/\r?\n/gu, " ").trim();
}
async function writeCaseAggregateFromRegistry(context: CaseContext, caseRepo: string, options: { sync?: boolean } = {}) {
const caseId = requiredText(context.parsed.caseId ?? context.rest[1], "caseId");
const runId = await resolveAggregateRunId(caseRepo, caseId, text(context.parsed.runId ?? context.rest[2]));
const runRel = artifactRel(path.join("runs", caseId, runId));
const caseRepoRunDir = path.join(caseRepo, runRel);
const evidence = await readJsonIfExists(path.join(caseRepoRunDir, "evidence.json"));
const run = await readJsonIfExists(path.join(caseRepoRunDir, "run.json"));
const manifest = await readJsonIfExists(path.join(caseRepoRunDir, "artifact-manifest.json"));
if (!evidence && !run && !manifest) throw cliError("case_registry_run_not_found", "case registry run artifacts were not found", { caseRepoRunDir, caseId, runId });
const outputRel = aggregateOutputRel(context);
const outputPath = path.join(caseRepoRunDir, outputRel);
const markdown = await renderCaseAggregateMarkdown({ caseRepoRunDir, caseId, runId, evidence, run, manifest });
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, markdown, "utf8");
const file = await registryAggregateFileRecord(outputPath);
const registrySync = options.sync === true ? await syncCaseRegistryRel(context, caseRepo, artifactRel(path.join(runRel, outputRel)), `data: aggregate caserun ${runId}`) : null;
return { caseId, runId, caseRepoRunDir, path: outputPath, rel: artifactRel(path.join(runRel, outputRel)), bytes: file.bytes, sha256: file.sha256, registrySync };
}
async function resolveAggregateRunId(caseRepo: string, caseId: string, explicit: string) {
if (explicit) return explicit;
const runRoot = path.join(caseRepo, "runs", caseId);
let entries: string[] = [];
try {
entries = await readdir(runRoot);
} catch {
throw cliError("case_registry_runs_not_found", "case registry has no run directory for this case", { caseId, runRoot });
}
const runs = entries.filter((entry) => !entry.startsWith(".")).sort();
if (runs.length === 0) throw cliError("case_registry_runs_empty", "case registry has no runs for this case", { caseId, runRoot });
return runs.at(-1) ?? "";
}
function aggregateOutputRel(context: CaseContext) {
const value = text(context.parsed.output ?? context.parsed.outputPath ?? context.parsed.aggregatePath) || "aggregate.md";
const rel = artifactRel(value).replace(/^\/+/, "");
if (!rel || rel.includes("../") || rel === ".." || path.isAbsolute(value)) throw cliError("invalid_aggregate_output_path", "aggregate output must be a relative path inside the registry run directory", { output: value });
if (!rel.endsWith(".md")) throw cliError("invalid_aggregate_output_ext", "aggregate output must be a markdown file", { output: value });
return rel;
}
async function renderCaseAggregateMarkdown(input: { caseRepoRunDir: string; caseId: string; runId: string; evidence: any; run: any; manifest: any }) {
const prompt = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-prompt.md"));
const finalResponse = await readTextIfExists(path.join(input.caseRepoRunDir, "final-response.md"));
const diffPatch = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-diff.patch"));
const traceMarkdown = await readTextIfExists(path.join(input.caseRepoRunDir, "agent-trace.md"));
const specText = await readTextIfExists(path.join(input.caseRepoRunDir, ".hwlab", "hwpod-spec.yaml"));
const messages = await readJsonIfExists(path.join(input.caseRepoRunDir, "agent-messages.json"));
const evidence = input.evidence ?? {};
const run = input.run ?? {};
const manifest = input.manifest ?? {};
const traceLookup = manifest.traceLookup ?? evidence.traceLookup ?? run.agent?.traceLookup;
const commands = traceLookup?.commands ?? {};
const agent = manifest.agent ?? manifest.trace ?? evidence.agent ?? run.agent ?? {};
const subject = manifest.subject ?? evidence.subject ?? run.subject ?? {};
const trace = manifest.trace ?? evidence.agentTrace ?? run.agentTrace ?? {};
const diff = manifest.diff ?? evidence.agentDiff ?? run.agentDiff ?? {};
const keilJob = manifest.keilJob ?? evidence.keilJob ?? null;
const agentStage = manifest.agentStage ?? agentStageSummary(evidence);
const traceBody = aggregateTraceBody(messages, traceMarkdown);
return `${[
`# HWPOD CaseRun Aggregate: ${input.caseId}`,
``,
`- caseId: ${input.caseId}`,
`- runId: ${input.runId}`,
`- generatedFrom: case registry artifacts`,
`- aggregationOnly: true`,
`- autoEvaluation: false`,
`- primaryEntry: aggregate.md`,
``,
`## 运行环境信息`,
``,
aggregateBullets([
["apiUrl", run.apiUrl ?? evidence.apiUrl],
["compileOnly", evidence.compileOnly ?? run.compileOnly],
["caseRepoRunDir", input.caseRepoRunDir],
["sourceRunDir", manifest.sourceRunDir ?? run.runDir],
["createdAt", run.createdAt],
["completedAt", run.completedAt ?? run._control?.completedAt],
["runnerPostAgentCompileCheck", manifest.decisions?.runnerPostAgentCompileCheck ?? evidence.decisions?.runnerPostAgentCompileCheck ?? "recorded"]
]),
``,
`## HWPOD 信息`,
``,
aggregateBullets([
["subjectRepoLocalPath", subject.repoLocalPath],
["subjectCommitId", subject.commitId],
["subjectSubdir", subject.subdir],
["subjectWorktreePath", subject.worktreePath],
["sourceRootBaselineStatus", subject.sourceRootBaseline?.statusShort],
["sourceRootAfterPrepareStatus", subject.sourceRootAfterPrepare?.statusShort],
["keilJobId", keilJob?.jobId],
["keilStatus", keilJob?.status],
["hwpodExitCode", evidence.hwpod?.exitCode]
]),
specText ? aggregateDetails("Run-local HWPOD spec", "yaml", specText) : `_Run-local HWPOD spec artifact is missing._`,
``,
`## Code Agent 信息`,
``,
aggregateBullets([
["providerProfile", agent.providerProfile],
["projectId", agent.projectId],
["conversationId", agent.conversationId],
["sessionId", agent.sessionId],
["threadId", agent.threadId],
["traceId", agent.traceId ?? trace.traceId],
["agentRunId", trace.agentRun?.runId],
["agentCommandId", trace.agentRun?.commandId],
["traceSource", trace.source],
["traceCommand", commands.trace],
["resultCommand", commands.result],
["inspectCommand", commands.inspect]
]),
``,
`## 输入 Prompt`,
``,
prompt ? aggregateFence("markdown", prompt) : `_agent-prompt.md artifact is missing._`,
``,
`## 低噪声 Trace`,
``,
`- renderer: ${manifest.readableAgent?.renderer ?? messages?.renderer ?? CASE_TRACE_RENDERER}`,
`- sourceEventCount: ${manifest.readableAgent?.traceRender?.sourceEventCount ?? messages?.sourceEventCount ?? trace.sourceEventCount ?? ""}`,
`- renderedRowCount: ${manifest.readableAgent?.traceRender?.renderedRowCount ?? messages?.renderedRowCount ?? trace.renderedRowCount ?? ""}`,
`- hwpodCommandCount: ${trace.hwpodCommandCount ?? evidence.agentTrace?.hwpodCommandCount ?? ""}`,
`- hwpodBuildCommandCount: ${trace.hwpodBuildCommandCount ?? evidence.agentTrace?.hwpodBuildCommandCount ?? ""}`,
``,
traceBody,
``,
`## Final Response`,
``,
finalResponse ? finalResponse.trimEnd() : `_final-response.md artifact is missing._`,
``,
`## 最后 Diff`,
``,
aggregateBullets([
["statusShort", diff.statusShort],
["diffStat", diff.diffStat],
["diffSha256", diff.sha256 ?? diff.diffPatchSha256],
["sourceRootChangedAfterPrepare", diff.sourceRootChangedAfterPrepare],
["sourceRootChangedAfterAgent", diff.sourceRootChangedAfterAgent]
]),
diffPatch ? aggregateFence("diff", diffPatch) : `_agent-diff.patch artifact is missing._`,
``,
`## 原始产物索引`,
``,
aggregateArtifactIndex(manifest),
``,
`## 说明`,
``,
`本文件只做已完成 CaseRun registry 产物的二次整理聚合,不新增自动结论、门禁或等级。`
].join("\n")}\n`;
}
function aggregateTraceBody(messages: any, traceMarkdown: string) {
const rows = arrayOfObjects(messages?.rows);
if (rows.length > 0) {
return renderTraceRowsMarkdown(rows.map((row) => traceEventRowFromAggregate(row)));
}
if (traceMarkdown) {
return renderTraceRowsMarkdown([{ rowId: "trace-markdown", seq: null, tone: "source", header: "Rendered trace markdown", body: traceMarkdown, bodyFormat: "markdown" }]);
}
return `_No readable trace artifact was found._`;
}
function traceEventRowFromAggregate(row: Record<string, unknown>): TraceEventRow {
const bodyFormat = text(row.bodyFormat);
return {
rowId: text(row.rowId) || text(row.header) || "trace-row",
seq: numberOption(row.seq) ?? null,
tone: traceRowTone(row.tone),
header: text(row.header) || text(row.rowId) || "trace row",
body: row.body === undefined || row.body === null ? null : String(row.body),
terminal: row.terminal === true ? true : undefined,
bodyFormat: bodyFormat === "markdown" || bodyFormat === "text" ? bodyFormat : undefined
};
}
function aggregateBullets(items: Array<[string, unknown]>) {
const lines = items.map(([key, value]) => `- ${key}: ${aggregateInlineValue(value)}`).filter((line) => !line.endsWith(": "));
return lines.length > 0 ? lines.join("\n") : `_No data recorded._`;
}
function aggregateInlineValue(value: unknown) {
if (value === undefined || value === null || value === "") return "";
if (typeof value === "boolean" || typeof value === "number") return String(value);
if (typeof value === "object") return `\`${markdownInline(JSON.stringify(value))}\``;
return markdownInline(String(value));
}
function markdownInline(value: string) {
return value.replace(/\r?\n/gu, " ").trim();
}
function aggregateDetails(summary: string, language: string, body: string) {
return [`<details>`, `<summary>${escapeHtml(summary)}</summary>`, ``, aggregateFence(language, body), ``, `</details>`].join("\n");
}
function escapeHtml(value: string) {
return String(value).replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
}
function aggregateFence(language: string, body: string) {
const fence = body.includes("```") ? "````" : "```";
return `${fence}${language}\n${body.trimEnd()}\n${fence}`;
}
function aggregateArtifactIndex(manifest: any) {
const files = arrayOfObjects(manifest?.files);
if (files.length === 0) return `_artifact-manifest.json is missing or has no files list._`;
return [`| Path | Bytes | SHA-256 |`, `|---|---:|---|`, ...files.map((file) => `| ${markdownTableText(file.path, 220)} | ${file.bytes ?? ""} | ${markdownTableText(file.sha256, 80)} |`)].join("\n");
}
async function registryAggregateFileRecord(file: string) {
const content = await readFile(file);
return { bytes: content.length, sha256: sha256Buffer(content) };
}
async function archiveCaseRunArtifacts(context: CaseContext, run: PreparedCaseRun, evidence: any, options: { sync?: boolean } = {}): Promise<CaseRegistryArchive> {
const runRel = caseRegistryRunRel(run);
const caseRepoRunDir = path.join(run.caseRepo, runRel);
@@ -1759,6 +2001,7 @@ async function archiveCaseRunArtifacts(context: CaseContext, run: PreparedCaseRu
["evidence.json", path.join(run.runDir, "evidence.json")],
["summary.md", undefined]
]);
if (await fileExists(path.join(caseRepoRunDir, "aggregate.md"))) materialized.set("aggregate.md", undefined);
const readableAgent = await writeReadableAgentArtifacts(caseRepoRunDir, run, evidence, agentTraceSnapshot);
for (const rel of readableAgent.generatedFiles) materialized.set(rel, undefined);
const skippedFiles: string[] = [];
@@ -1781,22 +2024,25 @@ async function archiveCaseRunArtifacts(context: CaseContext, run: PreparedCaseRu
}
async function syncCaseRegistryRun(context: CaseContext, run: PreparedCaseRun, runRel: string) {
return syncCaseRegistryRel(context, run.caseRepo, runRel, `data: archive caserun ${run.runId}`);
}
async function syncCaseRegistryRel(context: CaseContext, caseRepo: string, relInput: string, commitMessage: string) {
if (context.parsed.noCaseRepoGitSync === true || context.parsed.noRegistryGitSync === true) {
return { status: "skipped", reason: "disabled_by_cli_flag" };
}
const rel = artifactRel(runRel);
const before = await gitProcess(context, run.caseRepo, ["status", "--short", "--", rel]);
const rel = artifactRel(relInput);
const before = await gitProcess(context, caseRepo, ["status", "--short", "--", rel]);
if (before.exitCode !== 0) return { status: "failed", stage: "status_before", command: before.command, exitCode: before.exitCode, stderr: clipText(before.stderr) };
if (!text(before.stdout)) return { status: "unchanged", path: rel };
const add = await gitProcess(context, run.caseRepo, ["add", "--", rel]);
const add = await gitProcess(context, caseRepo, ["add", "--", rel]);
if (add.exitCode !== 0) return { status: "failed", stage: "add", command: add.command, exitCode: add.exitCode, stderr: clipText(add.stderr) };
const staged = await gitProcess(context, run.caseRepo, ["diff", "--cached", "--quiet", "--", rel]);
const staged = await gitProcess(context, caseRepo, ["diff", "--cached", "--quiet", "--", rel]);
if (staged.exitCode === 0) return { status: "unchanged_after_add", path: rel };
if (staged.exitCode !== 1) return { status: "failed", stage: "diff_cached", command: staged.command, exitCode: staged.exitCode, stderr: clipText(staged.stderr) };
const commitMessage = `data: archive caserun ${run.runId}`;
const commit = await gitProcess(context, run.caseRepo, ["commit", "-m", commitMessage, "--", rel]);
const commit = await gitProcess(context, caseRepo, ["commit", "-m", commitMessage, "--", rel]);
if (commit.exitCode !== 0) return { status: "failed", stage: "commit", command: commit.command, exitCode: commit.exitCode, stderr: clipText(commit.stderr), stdout: clipText(commit.stdout) };
const push = await gitProcess(context, run.caseRepo, ["push", "origin", "HEAD"]);
const push = await gitProcess(context, caseRepo, ["push", "origin", "HEAD"]);
if (push.exitCode !== 0) return { status: "failed", stage: "push", command: push.command, exitCode: push.exitCode, stderr: clipText(push.stderr), stdout: clipText(push.stdout) };
const commitSha = text(commit.stdout.match(/\[\S+\s+([0-9a-f]{7,40})\]/iu)?.[1]);
return clean({ status: "pushed", path: rel, commitMessage, commitSha, commitStdout: clipText(commit.stdout, 1000), pushStdout: clipText(push.stdout, 1000), pushStderr: clipText(push.stderr, 1000) });
@@ -2134,6 +2380,7 @@ function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseRun, evi
traceRender: readableAgent.traceRender,
finalResponse: readableAgent.finalResponse
}),
aggregate: aggregateManifestEntry(files),
prompt: clean({ path: "agent-prompt.md", sha256: evidence.agent?.promptSha256 ?? run.agent?.promptSha256 }),
diff: clean({
path: "agent-diff.patch",
@@ -2160,6 +2407,12 @@ function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseRun, evi
});
}
function aggregateManifestEntry(files: CaseRegistryArchiveFile[]) {
const file = files.find((entry) => entry.path === "aggregate.md");
if (!file) return undefined;
return { path: file.path, bytes: file.bytes, sha256: file.sha256, primaryEntry: true, aggregationOnly: true, autoEvaluation: false };
}
function caseRunArtifactTrace(run: PreparedCaseRun, evidence: any) {
return clean({
traceId: evidence.agent?.traceId ?? run.agent?.traceId ?? evidence.agentTrace?.traceId ?? run.agentTrace?.traceId,
@@ -2319,6 +2572,10 @@ async function fileSize(file: string) {
try { return (await stat(file)).size; } catch { return 0; }
}
async function fileExists(file: string) {
try { return (await stat(file)).isFile(); } catch { return false; }
}
async function readTail(file: string, maxBytes: number) {
try {
const content = await readFile(file);
+63
View File
@@ -79,6 +79,43 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event));
}
export function renderTraceRowsMarkdown(rows: TraceEventRow[] = []): string {
if (!Array.isArray(rows) || rows.length === 0) return `_No rendered trace rows were returned._`;
return rows.map((row, index) => renderTraceRowMarkdown(row, index + 1)).join("\n\n");
}
export function renderTraceRowMarkdown(row: TraceEventRow, index = 1): string {
if (!isToolTraceRow(row)) return renderTraceMessageRowMarkdown(row);
const body = renderTraceRowDetailBody(row) || row.header || `trace row ${index}`;
return [
`<details>`,
`<summary>${escapeHtml(`已运行 ${traceToolSummary(row)}`)}</summary>`,
``,
traceMarkdownFence(row.bodyFormat === "markdown" ? "markdown" : "text", body),
``,
`</details>`
].join("\n");
}
function renderTraceMessageRowMarkdown(row: TraceEventRow): string {
const body = row.body?.trimEnd();
if (body) return body;
return row.header || `_No readable trace row body._`;
}
function isToolTraceRow(row: TraceEventRow): boolean {
return row.rowId.startsWith("tool:") || /commandExecution/u.test(row.header);
}
function traceToolSummary(row: TraceEventRow): string {
const firstLine = traceMarkdownPreviewLines(row.body).find((line) => !/^(stdout|stderr|exitCode|rowId|terminal):/iu.test(line.trim()));
return compactTraceOneLine(stripTraceToolOutputFromSummary(firstLine || row.header || "tool call"), 320);
}
function stripTraceToolOutputFromSummary(value: string): string {
return value.replace(/\s+(stdout:|stderr:|exitCode=).*$/isu, "").trim();
}
interface AssistantRowState {
rowIndex: number;
comparableText: string;
@@ -429,6 +466,32 @@ function traceDisplayBody(event: TraceEvent): string | null {
return lines.length > 0 ? lines.join("\n").slice(0, 1400) : null;
}
function traceMarkdownPreviewLines(body: string | null | undefined): string[] {
return String(body ?? "")
.replace(/\r\n|\r/gu, "\n")
.trim()
.split("\n")
.map((line) => line.trimEnd())
.filter((line) => Boolean(line.trim()));
}
function renderTraceRowDetailBody(row: TraceEventRow): string {
return [
row.body?.trimEnd() ?? "",
row.rowId ? `rowId: ${row.rowId}` : "",
row.terminal === true ? "terminal: true" : ""
].filter(Boolean).join("\n");
}
function escapeHtml(value: string): string {
return String(value).replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
}
function traceMarkdownFence(language: string, body: string): string {
const fence = body.includes("```") ? "````" : "```";
return `${fence}${language}\n${body.trimEnd()}\n${fence}`;
}
function traceStatusToken(event: TraceEvent): string {
const status = String(event.status ?? "");
if (status === "completed" || status === "succeeded") return "ok";
+41 -1
View File
@@ -9,6 +9,7 @@ import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "./hwpod-node-op
import { resolveRuntimeEndpoint, runtimeEndpointVisibility } from "./runtime-endpoint-resolver.ts";
export const DEFAULT_HWPOD_SPEC_PATH = ".hwlab/hwpod-spec.yaml";
const HWPOD_SPEC_ENV_NAMES = ["HWPOD_SPEC_CONTENT"];
const COMPILER_NAME = "hwpod-compiler-cli";
const CTL_NAME = "hwpod-ctl";
@@ -57,9 +58,11 @@ async function readCliStdinForCommand(argv: string[]): Promise<string | undefine
}
export async function runHwpodCompilerCli(argv: string[], options: { env?: EnvLike; now?: () => string } = {}) {
const env = options.env ?? process.env;
const now = options.now ?? (() => new Date().toISOString());
try {
const parsed = parseOptions(argv);
await ensureSpecFromEnv(specPathFrom(parsed), env);
const command = parsed._[0] || "help";
if (["help", "--help", "-h"].includes(command)) return result(0, compilerHelp(), now);
if (command !== "compile") throw cliError("unsupported_compiler_command", `unsupported hwpod-compiler-cli command: ${command}`);
@@ -75,15 +78,20 @@ export async function runHwpodCompilerCli(argv: string[], options: { env?: EnvLi
}
export async function runHwpodCtl(argv: string[], options: { env?: EnvLike; now?: () => string } = {}) {
const env = options.env ?? process.env;
const now = options.now ?? (() => new Date().toISOString());
try {
const parsed = parseOptions(argv);
const group = parsed._[0] || "help";
if (["help", "--help", "-h"].includes(group)) return result(0, ctlHelp(), now);
if (group === "bind") return result(0, await bindSpec(parsed, now), now);
if (group === "bind") {
await ensureSpecFromEnv(specPathFrom(parsed), env);
return result(0, await bindSpec(parsed, now), now);
}
if (group !== "spec") throw cliError("unsupported_ctl_command", `unsupported hwpod-ctl command: ${group}`);
const subcommand = parsed._[1] || "validate";
if (subcommand === "init") return result(0, await initSpec(parsed, now), now);
await ensureSpecFromEnv(specPathFrom(parsed), env);
if (subcommand === "validate") return result(0, await validateSpec(parsed), now);
if (subcommand === "show") return result(0, await showSpec(parsed), now);
if (subcommand === "set") return result(0, await setSpec(parsed, now), now);
@@ -99,6 +107,7 @@ export async function runHwpodCli(argv: string[], options: { env?: EnvLike; fetc
const now = options.now ?? (() => new Date().toISOString());
try {
const parsed = parseOptions(argv);
await ensureSpecFromEnv(specPathFrom(parsed), env);
const command = parsed._[0] || "help";
if (["help", "--help", "-h"].includes(command)) return result(0, cliHelp(), now);
if (parsed.help === true || parsed.h === true) return result(0, hwpodCliCommandHelp(command, parsed), now);
@@ -126,6 +135,37 @@ export async function readHwpodSpec(specPath = DEFAULT_HWPOD_SPEC_PATH) {
return normalizeHwpodSpec(parseSimpleYaml(text, specPath), specPath);
}
async function ensureSpecFromEnv(specPath = DEFAULT_HWPOD_SPEC_PATH, env: EnvLike = process.env) {
for (const name of HWPOD_SPEC_ENV_NAMES) {
const raw = text(env[name]);
if (!raw) continue;
try {
await readFile(specPath, "utf8");
return { source: "existing", specPath };
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
const content = decodeHwpodSpecEnv(raw);
await mkdir(path.dirname(specPath), { recursive: true });
await writeFile(specPath, content.endsWith("\n") ? content : `${content}\n`, "utf8");
return { source: name, specPath };
}
return null;
}
function decodeHwpodSpecEnv(value: string) {
if (/^base64:/iu.test(value)) return Buffer.from(value.replace(/^base64:/iu, ""), "base64").toString("utf8");
if (!/[\r\n]/u.test(value) && /^[A-Za-z0-9+/=_-]+$/u.test(value)) {
try {
const decoded = Buffer.from(value, "base64").toString("utf8");
if (/^apiVersion:/mu.test(decoded) && /\nkind:\s*Hwpod\b/mu.test(decoded)) return decoded;
} catch {
// Plain YAML without newlines is allowed to fall through unchanged.
}
}
return value;
}
export function compileHwpodNodeOpsPlan({ document, specPath = DEFAULT_HWPOD_SPEC_PATH, intent, args = {}, now = () => new Date().toISOString() }: any) {
const normalizedIntent = normalizeIntent(intent);
const nodeId = document.spec.nodeBinding.nodeId;