From 1b76fb37602f2e266a17a6c5f9e2963b4ea85fde Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sun, 7 Jun 2026 21:26:07 +0800 Subject: [PATCH] fix: enable v02 env reuse for runtime services --- deploy/deploy.json | 12 +- deploy/runtime/boot/hwlab-agent-skills.sh | 133 +++++++++++ deploy/runtime/boot/hwlab-cloud-api.sh | 46 ++++ deploy/runtime/boot/hwlab-edge-proxy.sh | 32 +++ deploy/runtime/boot/hwlab-gateway.sh | 30 +++ docs/reference/spec-v02-cicd.md | 18 +- scripts/g14-ci-plan.test.mjs | 277 +++++++++++++--------- scripts/src/g14-ci-plan-lib.mjs | 20 +- 8 files changed, 434 insertions(+), 134 deletions(-) create mode 100644 deploy/runtime/boot/hwlab-agent-skills.sh create mode 100644 deploy/runtime/boot/hwlab-cloud-api.sh create mode 100644 deploy/runtime/boot/hwlab-edge-proxy.sh create mode 100644 deploy/runtime/boot/hwlab-gateway.sh diff --git a/deploy/deploy.json b/deploy/deploy.json index e8564d98..f8cb8749 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -123,10 +123,18 @@ "runtimePath": "deploy/gitops/g14/runtime-v02", "imageTagMode": "full", "envReuseServices": [ - "hwlab-cloud-web" + "hwlab-cloud-api", + "hwlab-cloud-web", + "hwlab-gateway", + "hwlab-edge-proxy", + "hwlab-agent-skills" ], "bootScripts": { - "hwlab-cloud-web": "deploy/runtime/boot/hwlab-cloud-web.sh" + "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" }, "services": [ { diff --git a/deploy/runtime/boot/hwlab-agent-skills.sh b/deploy/runtime/boot/hwlab-agent-skills.sh new file mode 100644 index 00000000..710c1f96 --- /dev/null +++ b/deploy/runtime/boot/hwlab-agent-skills.sh @@ -0,0 +1,133 @@ +#!/bin/sh +set -eu + +export HWLAB_SERVICE_ID="hwlab-agent-skills" +export HWLAB_ARTIFACT_KIND="skills-bundle" +export HWLAB_SERVICE_ENTRYPOINT="skills/hwlab-agent-runtime/SKILL.md" +export HWLAB_RUNTIME_MODE="${HWLAB_RUNTIME_MODE:-env-reuse-git-mirror-checkout}" +export HWLAB_COMMIT_ID="${HWLAB_BOOT_COMMIT:?HWLAB_BOOT_COMMIT is required}" +export HWLAB_REVISION="${HWLAB_BOOT_COMMIT}" +export HWLAB_SKILLS_COMMIT_ID="${HWLAB_SKILLS_COMMIT_ID:-${HWLAB_BOOT_COMMIT}}" +export HWLAB_IMAGE="${HWLAB_ENVIRONMENT_IMAGE:-${HWLAB_IMAGE:-}}" +export HWLAB_IMAGE_DIGEST="${HWLAB_ENVIRONMENT_DIGEST:-${HWLAB_IMAGE_DIGEST:-unknown}}" +export PORT="${PORT:-${HWLAB_PORT:-7430}}" +export HWLAB_PORT="$PORT" + +bun_bin="${HWLAB_BUN_COMMAND:-}" +if [ -z "$bun_bin" ]; then + if command -v bun >/dev/null 2>&1; then + bun_bin="$(command -v bun)" + elif [ -x /usr/local/bin/bun ]; then + bun_bin="/usr/local/bin/bun" + elif [ -x ./node_modules/.bin/bun ]; then + bun_bin="./node_modules/.bin/bun" + else + echo "hwlab-agent-skills boot failed: bun not found" >&2 + exit 127 + fi +fi + +cat > .hwlab-agent-skills-runtime.mjs <<'NODE' +import { Buffer } from "node:buffer"; +import { createServer } from "node:http"; +import { readdirSync } from "node:fs"; +import path from "node:path"; + +const serviceId = process.env.HWLAB_SERVICE_ID || "hwlab-agent-skills"; +const environment = process.env.HWLAB_ENVIRONMENT || "v02"; +const port = Number.parseInt(process.env.PORT || process.env.HWLAB_PORT || "7430", 10); +const processStartedAt = new Date(); + +function sendJson(response, statusCode, body) { + const payload = JSON.stringify(body, null, 2); + response.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload) + }); + response.end(payload); +} + +function imageTagFromReference(imageReference) { + const source = String(imageReference || "").trim(); + const slashIndex = source.lastIndexOf("/"); + const colonIndex = source.lastIndexOf(":"); + if (colonIndex <= slashIndex) return null; + return source.slice(colonIndex + 1) || null; +} + +function shortRevision(value) { + const source = String(value || "").trim(); + return source.length >= 12 ? source.slice(0, 12) : source || "unknown"; +} + +function skillNames() { + const root = path.join(process.cwd(), "skills"); + try { + return readdirSync(root, { withFileTypes: true }) + .filter((item) => item.isDirectory()) + .map((item) => item.name) + .sort(); + } catch { + return []; + } +} + +function healthPayload() { + const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_BOOT_COMMIT || "unknown"; + const imageReference = process.env.HWLAB_IMAGE || process.env.HWLAB_ENVIRONMENT_IMAGE || "unknown"; + const names = skillNames(); + return { + serviceId, + environment, + status: "ok", + artifactKind: "skills-bundle", + runtimeMode: process.env.HWLAB_RUNTIME_MODE || "env-reuse-git-mirror-checkout", + revision: process.env.HWLAB_REVISION || commitId, + commit: { + id: commitId, + source: process.env.HWLAB_BUILD_SOURCE || "git-mirror-runtime-checkout" + }, + image: { + reference: imageReference, + tag: imageTagFromReference(imageReference) || shortRevision(commitId), + digest: process.env.HWLAB_IMAGE_DIGEST || process.env.HWLAB_ENVIRONMENT_DIGEST || "unknown" + }, + boot: { + repo: process.env.HWLAB_BOOT_REPO || null, + commit: process.env.HWLAB_BOOT_COMMIT || null, + script: process.env.HWLAB_BOOT_SH || null + }, + skills: { + root: `${process.cwd()}/skills`, + count: names.length, + names, + commitId: process.env.HWLAB_SKILLS_COMMIT_ID || commitId, + entrypoint: process.env.HWLAB_SERVICE_ENTRYPOINT || "skills/hwlab-agent-runtime/SKILL.md" + }, + process: { + pid: process.pid, + startedAt: processStartedAt.toISOString(), + uptimeSeconds: Math.round((Date.now() - processStartedAt.getTime()) / 1000) + } + }; +} + +const server = createServer((request, response) => { + const url = new URL(request.url || "/", "http://hwlab-agent-skills.local"); + if (url.pathname === "/health/live" || url.pathname === "/health") { + sendJson(response, 200, healthPayload()); + return; + } + if (url.pathname === "/help") { + sendJson(response, 200, { serviceId, commands: ["GET /health", "GET /health/live", "GET /help"] }); + return; + } + sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname }); +}); + +server.listen(port, "0.0.0.0", () => { + process.stdout.write(JSON.stringify({ serviceId, environment, status: "listening", port }) + "\n"); +}); +NODE + +exec "$bun_bin" .hwlab-agent-skills-runtime.mjs diff --git a/deploy/runtime/boot/hwlab-cloud-api.sh b/deploy/runtime/boot/hwlab-cloud-api.sh new file mode 100644 index 00000000..582c09b1 --- /dev/null +++ b/deploy/runtime/boot/hwlab-cloud-api.sh @@ -0,0 +1,46 @@ +#!/bin/sh +set -eu + +export HWLAB_SERVICE_ID="hwlab-cloud-api" +export HWLAB_ARTIFACT_KIND="bun-command" +export HWLAB_SERVICE_ENTRYPOINT="cmd/hwlab-cloud-api/main.ts" +export HWLAB_RUNTIME_MODE="${HWLAB_RUNTIME_MODE:-env-reuse-git-mirror-checkout}" +export HWLAB_COMMIT_ID="${HWLAB_BOOT_COMMIT:?HWLAB_BOOT_COMMIT is required}" +export HWLAB_REVISION="${HWLAB_BOOT_COMMIT}" +export HWLAB_IMAGE="${HWLAB_ENVIRONMENT_IMAGE:-${HWLAB_IMAGE:-}}" +export HWLAB_IMAGE_DIGEST="${HWLAB_ENVIRONMENT_DIGEST:-${HWLAB_IMAGE_DIGEST:-unknown}}" +export PORT="${PORT:-${HWLAB_CLOUD_API_PORT:-${HWLAB_PORT:-6667}}}" +export HWLAB_PORT="$PORT" +export HWLAB_CLOUD_API_PORT="$PORT" +export HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT="${HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT:-${HWLAB_BOOT_COMMIT}}" +export HWLAB_CODE_AGENT_AGENTRUN_REPO_URL="${HWLAB_CODE_AGENT_AGENTRUN_REPO_URL:-${HWLAB_BOOT_READ_URL:-http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git}}" +export HWLAB_CODE_AGENT_WORKSPACE="${HWLAB_CODE_AGENT_WORKSPACE:-$PWD}" +export HWLAB_CODE_AGENT_CODEX_WORKSPACE="${HWLAB_CODE_AGENT_CODEX_WORKSPACE:-$HWLAB_CODE_AGENT_WORKSPACE}" +export CODEX_HOME="${CODEX_HOME:-/codex-home}" + +if [ -z "${HWLAB_CODE_AGENT_CODEX_COMMAND:-}" ] || [ "${HWLAB_CODE_AGENT_CODEX_COMMAND:-}" = "/app/node_modules/.bin/codex" ]; then + export HWLAB_CODE_AGENT_CODEX_COMMAND="$PWD/node_modules/.bin/codex" +fi +if [ -z "${HWLAB_PREINSTALLED_SKILLS_DIR:-}" ] || [ "${HWLAB_PREINSTALLED_SKILLS_DIR:-}" = "/app/skills" ]; then + export HWLAB_PREINSTALLED_SKILLS_DIR="$PWD/skills" +fi +export HWLAB_USER_SKILLS_DIR="${HWLAB_USER_SKILLS_DIR:-/data/user-skills}" +if [ -z "${HWLAB_CODE_AGENT_SKILLS_DIRS:-}" ] || [ "${HWLAB_CODE_AGENT_SKILLS_DIRS:-}" = "/app/skills:/data/user-skills" ]; then + export HWLAB_CODE_AGENT_SKILLS_DIRS="$HWLAB_PREINSTALLED_SKILLS_DIR:$HWLAB_USER_SKILLS_DIR" +fi + +bun_bin="${HWLAB_BUN_COMMAND:-}" +if [ -z "$bun_bin" ]; then + if command -v bun >/dev/null 2>&1; then + bun_bin="$(command -v bun)" + elif [ -x /usr/local/bin/bun ]; then + bun_bin="/usr/local/bin/bun" + elif [ -x ./node_modules/.bin/bun ]; then + bun_bin="./node_modules/.bin/bun" + else + echo "hwlab-cloud-api boot failed: bun not found" >&2 + exit 127 + fi +fi + +exec "$bun_bin" run cmd/hwlab-cloud-api/main.ts diff --git a/deploy/runtime/boot/hwlab-edge-proxy.sh b/deploy/runtime/boot/hwlab-edge-proxy.sh new file mode 100644 index 00000000..4f21e41e --- /dev/null +++ b/deploy/runtime/boot/hwlab-edge-proxy.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -eu + +export HWLAB_SERVICE_ID="hwlab-edge-proxy" +export HWLAB_ARTIFACT_KIND="bun-command" +export HWLAB_SERVICE_ENTRYPOINT="cmd/hwlab-edge-proxy/main.ts" +export HWLAB_RUNTIME_MODE="${HWLAB_RUNTIME_MODE:-env-reuse-git-mirror-checkout}" +export HWLAB_COMMIT_ID="${HWLAB_BOOT_COMMIT:?HWLAB_BOOT_COMMIT is required}" +export HWLAB_REVISION="${HWLAB_BOOT_COMMIT}" +export HWLAB_IMAGE="${HWLAB_ENVIRONMENT_IMAGE:-${HWLAB_IMAGE:-}}" +export HWLAB_IMAGE_DIGEST="${HWLAB_ENVIRONMENT_DIGEST:-${HWLAB_IMAGE_DIGEST:-unknown}}" +edge_port="${HWLAB_EDGE_PORT:-${PORT:-${HWLAB_PORT:-6667}}}" +export PORT="${PORT:-$edge_port}" +export HWLAB_PORT="$PORT" +export HWLAB_EDGE_PORT="$edge_port" +export HWLAB_EDGE_LISTEN="${HWLAB_EDGE_LISTEN:-0.0.0.0:$edge_port}" + +bun_bin="${HWLAB_BUN_COMMAND:-}" +if [ -z "$bun_bin" ]; then + if command -v bun >/dev/null 2>&1; then + bun_bin="$(command -v bun)" + elif [ -x /usr/local/bin/bun ]; then + bun_bin="/usr/local/bin/bun" + elif [ -x ./node_modules/.bin/bun ]; then + bun_bin="./node_modules/.bin/bun" + else + echo "hwlab-edge-proxy boot failed: bun not found" >&2 + exit 127 + fi +fi + +exec "$bun_bin" run cmd/hwlab-edge-proxy/main.ts diff --git a/deploy/runtime/boot/hwlab-gateway.sh b/deploy/runtime/boot/hwlab-gateway.sh new file mode 100644 index 00000000..54b3655f --- /dev/null +++ b/deploy/runtime/boot/hwlab-gateway.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +export HWLAB_SERVICE_ID="hwlab-gateway" +export HWLAB_ARTIFACT_KIND="bun-command" +export HWLAB_SERVICE_ENTRYPOINT="cmd/hwlab-gateway/main.ts" +export HWLAB_RUNTIME_MODE="${HWLAB_RUNTIME_MODE:-env-reuse-git-mirror-checkout}" +export HWLAB_COMMIT_ID="${HWLAB_BOOT_COMMIT:?HWLAB_BOOT_COMMIT is required}" +export HWLAB_REVISION="${HWLAB_BOOT_COMMIT}" +export HWLAB_IMAGE="${HWLAB_ENVIRONMENT_IMAGE:-${HWLAB_IMAGE:-}}" +export HWLAB_IMAGE_DIGEST="${HWLAB_ENVIRONMENT_DIGEST:-${HWLAB_IMAGE_DIGEST:-unknown}}" +export PORT="${PORT:-${HWLAB_PORT:-7001}}" +export HWLAB_PORT="$PORT" +export HWLAB_GATEWAY_ENDPOINT="${HWLAB_GATEWAY_ENDPOINT:-http://127.0.0.1:$PORT}" + +bun_bin="${HWLAB_BUN_COMMAND:-}" +if [ -z "$bun_bin" ]; then + if command -v bun >/dev/null 2>&1; then + bun_bin="$(command -v bun)" + elif [ -x /usr/local/bin/bun ]; then + bun_bin="/usr/local/bin/bun" + elif [ -x ./node_modules/.bin/bun ]; then + bun_bin="./node_modules/.bin/bun" + else + echo "hwlab-gateway boot failed: bun not found" >&2 + exit 127 + fi +fi + +exec "$bun_bin" run cmd/hwlab-gateway/main.ts diff --git a/docs/reference/spec-v02-cicd.md b/docs/reference/spec-v02-cicd.md index 5f06cfc1..6f2dcc7b 100644 --- a/docs/reference/spec-v02-cicd.md +++ b/docs/reference/spec-v02-cicd.md @@ -126,10 +126,10 @@ devops-infra git mirror 仍是 PipelineRun 和 Argo CD 的集群内读写源。` 7. affected service 通过 BuildKit 发布到 G14 本地 registry;reused service 复用 catalog digest。 所有 selected service 的 build TaskRun 都只依赖 `plan-artifacts`,不按 service 串行排队,也不设置 8 并发或其他 Pipeline 级限流。 实际并发由 Tekton controller、G14 节点资源、PVC I/O、BuildKit sidecar 和本地 registry 承载能力决定。 - `hwlab-cloud-web` 必须使用 `env-reuse-git-mirror-checkout`。 - 只有 package/runtime/env 输入变化时才构建 `-env` 镜像。 - 纯前端源码、HWPOD workspace 工具或 boot code 变化只更新三变量和 GitOps desired state, - 不再重新构建业务镜像。 + v0.2 保留 runtime service(`hwlab-cloud-api`、`hwlab-cloud-web`、`hwlab-gateway`、`hwlab-edge-proxy`、`hwlab-agent-skills`)必须使用 `env-reuse-git-mirror-checkout`。 + 只有 package/runtime/env、env image 构建定义、launcher 输入变化或缺少可复用 env digest 时才构建 `-env` 镜像。 + service source 或 boot script 的 code-only 变化只更新三变量和 GitOps desired state,不再重新构建业务镜像。 + CLI、CaseRun、HWPOD workspace 工具、文档、测试和其他非 service 资产不进入 env-reuse service 触发面。 8. promotion 刷新 `deploy/artifact-catalog.v02.json`,render `deploy/gitops/g14/runtime-v02/**`,只在本 PipelineRun 的 source commit 仍是当前 `origin/v0.2` head 时推送到 `devops-infra` mirror/relay 的 `v0.2-gitops`;若 source branch 已推进,本轮输出 superseded/no-op,写出 `runtime-ready-required=false`,不得回写旧 GitOps revision。 9. `hwlab-g14-v02` 从本地 mirror/relay 的 `v0.2-gitops:deploy/gitops/g14/runtime-v02` 同步到 `hwlab-v02`。 10. UniDesk CLI auto-CD 观察定点 `control-plane status --lane v02 --source-commit ` 或 `--pipeline-run `,确认 PipelineRun、Argo、runtime workload、public probes 和 planArtifacts build/reuse 摘要。历史 merge head 若因后续 `origin/v0.2` 推进而被 superseded,PR 评论以 superseded 收口;最新 head 仍必须继续收敛到 runtime。 @@ -222,7 +222,7 @@ Git 读写都走 `devops-infra` 本地 mirror/relay。读路径用 HTTP mirror c 任何仍需要访问 GitHub canonical remote 的 SSH 操作都必须显式走 G14 proxy。Host SSH config 可以使用 OpenBSD `nc -X connect -x 127.0.0.1:10808 %h %p`;Tekton `GIT_SSH_COMMAND`、git-mirror `sync`/`flush` 和其他容器内 `git@github.com` 或 `ssh://git@ssh.github.com:443` 路径必须使用 repo-owned Node HTTP CONNECT ProxyCommand,并保留短 `ConnectTimeout` 与 `ServerAlive*`。只改 `HTTP_PROXY`/`HTTPS_PROXY` 对 OpenSSH 无效;BusyBox `nc` 不支持 `-X connect`,不得再把 `ssh.github.com:443` 当作等价 proxy 或让 GitHub SSH 直连反复 60s 超时。 -env image 复用把系统依赖和业务代码身份分离。`environmentDigest` 表示可复用运行环境,`HWLAB_BOOT_REPO`、`HWLAB_BOOT_COMMIT` 和 `HWLAB_BOOT_SH` 表示本次代码启动身份。code-only 变更只更新 boot metadata 和 runtime identity,不发布新 env image;只有真实 env 输入变化或缺少可复用 env digest 时才进入 env rebuild。AgentRun v0.1 是 HWLAB 外部共享执行基础设施,`hwlab-agent-worker` 不再作为 v0.2 service image 或 suspended Job template 参与 planner、BuildKit、artifact catalog 或 GitOps render。`package.json` 只按 dependency/runtime 字段参与 env/runtime hash,`scripts` 只属于开发入口,不得因为清理旧门禁脚本而重建所有 service 或重建 env image。 +env image 复用把系统依赖和业务代码身份分离。`environmentDigest` 表示可复用运行环境,`HWLAB_BOOT_REPO`、`HWLAB_BOOT_COMMIT` 和 `HWLAB_BOOT_SH` 表示本次代码启动身份。service code-only 变更只更新 boot metadata 和 runtime identity,不发布新 env image;只有真实 env 输入、env image 构建定义、launcher 输入变化或缺少可复用 env digest 时才进入 env rebuild。AgentRun v0.1 是 HWLAB 外部共享执行基础设施,`hwlab-agent-worker` 不再作为 v0.2 service image 或 suspended Job template 参与 planner、BuildKit、artifact catalog 或 GitOps render。`package.json` 只按 dependency/runtime 字段参与 env/runtime hash,`scripts` 只属于开发入口,不得因为清理旧门禁脚本而重建所有 service 或重建 env image。CLI、CaseRun、HWPOD workspace 工具、文档和测试不属于 v0.2 service runtime 输入,不得因为 env reuse 机制被纳入 service rollout 或 build 范围;若某个工具文件确实被浏览器或服务源码直接 import,必须作为对应 service 的显式 component path 建模,而不能通过宽泛 shared path 扩散到所有服务。env image 构建定义只包含真正改变 `-env` 镜像内容或启动器行为的文件,不能把 planner、测试、CLI 或 host helper 混入环境输入。 BuildKit publish 采用 service 级全并行 fan-out。 每个 service build task 拥有独立 BuildKit sidecar、独立 service workdir 和独立 report 文件,均从同一 `plan-artifacts` 结果判断是否需要执行。 @@ -467,7 +467,7 @@ CI/CD 必须把三变量同时写入 `deploy/artifact-catalog.v02.json`、render 推荐启动形态是 initContainer + `emptyDir` + generic env image。initContainer 或 env image 内的 launcher 读取三变量,通过 resolver 把 `HWLAB_BOOT_REPO` 的只读 fetch/checkout 自动分流到 `devops-infra` git mirror/cache,按 `HWLAB_BOOT_COMMIT` checkout 到 Pod 私有 `emptyDir`;main container 复用同一 env image,并执行 checkout 后代码目录里的 `$HWLAB_BOOT_SH`。env image 只承载系统依赖、runtime、launcher、git client 和通用工具,不把业务代码或旧 boot script 当作运行真相。 -code-only 变更时,planner 必须输出 `envChanged=false`、`codeChanged=true`,跳过 BuildKit image publish,复用上一版 env image digest,只更新 catalog 和 workload Pod template 中的 `HWLAB_BOOT_COMMIT`、code identity annotation 与相关 health metadata。Kubernetes 原生 Deployment/StatefulSet rolling update 仍由 Pod template hash 变化触发;不需要在容器内长期驻留 watcher,也不把 `git pull` 结果作为运行真相。 +service code-only 变更时,planner 必须输出 `envChanged=false`、`codeChanged=true`,跳过 BuildKit image publish,复用上一版 env image digest,只更新 catalog 和 workload Pod template 中的 `HWLAB_BOOT_COMMIT`、code identity annotation 与相关 health metadata。非 service 变更必须输出空 `affectedServices` 或只影响自身所属的非 runtime 流程,不得为了 env reuse 触发 runtime service rollout。Kubernetes 原生 Deployment/StatefulSet rolling update 仍由 Pod template hash 变化触发;不需要在容器内长期驻留 watcher,也不把 `git pull` 结果作为运行真相。 `devops-infra` git mirror/relay 是 allowlisted、PVC-backed 或等价持久化服务,负责缓存 GitHub 对象并承接 `v0.2-gitops` 本地写入。只有 mirror/relay sync/flush 边界持有 GitHub 远端凭证;`hwlab-v02`、AgentRun 和其他业务 namespace 只能访问只读 mirror/cache endpoint,不持有 GitHub deploy key。runtime checkout 失败、mirror miss 超过明确等待窗口、commit ancestry 不满足 lane 约束或 boot script 校验失败时,Pod 必须启动失败或 NotReady,不得回退到 env image 内旧代码,也不得在业务 namespace 直连 GitHub 拉取。 @@ -547,8 +547,9 @@ registry 与 git mirror/relay 分属不同基础设施边界。registry 保持 - `gitops-promote` 推送本地 mirror/relay 的 `v0.2-gitops` 后应触发 `argocd/hwlab-g14-v02` hard refresh,减少 GitOps push 与 Argo 仓库发现之间的漂移窗口;refresh 触发失败只作为低噪声事件输出,最终通过仍由 `runtime-ready` 判定。 - mirror/relay status 必须能显示本地 `v0.2-gitops` revision、GitHub 已 flush revision、pending flush revision 和最近一次 flush 错误;CI 通过不要求 GitHub flush 已完成,但 auto-CD 收口必须在 `pendingFlush=true` 时触发 flush 并最终观察到 `pendingFlush=false`。 - `runtime-ready` 必须以 workload Pod template 的 source commit 判断 Argo 刷新,并在 Argo 刷新超时、observer RBAC 不足或 workload 未就绪时失败;不得把 CrashLoop、未刷新或公网不可用的 runtime 标成绿色。 -- 启用 env 容器复用的服务必须在 catalog、Pod template annotation/env 和 `/health/live` 或等价 runtime identity 中同时暴露 env image digest 与 `HWLAB_BOOT_REPO`/`HWLAB_BOOT_COMMIT`/`HWLAB_BOOT_SH`;只暴露镜像 digest 不能证明 code-only rollout 已生效。 +- 启用 env 容器复用的 runtime service 必须在 catalog、Pod template annotation/env 和 `/health/live` 或等价 runtime identity 中同时暴露 env image digest 与 `HWLAB_BOOT_REPO`/`HWLAB_BOOT_COMMIT`/`HWLAB_BOOT_SH`;只暴露镜像 digest 不能证明 service code-only rollout 已生效。 - code-only rollout 的 PipelineRun 必须能证明没有发布新 env image、复用了上一版 env digest、只更新 boot commit/code identity,并由 Kubernetes Pod template hash 触发滚动。 +- 非 service 资产变更必须能证明没有因为 env reuse 触发 runtime service build 或 rollout;典型非 service 资产包括 `tools/hwlab-cli*`、CaseRun runner/helper、`.hwlab/` workspace spec、docs 和 tests。 - `devops-infra` git mirror/cache miss、commit ancestry 拒绝或 boot script 校验失败必须导致本轮 rollout fail closed,不得隐式回退到 GitHub 或 env image 内旧代码。 - `http://74.48.78.17:19666/` 返回 `v0.2` Cloud Web。 - `http://74.48.78.17:19667/health/live` 返回 `v0.2` runtime health,payload 中的 namespace、revision 或 runtime identity 能与 `hwlab-v02`/`v0.2` 对齐。 @@ -616,7 +617,8 @@ GitOps branch 已更新、source branch render 通过、PipelineRun 名称存在 | Argo v02 Application | 已实现 | `hwlab-g14-v02` 指向 v02 GitOps path 和 namespace。 | | FRP `19666/19667` 入口 | 已实现 | 由 `hwlab-v02-frpc` 与 master frps allowlist 共同提供。 | | SecretRef 独立与 provider 验收 | 已实现/持续约束 | SecretRef 已独立;验收必须做真实短连接聊天。 | -| env 容器复用三变量启动 | 已实现/持续约束 | env-reuse fast lane 已由 CI/CD 自动推导 `HWLAB_BOOT_REPO`、`HWLAB_BOOT_COMMIT`、`HWLAB_BOOT_SH`;code-only rollout 复用 env image digest,只更新代码身份。 | +| env 容器复用三变量启动 | 已实现/持续约束 | 五个 v0.2 runtime service 均启用 env-reuse fast lane,并由 CI/CD 自动推导 `HWLAB_BOOT_REPO`、`HWLAB_BOOT_COMMIT`、`HWLAB_BOOT_SH`;service code-only rollout 复用 env image digest,只更新代码身份。 | +| 非 service 资产不触发 env reuse | 已实现/持续约束 | CLI、CaseRun、HWPOD workspace 工具、`.hwlab/`、docs 和 tests 不进入 env-reuse service 触发面;被服务直接 import 的文件必须显式挂到对应 service component path。 | | `devops-infra` git mirror/relay 加速 | 已实现/持续约束 | source/catalog/runtime checkout 读路径和 GitOps promotion 写路径均使用独立基础设施集群 mirror/relay;Argo source 指向本地 mirror,GitHub flush 由 UniDesk CLI 手动触发,不设置 CronJob。runtime namespace 不持有 GitHub deploy key,registry 保持 G14 `hwlab-ci/hwlab-registry`。 | | `hwlab-cli` 不进 CI/CD service matrix | 已实现/持续约束 | CLI 是固定 repo 短连接 client,不发布镜像、不生成 artifact、不创建 `build-hwlab-cli` TaskRun;相关旧入口出现时直接删除。 | | CI/CD fast lane 性能预算 | 已实现/持续约束 | env-reuse no-op 目标约 40s;真实 runtime rollout 目标约 50s;不得恢复 `prepare-source` 依赖安装、GitHub 关键路径写入或 no-op runtime 等待。 | diff --git a/scripts/g14-ci-plan.test.mjs b/scripts/g14-ci-plan.test.mjs index fda0d9e7..15d7bf4a 100644 --- a/scripts/g14-ci-plan.test.mjs +++ b/scripts/g14-ci-plan.test.mjs @@ -16,6 +16,14 @@ import { const execFileAsync = promisify(execFile); +const V02_RUNTIME_SERVICE_IDS = Object.freeze([ + "hwlab-cloud-api", + "hwlab-cloud-web", + "hwlab-gateway", + "hwlab-edge-proxy", + "hwlab-agent-skills" +]); + test("G14 CI planner maps bridge changes to cloud-api only", async () => { const repo = await createFixtureRepo(); await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge v2');\n"); @@ -56,7 +64,7 @@ test("planner rebuilds when catalog digest is missing instead of blocking reuse" assert.equal(plan.services[0].reason.includes("catalog-digest-missing"), true); }); -test("planner rebuilds service image when catalog component input hash is stale", async () => { +test("v02 planner rolls cloud-api service code changes without rebuilding service image", async () => { const repo = await createFixtureRepo(); const initialPlan = await createG14CiPlan({ repoRoot: repo, @@ -67,21 +75,18 @@ test("planner rebuilds service image when catalog component input hash is stale" services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); const initialCloudApi = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-api"); - const staleHash = initialCloudApi.componentInputHash; await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { sourceCommitId: initialPlan.sourceCommitId, - cloudApiComponentInputHash: staleHash + environmentInputHashes: { "hwlab-cloud-api": initialCloudApi.environmentInputHash }, + codeInputHashes: { "hwlab-cloud-api": initialCloudApi.codeInputHash }, + bootCommits: { "hwlab-cloud-api": initialPlan.sourceCommitId } }), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); - await git(repo, ["commit", "-m", "seed catalog with current cloud-api hash"]); + await git(repo, ["commit", "-m", "seed catalog with current cloud-api env reuse hashes"]); - await writeFile(path.join(repo, "tools/src/hwpod-harness-lib.ts"), "export const hwpod = true;\n"); - await git(repo, ["add", "tools/src/hwpod-harness-lib.ts"]); - await git(repo, ["commit", "-m", "change shared hwpod runtime"]); - await mkdir(path.join(repo, "docs/reference"), { recursive: true }); - await writeFile(path.join(repo, "docs/reference/g14-gitops-cicd.md"), "document planner reuse invariant\n"); - await git(repo, ["add", "docs/reference/g14-gitops-cicd.md"]); - await git(repo, ["commit", "-m", "document planner reuse invariant"]); + await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api v2');\n"); + await git(repo, ["add", "cmd/hwlab-cloud-api/main.ts"]); + await git(repo, ["commit", "-m", "change cloud-api service code"]); const plan = await createG14CiPlan({ repoRoot: repo, @@ -93,41 +98,20 @@ test("planner rebuilds service image when catalog component input hash is stale" }); const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); assert.equal(cloudApi.affected, true); - assert.equal(cloudApi.buildRequired, true); - assert.equal(cloudApi.componentInputChanged, true); - assert.equal(cloudApi.reuse.status, "component-input-mismatch"); - assert.deepEqual(cloudApi.reason, ["component-input-mismatch"]); - assert.ok(plan.buildServices.includes("hwlab-cloud-api")); + assert.equal(cloudApi.runtimeMode, "env-reuse-git-mirror-checkout"); + assert.equal(cloudApi.envChanged, false); + assert.equal(cloudApi.codeChanged, true); + assert.equal(cloudApi.buildRequired, false); + assert.deepEqual(cloudApi.reason, ["code-input-changed", "component-path-changed"]); + assert.deepEqual(plan.buildServices, []); }); -test("planner rejects polluted catalog hash that does not match catalog source tree", async () => { - const repo = await createFixtureRepo(); - const oldSourceCommit = (await git(repo, ["rev-parse", "HEAD~1"])).stdout.trim(); - - await writeFile(path.join(repo, "tools/src/hwpod-harness-lib.ts"), "export const hwpod = true;\n"); - await git(repo, ["add", "tools/src/hwpod-harness-lib.ts"]); - await git(repo, ["commit", "-m", "change shared hwpod runtime"]); - - const currentPlan = await createG14CiPlan({ - repoRoot: repo, - lane: "v02", - baseRef: "HEAD", - targetRef: "HEAD", - artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json", - services: ["hwlab-cloud-api"] - }); - const currentCloudApi = currentPlan.services.find((service) => service.serviceId === "hwlab-cloud-api"); - await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { - sourceCommitId: oldSourceCommit, - cloudApiComponentInputHash: currentCloudApi.componentInputHash - }), null, 2)); - await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); - await git(repo, ["commit", "-m", "pollute catalog provenance"]); - +test("v02 planner rebuilds env image when an env reuse catalog digest is missing", async () => { + const repo = await createFixtureRepo({ catalog: "missing-digest" }); await mkdir(path.join(repo, "docs/reference"), { recursive: true }); - await writeFile(path.join(repo, "docs/reference/g14-gitops-cicd.md"), "docs only after provenance pollution\n"); + await writeFile(path.join(repo, "docs/reference/g14-gitops-cicd.md"), "docs only with missing env digest\n"); await git(repo, ["add", "docs/reference/g14-gitops-cicd.md"]); - await git(repo, ["commit", "-m", "docs after polluted catalog"]); + await git(repo, ["commit", "-m", "docs after missing env digest"]); const plan = await createG14CiPlan({ repoRoot: repo, @@ -140,8 +124,9 @@ test("planner rejects polluted catalog hash that does not match catalog source t const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); assert.equal(cloudApi.affected, true); assert.equal(cloudApi.buildRequired, true); - assert.equal(cloudApi.reuse.status, "component-source-mismatch"); - assert.deepEqual(cloudApi.reason, ["component-source-mismatch"]); + assert.equal(cloudApi.envChanged, true); + assert.equal(cloudApi.reuse.status, "not-reused"); + assert.deepEqual(cloudApi.reason, ["environment-input-changed"]); }); test("artifact reuse paths preserve catalog provenance instead of current planner provenance", async () => { @@ -168,11 +153,11 @@ test("component model uses built-in service paths", () => { "internal/db/", "skills/hwlab-agent-runtime/" ]); - assert.equal(models[0].sharedPaths.includes(".hwlab/"), true); + assert.deepEqual(models[0].sharedPaths, ["internal/build-metadata.mjs", "internal/protocol/"]); assert.deepEqual(matchingPaths(["cmd/hwlab-cloud-api/main.ts"], models[0].componentPaths), ["cmd/hwlab-cloud-api/main.ts"]); }); -test("v02 planner treats preinstalled hwpod-spec changes as shared runtime inputs", async () => { +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"); await git(repo, ["add", ".hwlab/hwpod-spec.yaml"]); @@ -186,10 +171,10 @@ test("v02 planner treats preinstalled hwpod-spec changes as shared runtime input artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); - assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]); - assert.deepEqual(plan.buildServices, ["hwlab-cloud-api"]); - assert.deepEqual(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").reason, ["shared-runtime-changed"]); - assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-web").codeChanged, true); + assert.deepEqual(plan.affectedServices, []); + assert.deepEqual(plan.buildServices, []); + assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").affected, false); + assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-web").affected, false); }); test("v02 planner treats AgentRun runtime prompt changes as cloud-api inputs", async () => { @@ -209,14 +194,11 @@ test("v02 planner treats AgentRun runtime prompt changes as cloud-api inputs", a services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web"); - const initialHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash])); - await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { - sourceCommitId: initialPlan.sourceCommitId, - componentInputHashes: initialHashes, + await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash, cloudWebCodeInputHash: initialCloudWeb.codeInputHash, cloudWebBootCommit: initialPlan.sourceCommitId - }), null, 2)); + })), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); await git(repo, ["commit", "-m", "seed v02 catalog with agentrun prompt"]); @@ -233,11 +215,13 @@ test("v02 planner treats AgentRun runtime prompt changes as cloud-api inputs", a services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]); - assert.deepEqual(plan.buildServices, ["hwlab-cloud-api"]); + assert.deepEqual(plan.buildServices, []); assert.deepEqual(plan.rolloutServices, ["hwlab-cloud-api"]); const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); assert.deepEqual(cloudApi.changedPaths, ["internal/agent/prompts/hwlab-v02-runtime.md"]); - assert.deepEqual(cloudApi.reason, ["component-path-changed"]); + assert.equal(cloudApi.envChanged, false); + assert.equal(cloudApi.codeChanged, true); + assert.deepEqual(cloudApi.reason, ["code-input-changed", "component-path-changed"]); }); test("planner scopes cloud-web runtime changes to cloud-web", async () => { @@ -265,14 +249,11 @@ test("v02 planner treats shared trace renderer changes as cloud-web code rollout services: ["hwlab-cloud-api", "hwlab-cloud-web"] }); const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web"); - const initialHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash])); - await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { - sourceCommitId: initialPlan.sourceCommitId, - componentInputHashes: initialHashes, + await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash, cloudWebCodeInputHash: initialCloudWeb.codeInputHash, cloudWebBootCommit: initialPlan.sourceCommitId - }), null, 2)); + })), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); await git(repo, ["commit", "-m", "seed v02 catalog with trace renderer"]); @@ -425,6 +406,61 @@ test("v02 planner marks cloud-web code-only change as env reuse rollout", async assert.equal(plan.ciCdPlan.noImageBuildReason, "env-reuse-code-only-rollout"); }); +test("v02 planner enables env reuse for all retained runtime services", async () => { + const repo = await createFixtureRepo({ serviceIds: V02_RUNTIME_SERVICE_IDS }); + const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); + const initialPlan = await createG14CiPlan({ + repoRoot: repo, + lane: "v02", + baseRef: "HEAD", + targetRef: initialSha, + artifactCatalogPath: "deploy/artifact-catalog.v02.json", + services: V02_RUNTIME_SERVICE_IDS + }); + const environmentInputHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.environmentInputHash])); + const codeInputHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.codeInputHash])); + const bootCommits = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, initialPlan.sourceCommitId])); + await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { + sourceCommitId: initialPlan.sourceCommitId, + environmentInputHashes, + codeInputHashes, + bootCommits, + serviceIds: V02_RUNTIME_SERVICE_IDS + }), null, 2)); + await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); + await git(repo, ["commit", "-m", "seed all v02 env reuse catalogs"]); + + await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api v2');\n"); + await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "v2\n"); + await writeFile(path.join(repo, "cmd/hwlab-gateway/main.ts"), "console.log('gateway v2');\n"); + await writeFile(path.join(repo, "cmd/hwlab-edge-proxy/main.ts"), "console.log('edge v2');\n"); + await writeFile(path.join(repo, "skills/hwlab-agent-runtime/SKILL.md"), "agent runtime skill v2\n"); + await git(repo, ["add", "."]); + await git(repo, ["commit", "-m", "change all v02 service code"]); + + const plan = await createG14CiPlan({ + repoRoot: repo, + lane: "v02", + baseRef: "HEAD~1", + targetRef: "HEAD", + artifactCatalogPath: "deploy/artifact-catalog.v02.json", + services: V02_RUNTIME_SERVICE_IDS + }); + assert.deepEqual(plan.compatibility.v02EnvReuseServiceIds, V02_RUNTIME_SERVICE_IDS); + assert.deepEqual(plan.affectedServices, sortedServiceIds(V02_RUNTIME_SERVICE_IDS)); + assert.deepEqual(plan.rolloutServices, sortedServiceIds(V02_RUNTIME_SERVICE_IDS)); + assert.deepEqual(plan.buildServices, []); + assert.equal(plan.imageBuildRequired, false); + for (const service of plan.services) { + assert.equal(service.runtimeMode, "env-reuse-git-mirror-checkout", service.serviceId); + assert.equal(service.envReuse, true, service.serviceId); + assert.equal(service.envChanged, false, service.serviceId); + assert.equal(service.codeChanged, true, service.serviceId); + assert.equal(service.buildRequired, false, service.serviceId); + assert.equal(service.bootSh, `deploy/runtime/boot/${service.serviceId}.sh`, service.serviceId); + } +}); + test("v02 planner rejects removed HWLAB-owned agent worker service selections", async () => { const repo = await createFixtureRepo(); await assert.rejects( @@ -451,15 +487,7 @@ test("v02 planner keeps hwlab-cli-only changes out of runtime service builds", a artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-agent-skills"] }); - const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web"); - const initialHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash])); - await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { - sourceCommitId: initialPlan.sourceCommitId, - componentInputHashes: initialHashes, - cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash, - cloudWebCodeInputHash: initialCloudWeb.codeInputHash, - cloudWebBootCommit: initialPlan.sourceCommitId - }), null, 2)); + await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan)), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); await git(repo, ["commit", "-m", "seed v02 catalog"]); @@ -488,7 +516,7 @@ test("v02 planner keeps hwlab-cli-only changes out of runtime service builds", a assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").affected, false); }); -test("v02 planner treats hwpod runner CLI changes as runtime inputs", async () => { +test("v02 planner scopes hwpod skill bundle changes to the skills service", async () => { const repo = await createFixtureRepo(); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createG14CiPlan({ @@ -499,15 +527,7 @@ test("v02 planner treats hwpod runner CLI changes as runtime inputs", async () = artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-agent-skills"] }); - const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web"); - const initialHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash])); - await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { - sourceCommitId: initialPlan.sourceCommitId, - componentInputHashes: initialHashes, - cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash, - cloudWebCodeInputHash: initialCloudWeb.codeInputHash, - cloudWebBootCommit: initialPlan.sourceCommitId - }), null, 2)); + await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan)), null, 2)); await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); await git(repo, ["commit", "-m", "seed v02 catalog"]); @@ -524,20 +544,28 @@ test("v02 planner treats hwpod runner CLI changes as runtime inputs", async () = artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-agent-skills"] }); - assert.deepEqual(plan.affectedServices, ["hwlab-agent-skills", "hwlab-cloud-api", "hwlab-cloud-web"]); - assert.deepEqual(plan.buildServices, ["hwlab-agent-skills", "hwlab-cloud-api"]); - assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-web").codeChanged, true); - assert.deepEqual(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").reason, ["shared-runtime-changed"]); + assert.deepEqual(plan.affectedServices, ["hwlab-agent-skills"]); + assert.deepEqual(plan.buildServices, []); + assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").affected, false); + assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-web").affected, false); + const skillsService = plan.services.find((service) => service.serviceId === "hwlab-agent-skills"); + assert.equal(skillsService.codeChanged, true); + assert.equal(skillsService.envChanged, false); + assert.deepEqual(skillsService.reason, ["code-input-changed", "component-path-changed"]); }); 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 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-deepseek-responses-bridge"), { recursive: true }); + await mkdir(path.join(repo, "cmd/hwlab-gateway"), { recursive: true }); + await mkdir(path.join(repo, "cmd/hwlab-edge-proxy"), { recursive: true }); await mkdir(path.join(repo, "web/hwlab-cloud-web"), { recursive: true }); await mkdir(path.join(repo, "internal/dev-entrypoint"), { recursive: true }); + await mkdir(path.join(repo, "internal/sim"), { recursive: true }); await mkdir(path.join(repo, "tools/hwlab-cli"), { recursive: true }); await mkdir(path.join(repo, "tools/src"), { recursive: true }); await mkdir(path.join(repo, "skills/hwpod-cli"), { recursive: true }); @@ -547,19 +575,22 @@ 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 }), null, 2)); + await writeFile(path.join(repo, "deploy/deploy.json"), JSON.stringify(createDeployFixture({ deployServices, k3sServiceMappings, serviceIds }), null, 2)); 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)); await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api');\n"); await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge');\n"); await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.test.ts"), "console.log('test');\n"); + await writeFile(path.join(repo, "cmd/hwlab-gateway/main.ts"), "console.log('gateway');\n"); + await writeFile(path.join(repo, "cmd/hwlab-edge-proxy/main.ts"), "console.log('edge');\n"); await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/artifact-runtime.mjs"), "export const artifactRuntime = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-runtime.mjs"), "export const runtime = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-proxy.mjs"), "export const proxy = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-routes.mjs"), "export const routes = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/http.mjs"), "export const http = 1;\n"); + await writeFile(path.join(repo, "internal/sim/http.mjs"), "export const simHttp = 1;\n"); await writeFile(path.join(repo, "tools/hwpod-cli.ts"), "console.log('hwpod cli');\n"); await writeFile(path.join(repo, "tools/hwpod-ctl.ts"), "console.log('hwpod ctl');\n"); await writeFile(path.join(repo, "tools/hwpod-compiler-cli.ts"), "console.log('hwpod compiler');\n"); @@ -572,6 +603,10 @@ async function createFixtureRepo(options = {}) { await writeFile(path.join(repo, "skills/hwpod-ctl/SKILL.md"), "hwpod ctl skill\n"); await writeFile(path.join(repo, ".hwlab/hwpod-spec.yaml"), "kind: Hwpod\nmetadata:\n name: d601-f103-v2\nspec:\n nodeBinding:\n nodeId: node-d601-f103-v2\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"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-cloud-web.sh"), "#!/bin/sh\nbun run --cwd web/hwlab-cloud-web build\nexec bun .hwlab-cloud-web-runtime.mjs\n"); + await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-cloud-api.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-cloud-api/main.ts\n"); + await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-gateway.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-gateway/main.ts\n"); + await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-edge-proxy.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-edge-proxy/main.ts\n"); + await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-agent-skills.sh"), "#!/bin/sh\nexec bun .hwlab-agent-skills-runtime.mjs\n"); await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts"), "console.log('launcher');\n"); await writeFile(path.join(repo, "skills/hwlab-agent-runtime/SKILL.md"), "agent runtime skill\n"); await git(repo, ["init"]); @@ -581,13 +616,13 @@ async function createFixtureRepo(options = {}) { const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); const initialPlan = await createG14CiPlan({ repoRoot: repo, + lane: "v02", baseRef: "HEAD", targetRef: "HEAD", artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json", - services: ["hwlab-cloud-api", "hwlab-cloud-web"] + services: serviceIds }); - const componentInputHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash])); - const catalog = createCatalogFixture(catalogMode, { sourceCommitId: initialSha, componentInputHashes }); + const catalog = createCatalogFixture(catalogMode, catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha, serviceIds })); 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"]); @@ -614,30 +649,28 @@ async function refreshFixtureCatalogForHead(repo) { await git(repo, ["commit", "-m", "refresh fixture artifact catalogs"]); } -function createDeployFixture({ deployServices, k3sServiceMappings }) { +function createDeployFixture({ deployServices, k3sServiceMappings, serviceIds = ["hwlab-cloud-api", "hwlab-cloud-web"] }) { const deploy = { lanes: { v02: { sourceRepo: "git@github.com:pikasTech/HWLAB.git", - envReuseServices: ["hwlab-cloud-web"], + envReuseServices: [...V02_RUNTIME_SERVICE_IDS], bootScripts: { - "hwlab-cloud-web": "deploy/runtime/boot/hwlab-cloud-web.sh" + "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" } } } }; if (deployServices) { - deploy.services = [ - { serviceId: "hwlab-cloud-api" }, - { serviceId: "hwlab-cloud-web" } - ]; + deploy.services = serviceIds.map((serviceId) => ({ serviceId })); } if (k3sServiceMappings) { deploy.k3s = { - serviceMappings: [ - { serviceId: "hwlab-cloud-api", name: "hwlab-cloud-api" }, - { serviceId: "hwlab-cloud-web", name: "hwlab-cloud-web" } - ] + serviceMappings: serviceIds.map((serviceId) => ({ serviceId, name: serviceId })) }; } return deploy; @@ -645,11 +678,13 @@ function createDeployFixture({ deployServices, k3sServiceMappings }) { function createCatalogFixture(mode, options = {}) { const digest = mode === "ready" ? `sha256:${"1".repeat(64)}` : "not_published"; - const serviceIds = [ + const serviceIds = options.serviceIds ?? uniquePreserveOrder([ "hwlab-cloud-api", "hwlab-cloud-web", - ...(options.componentInputHashes?.["hwlab-agent-skills"] ? ["hwlab-agent-skills"] : []) - ]; + ...Object.keys(options.componentInputHashes ?? {}), + ...Object.keys(options.environmentInputHashes ?? {}), + ...Object.keys(options.codeInputHashes ?? {}) + ]); return { services: serviceIds.map((serviceId) => ({ serviceId, @@ -661,21 +696,49 @@ function createCatalogFixture(mode, options = {}) { ...((options.componentInputHashes?.[serviceId] || (serviceId === "hwlab-cloud-api" && options.cloudApiComponentInputHash)) ? { componentInputHash: options.cloudApiComponentInputHash ?? options.componentInputHashes?.[serviceId] } : {}), - ...(serviceId === "hwlab-cloud-web" ? { + ...(V02_RUNTIME_SERVICE_IDS.includes(serviceId) ? { runtimeMode: "env-reuse-git-mirror-checkout", envReuse: true, environmentImage: `127.0.0.1:5000/hwlab/${serviceId}-env:env-abc1234`, environmentDigest: digest, - environmentInputHash: options.cloudWebEnvironmentInputHash ?? "e".repeat(64), - codeInputHash: options.cloudWebCodeInputHash ?? "c".repeat(64), + environmentInputHash: options.environmentInputHashes?.[serviceId] ?? (serviceId === "hwlab-cloud-web" ? options.cloudWebEnvironmentInputHash : null) ?? "e".repeat(64), + codeInputHash: options.codeInputHashes?.[serviceId] ?? (serviceId === "hwlab-cloud-web" ? options.cloudWebCodeInputHash : null) ?? "c".repeat(64), bootRepo: "git@github.com:pikasTech/HWLAB.git", - bootCommit: options.cloudWebBootCommit ?? "abc1234", + bootCommit: options.bootCommits?.[serviceId] ?? (serviceId === "hwlab-cloud-web" ? options.cloudWebBootCommit : null) ?? "abc1234", bootSh: `deploy/runtime/boot/${serviceId}.sh` } : {}) })) }; } +function catalogOptionsFromPlan(plan, extra = {}) { + const serviceIds = plan.services.map((service) => service.serviceId); + return { + sourceCommitId: plan.sourceCommitId, + serviceIds, + componentInputHashes: Object.fromEntries(plan.services.map((service) => [service.serviceId, service.componentInputHash])), + environmentInputHashes: Object.fromEntries(plan.services.map((service) => [service.serviceId, service.environmentInputHash]).filter(([, value]) => value)), + codeInputHashes: Object.fromEntries(plan.services.map((service) => [service.serviceId, service.codeInputHash]).filter(([, value]) => value)), + bootCommits: Object.fromEntries(plan.services.map((service) => [service.serviceId, plan.sourceCommitId])), + ...extra + }; +} + +function sortedServiceIds(values) { + return [...values].sort(); +} + +function uniquePreserveOrder(values) { + const seen = new Set(); + const result = []; + for (const value of values) { + if (!value || seen.has(value)) continue; + seen.add(value); + result.push(value); + } + return result; +} + async function git(cwd, args) { return execFileAsync("git", ["-c", "user.name=HWLAB Test", "-c", "user.email=hwlab-test@example.invalid", ...args], { cwd }); } diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index 4147f634..a91393f7 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -20,10 +20,7 @@ export const DEFAULT_BUILD_SYSTEM_PATHS = Object.freeze([ export const DEFAULT_ENV_REUSE_LAUNCHER_PATHS = Object.freeze([ "deploy/runtime/launcher/", - "scripts/artifact-publish.mjs", - "scripts/g14-artifact-publish.mjs", - "scripts/src/dev-artifact-services.mjs", - "scripts/src/g14-ci-plan-lib.mjs" + "scripts/artifact-publish.mjs" ]); export const DEFAULT_RUNTIME_DEP_PATHS = Object.freeze([ @@ -43,19 +40,8 @@ export const DEFAULT_RUNTIME_PACKAGE_FIELDS = Object.freeze([ ]); export const DEFAULT_SHARED_RUNTIME_PATHS = Object.freeze([ - ".hwlab/", "internal/protocol/", - "internal/build-metadata.mjs", - "tools/hwpod-cli.ts", - "tools/hwpod-ctl.ts", - "tools/hwpod-compiler-cli.ts", - "tools/hwpod-node.ts", - "tools/src/hwpod-harness-lib.ts", - "tools/src/hwpod-node-lib.ts", - "tools/src/hwpod-node-ops-contract.ts", - "tools/src/runtime-endpoint-resolver.ts", - "skills/hwpod-cli/", - "skills/hwpod-ctl/" + "internal/build-metadata.mjs" ]); export const DEFAULT_DOCS_ONLY_PATHS = Object.freeze([ @@ -89,7 +75,7 @@ const serviceSpecificPaths = Object.freeze({ "internal/dev-entrypoint/cloud-web-routes.mjs", "tools/src/hwlab-cli/trace-renderer.ts" ], - "hwlab-gateway": ["cmd/hwlab-gateway/"], + "hwlab-gateway": ["cmd/hwlab-gateway/", "internal/sim/"], "hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"], "hwlab-agent-skills": ["skills/"] });