feat: add G14 monorepo CI planner

This commit is contained in:
Codex
2026-05-26 09:39:09 +08:00
parent 3b209f0ab6
commit 0ba5b2c352
9 changed files with 738 additions and 16 deletions
+1
View File
@@ -71,6 +71,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥
- Cloud Web M3 只读护栏:`npm run web:m3-readonly`
- Cloud Workbench 布局/遮挡 smoke`npm run web:layout`local-build 用 `npm run web:layout:build`DEV deploy 后用 `npm run web:layout:live`
- G14 artifact build helper`node scripts/g14-artifact-publish.mjs --publish ...`,只能由 G14 Tekton task 携带 CI artifact identity 调用;人工发布走 G14 poller/GitOps,不走 legacy CLI CD。
- G14 monorepo 组件计划:`node scripts/g14-ci-plan.mjs --base-ref <ref> --target-ref <ref> --pretty`,只读分析 affected/reused services,兼容 `CI.json``deploy/deploy.json`;细则见 [docs/reference/g14-gitops-cicd.md](docs/reference/g14-gitops-cicd.md)。
- G14 GitOps 渲染:`npm run g14:gitops:render`;检查已生成 Tekton/Argo CD manifests`npm run g14:gitops:check`
- DEV 依赖 runtime base 构建:`npm run dev-runtime-base:build`
- Legacy D601 DEV CD:旧脚本入口已删除;事故回放只读历史 issue/commit,不恢复旧命令。
+12
View File
@@ -38,6 +38,18 @@ npm run g14:gitops:render -- --source-revision <sourceCommit>
npm run g14:gitops:check
```
## Monorepo 组件计划与兼容 render
HWLAB 是 monorepoG14 CI/CD 加速必须按组件输入判断构建和滚动,但不能破坏当前 `CI.json``deploy/deploy.json` 与全量 source commit render 合同。
- `scripts/g14-ci-plan.mjs` 是只读 planner,默认读取当前 `CI.json``deploy/deploy.json``deploy/artifact-catalog.dev.json`,输出 `affectedServices``reusedServices``componentCommitId``componentInputHash``dockerfileHash``baseImageDigest``buildArgsHash` 和原因;它不得修改 CI、deploy、catalog 或 GitOps 文件。
- 服务清单兼容顺序固定为:显式 `--services``deploy.services[]``deploy.k3s.serviceMappings[]``internal/protocol.SERVICE_IDS`。因此旧 `deploy/deploy.json` 形态和当前完整 `deploy.services[]` 形态都必须能被 planner 识别。
- `CI.json` 仍是 CI 命令与禁用项合同来源;可选新增 `g14CiPlan.components.<serviceId>` 覆盖 `componentPaths``sharedPaths``runtimeDeps``buildSystemPaths`,但不得要求删除或改写现有 `commands` / `forbidden` 字段。
- `scripts/g14-artifact-publish.mjs` 的 publish report 可以携带 planner 的 per-service 元数据;`scripts/refresh-artifact-catalog.mjs` 可以把这些字段复制进 `deploy/artifact-catalog.dev.json` 的对应 service。旧字段 `commitId``image``imageTag``digest``publishState` 语义不变,旧检查脚本不得因为新增字段失败。
- `scripts/g14-gitops-render.mjs` 默认保持 legacy 兼容:不传新开关时,仍按 `--source-revision` 把所有 workload image 渲染为同一个 source commit tag,现有 `npm run g14:gitops:check` 必须继续通过。
- 混合 desired state 只能通过显式 `--use-deploy-images``HWLAB_G14_USE_DEPLOY_IMAGES=1` 启用。启用后 workload 的 container image、`HWLAB_IMAGE``HWLAB_IMAGE_TAG` 和 pod template `source-commit` 来自 `deploy.services[]` 的 per-service image/env;全局 GitOps metadata 仍记录本次 source commit。未启用时禁止读取旧 catalog/digest 去改变 rollout 行为。
- 后续 Tekton 并发化只能以 planner 输出作为输入,先做到每个 changed service 一个构建任务;unchanged service 复用 catalog digest,且不能改 pod template,避免无意义 rollout。没有完整 per-service desired state 证据时,必须回退当前全量 source commit render,而不是猜测复用。
## Code Agent Provider Profiles
+77 -4
View File
@@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs";
import { ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";
import { createG14CiPlan } from "./src/g14-ci-plan-lib.mjs";
import {
BUILDABLE_IMPLEMENTATION_STATES,
resolveDevArtifactServices,
@@ -73,6 +74,7 @@ function parseArgs(argv) {
baseImage: defaultBaseImage,
reportPath: defaultReportPath,
services: [...SERVICE_IDS],
servicesExplicit: false,
emitReport: true,
quietBuild: false,
concurrency: parseConcurrency(process.env.HWLAB_DEV_ARTIFACT_CONCURRENCY, 4)
@@ -86,7 +88,10 @@ function parseArgs(argv) {
else if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg);
else if (arg === "--base-image") args.baseImage = readOption(argv, ++index, arg);
else if (arg === "--report") args.reportPath = readOption(argv, ++index, arg);
else if (arg === "--services") args.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean);
else if (arg === "--services") {
args.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean);
args.servicesExplicit = true;
}
else if (arg === "--no-report") args.emitReport = false;
else if (arg === "--quiet-build") args.quietBuild = true;
else if (arg === "--concurrency") args.concurrency = parseConcurrency(readOption(argv, ++index, arg), 4);
@@ -1333,12 +1338,62 @@ function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlo
dockerBuildDurationMs: artifact.dockerBuildDurationMs ?? null,
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null,
publishDurationMs: artifact.publishDurationMs ?? null,
componentCommitId: artifact.componentCommitId ?? null,
componentInputHash: artifact.componentInputHash ?? null,
dockerfileHash: artifact.dockerfileHash ?? null,
baseImageReference: artifact.baseImageReference ?? null,
baseImageDigest: artifact.baseImageDigest ?? null,
buildArgsHash: artifact.buildArgsHash ?? null,
ciAffected: artifact.ciAffected ?? null,
ciReason: artifact.ciReason ?? [],
reuse: artifact.reuse ?? null,
notPublishedReason: artifactNotPublishedReason(artifact, mode, fatalBlocked)
};
})
};
}
function plannerFieldsFor(servicePlan) {
if (!servicePlan) return {};
return {
componentCommitId: servicePlan.componentCommitId ?? null,
componentInputHash: servicePlan.componentInputHash ?? null,
dockerfileHash: servicePlan.dockerfileHash ?? null,
baseImageReference: servicePlan.baseImageReference ?? null,
baseImageDigest: servicePlan.baseImageDigest ?? null,
buildArgsHash: servicePlan.buildArgsHash ?? null,
ciAffected: servicePlan.affected ?? false,
ciReason: servicePlan.reason ?? [],
reuse: servicePlan.reuse ?? null
};
}
function enrichServicesWithCiPlan(services, ciPlan) {
const byServiceId = new Map((ciPlan?.services ?? []).map((service) => [service.serviceId, service]));
return services.map((service) => ({
...service,
...plannerFieldsFor(byServiceId.get(service.serviceId))
}));
}
function ciPlanReportSummary(ciPlan) {
if (!ciPlan) return null;
return {
planVersion: ciPlan.planVersion,
sourceCommitId: ciPlan.sourceCommitId,
shortCommitId: ciPlan.shortCommitId,
baseRef: ciPlan.baseRef,
targetRef: ciPlan.targetRef,
compatibility: ciPlan.compatibility,
changedPathSummary: ciPlan.changedPathSummary,
imageBuildRequired: ciPlan.imageBuildRequired,
affectedServices: ciPlan.affectedServices,
reusedServices: ciPlan.reusedServices,
buildSkippedCount: ciPlan.buildSkippedCount,
ciCdPlan: ciPlan.ciCdPlan
};
}
function sumDurations(artifacts, field) {
return artifacts.reduce((sum, artifact) => sum + (Number.isFinite(artifact[field]) ? artifact[field] : 0), 0);
}
@@ -1357,7 +1412,7 @@ async function mapWithConcurrency(items, concurrency, worker) {
return results;
}
function createReport({ args, repo, commitId, shortCommit, mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer }) {
function createReport({ args, repo, commitId, shortCommit, mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer, ciPlan }) {
const status = reportStatus(mode, artifacts, blockers);
const fatalBlocked = hasFatalBlocker(blockers);
const requiredArtifacts = artifacts.filter((artifact) => artifact.artifactRequired);
@@ -1453,6 +1508,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
environment: ENVIRONMENT_DEV,
buildCreatedAt: artifacts.find((artifact) => artifact.buildCreatedAt)?.buildCreatedAt ?? null,
serviceInventory,
ciPlan: ciPlanReportSummary(ciPlan),
publishPlan,
serviceCount: artifacts.length,
requiredServiceCount: serviceInventory.requiredServiceCount,
@@ -1487,6 +1543,15 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
dockerBuildDurationMs: artifact.dockerBuildDurationMs ?? null,
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null,
publishDurationMs: artifact.publishDurationMs ?? null,
componentCommitId: artifact.componentCommitId ?? null,
componentInputHash: artifact.componentInputHash ?? null,
dockerfileHash: artifact.dockerfileHash ?? null,
baseImageReference: artifact.baseImageReference ?? null,
baseImageDigest: artifact.baseImageDigest ?? null,
buildArgsHash: artifact.buildArgsHash ?? null,
ciAffected: artifact.ciAffected ?? null,
ciReason: artifact.ciReason ?? [],
reuse: artifact.reuse ?? null,
dockerBuildLogTail: artifact.dockerBuildLogTail ?? null,
logTail: artifact.logTail ?? null,
pushLogTail: artifact.pushLogTail ?? null
@@ -1543,7 +1608,14 @@ async function main() {
const buildCreatedAt = new Date().toISOString();
const repo = repoLabelFromRemote(remoteUrl);
const producer = g14ArtifactProducer(process.env);
const services = await resolveServices(args.services, catalog);
const ciPlan = await createG14CiPlan({
repoRoot,
targetRef: "HEAD",
registryPrefix: args.registryPrefix,
baseImage: args.baseImage ?? undefined,
...(args.servicesExplicit ? { services: args.services } : {})
});
const services = enrichServicesWithCiPlan(await resolveServices(args.services, catalog), ciPlan);
let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities });
let artifacts = services.map((service) => artifactRecord({
@@ -1590,7 +1662,7 @@ async function main() {
artifacts = services.map((service) => artifactByServiceId.get(service.serviceId));
}
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer });
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer, ciPlan });
if (args.emitReport) {
await writeReport(args.reportPath, report);
}
@@ -1604,6 +1676,7 @@ async function main() {
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
selectedServiceIds: report.artifactPublish.selectedServiceIds,
ciPlan: report.artifactPublish.ciPlan,
concurrency: report.artifactPublish.concurrency,
timings: report.artifactPublish.timings,
publishPlan: report.artifactPublish.publishPlan,
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
import { createG14CiPlan } from "./src/g14-ci-plan-lib.mjs";
const args = parseArgs(process.argv.slice(2));
if (args.help) {
process.stdout.write(`${usage()}\n`);
process.exit(0);
}
const plan = await createG14CiPlan(args);
process.stdout.write(args.pretty ? `${JSON.stringify(plan, null, 2)}\n` : `${JSON.stringify(plan)}\n`);
function parseArgs(argv) {
const parsed = { pretty: false };
for (let index = 0; index < argv.length; index += 1) {
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 === "--ci-json") parsed.ciJsonPath = readOption(argv, ++index, arg);
else if (arg === "--deploy-json") parsed.deployJsonPath = readOption(argv, ++index, arg);
else if (arg === "--artifact-catalog") parsed.artifactCatalogPath = readOption(argv, ++index, arg);
else if (arg === "--registry-prefix") parsed.registryPrefix = readOption(argv, ++index, arg);
else if (arg === "--base-image") parsed.baseImage = readOption(argv, ++index, arg);
else if (arg === "--services") parsed.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean);
else if (arg === "--pretty") parsed.pretty = true;
else if (arg === "--help" || arg === "-h") parsed.help = true;
else throw new Error(`unknown argument ${arg}`);
}
return parsed;
}
function readOption(argv, index, name) {
const value = argv[index];
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
function usage() {
return [
"usage: node scripts/g14-ci-plan.mjs [--base-ref REF] [--target-ref REF] [--pretty]",
"",
"Plan G14 monorepo image builds without mutating CI/CD state.",
"The planner reads current CI.json and deploy/deploy.json by default and remains compatible with legacy full-build render.",
"",
"examples:",
" node scripts/g14-ci-plan.mjs --base-ref HEAD~1 --target-ref HEAD --pretty",
" node scripts/g14-ci-plan.mjs --services hwlab-cloud-api,hwlab-cloud-web --pretty"
].join("\n");
}
+122
View File
@@ -0,0 +1,122 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import {
classifyGlobalChange,
componentModelsForServices,
createG14CiPlan,
matchingPaths
} from "./src/g14-ci-plan-lib.mjs";
const execFileAsync = promisify(execFile);
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.mjs"), "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.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"]);
});
test("G14 CI planner treats docs-only changes as no image build", async () => {
const repo = await createFixtureRepo();
await mkdir(path.join(repo, "docs/reference"), { recursive: true });
await writeFile(path.join(repo, "docs/reference/g14-gitops-cicd.md"), "docs v2\n");
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "change docs"]);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, []);
assert.equal(plan.imageBuildRequired, false);
assert.equal(plan.changedPathSummary.docsOnly, true);
});
test("component model supports CI.json overrides without replacing deploy.json", () => {
const models = componentModelsForServices(["hwlab-cloud-api"], {
g14CiPlan: {
components: {
"hwlab-cloud-api": {
componentPaths: ["custom/api/"],
sharedPaths: ["shared/runtime.mjs"],
runtimeDeps: ["package.json"]
}
}
}
});
assert.deepEqual(models[0].componentPaths, ["custom/api/"]);
assert.deepEqual(matchingPaths(["custom/api/main.mjs"], models[0].componentPaths), ["custom/api/main.mjs"]);
});
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.mjs"), "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/main.test.mjs"]).testOnly, true);
});
async function createFixtureRepo(options = {}) {
const deployServices = options.deployServices !== false;
const k3sServiceMappings = options.k3sServiceMappings === true;
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, "web/hwlab-cloud-web"), { recursive: true });
await mkdir(path.join(repo, "deploy"), { recursive: true });
await writeFile(path.join(repo, "CI.json"), JSON.stringify({ version: 1, commands: [] }, null, 2));
await writeFile(path.join(repo, "deploy/deploy.json"), JSON.stringify(createDeployFixture({ deployServices, k3sServiceMappings }), null, 2));
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.mjs"), "console.log('api');\n");
await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.mjs"), "console.log('bridge');\n");
await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.test.mjs"), "console.log('test');\n");
await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "<html></html>\n");
await git(repo, ["init"]);
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "initial"]);
return repo;
}
function createDeployFixture({ deployServices, k3sServiceMappings }) {
const deploy = {};
if (deployServices) {
deploy.services = [
{ serviceId: "hwlab-cloud-api" },
{ serviceId: "hwlab-cloud-web" }
];
}
if (k3sServiceMappings) {
deploy.k3s = {
serviceMappings: [
{ serviceId: "hwlab-cloud-api", name: "hwlab-cloud-api" },
{ serviceId: "hwlab-cloud-web", name: "hwlab-cloud-web" }
]
};
}
return deploy;
}
async function git(cwd, args) {
return execFileAsync("git", ["-c", "user.name=HWLAB Test", "-c", "user.email=hwlab-test@example.invalid", ...args], { cwd });
}
+46 -10
View File
@@ -56,6 +56,7 @@ function parseArgs(argv) {
webEndpoint: defaultWebEndpoint,
prodRuntimeEndpoint: defaultProdRuntimeEndpoint,
prodWebEndpoint: defaultProdWebEndpoint,
useDeployImages: process.env.HWLAB_G14_USE_DEPLOY_IMAGES === "1",
check: false,
write: true,
help: false
@@ -72,6 +73,7 @@ function parseArgs(argv) {
else if (arg === "--web-endpoint") args.webEndpoint = readOption(argv, ++index, arg);
else if (arg === "--prod-runtime-endpoint") args.prodRuntimeEndpoint = readOption(argv, ++index, arg);
else if (arg === "--prod-web-endpoint") args.prodWebEndpoint = readOption(argv, ++index, arg);
else if (arg === "--use-deploy-images") args.useDeployImages = true;
else if (arg === "--check") {
args.check = true;
args.write = false;
@@ -105,6 +107,7 @@ function usage() {
` --web-endpoint URL default: ${defaultWebEndpoint}`,
` --prod-runtime-endpoint URL default: ${defaultProdRuntimeEndpoint}`,
` --prod-web-endpoint URL default: ${defaultProdWebEndpoint}`,
" --use-deploy-images render workload images/env from deploy/deploy.json per service; default keeps legacy source revision tags",
" --check verify generated files are current without writing"
].join("\n");
}
@@ -195,6 +198,22 @@ function imageFor(registryPrefix, serviceId, shortCommit) {
return `${registryPrefix}/${serviceId}:${shortCommit}`;
}
function imageTagFromReference(image) {
const value = String(image ?? "");
const slashIndex = value.lastIndexOf("/");
const colonIndex = value.lastIndexOf(":");
if (colonIndex <= slashIndex) return null;
return value.slice(colonIndex + 1).split("@", 1)[0] || null;
}
function runtimeCommitForDeployService(deployService, source) {
return deployService?.env?.HWLAB_COMMIT_ID ?? deployService?.commitId ?? imageTagFromReference(deployService?.image) ?? source.full;
}
function runtimeImageTagForDeployService(deployService, source) {
return deployService?.env?.HWLAB_IMAGE_TAG ?? imageTagFromReference(deployService?.image) ?? source.short;
}
function namespaceNameForProfile(profile) {
return profile === "prod" ? "hwlab-prod" : "hwlab-dev";
}
@@ -222,7 +241,7 @@ function namespaceScopedHost(value, namespace) {
return value.replace(/\.hwlab-dev\.svc\.cluster\.local/gu, `.${namespace}.svc.cluster.local`);
}
function transformWorkloads({ workloads, deploy, source, registryPrefix, runtimeEndpoint, webEndpoint, profile = "dev" }) {
function transformWorkloads({ workloads, deploy, source, registryPrefix, runtimeEndpoint, webEndpoint, profile = "dev", useDeployImages = false }) {
const result = cloneJson(workloads);
const deployServices = new Map((deploy.services ?? []).map((service) => [service.serviceId, service]));
const namespace = namespaceNameForProfile(profile);
@@ -255,10 +274,15 @@ function transformWorkloads({ workloads, deploy, source, registryPrefix, runtime
const podTemplate = item?.spec?.template;
if (!podTemplate?.spec?.containers) continue;
const templateServiceId = serviceIdForWorkload(item, podTemplate.spec.containers[0]);
const templateDeployService = deployServices.get(templateServiceId);
const templateSourceCommit = useDeployImages
? runtimeCommitForDeployService(templateDeployService, source)
: source.full;
label(podTemplate.metadata ??= {}, {
...stableRuntimeLabels,
"hwlab.pikastech.local/gitops-target": "g14",
"hwlab.pikastech.local/source-commit": source.full
"hwlab.pikastech.local/source-commit": templateSourceCommit
});
if ((item.kind === "Deployment" || item.kind === "StatefulSet") && item.spec?.selector?.matchLabels) {
item.spec.selector.matchLabels = {
@@ -267,13 +291,18 @@ function transformWorkloads({ workloads, deploy, source, registryPrefix, runtime
};
}
annotate(podTemplate.metadata, {
"hwlab.pikastech.local/source-commit": source.full,
"hwlab.pikastech.local/source-commit": templateSourceCommit,
"hwlab.pikastech.local/rendered-by": "scripts/g14-gitops-render.mjs"
});
for (const container of podTemplate.spec.containers) {
const serviceId = serviceIdForWorkload(item, container);
if (!deployServices.has(serviceId)) continue;
const image = imageFor(registryPrefix, serviceId, source.short);
const deployService = deployServices.get(serviceId);
if (!deployService) continue;
const image = useDeployImages && deployService.image
? deployService.image
: imageFor(registryPrefix, serviceId, source.short);
const runtimeCommit = useDeployImages ? runtimeCommitForDeployService(deployService, source) : source.full;
const runtimeImageTag = useDeployImages ? runtimeImageTagForDeployService(deployService, source) : source.short;
container.image = image;
container.imagePullPolicy = "IfNotPresent";
container.env ??= [];
@@ -281,9 +310,9 @@ function transformWorkloads({ workloads, deploy, source, registryPrefix, runtime
if (Object.hasOwn(entry, "value")) entry.value = namespaceScopedHost(entry.value, namespace);
}
upsertEnv(container.env, "HWLAB_ENVIRONMENT", profileLabel);
upsertEnv(container.env, "HWLAB_COMMIT_ID", source.full);
upsertEnv(container.env, "HWLAB_COMMIT_ID", runtimeCommit);
upsertEnv(container.env, "HWLAB_IMAGE", image);
upsertEnv(container.env, "HWLAB_IMAGE_TAG", source.short);
upsertEnv(container.env, "HWLAB_IMAGE_TAG", runtimeImageTag);
upsertEnv(container.env, "HWLAB_GITOPS_TARGET", "g14");
upsertEnv(container.env, "HWLAB_GITOPS_PROFILE", profileLabel);
upsertEnv(container.env, "HWLAB_GITOPS_SOURCE_COMMIT", source.full);
@@ -1664,7 +1693,7 @@ async function plannedFiles(args) {
const putJson = (relative, value) => files.set(path.join(args.outDir, relative), jsonManifest(value));
const putText = (relative, value) => files.set(path.join(args.outDir, relative), textFile(value));
putJson("source.json", {
const sourceDescriptor = {
kind: "hwlab-g14-gitops-source",
sourceCommit: source.full,
sourceShortCommit: source.short,
@@ -1684,7 +1713,14 @@ async function plannedFiles(args) {
},
tektonPipeline: "hwlab-ci/hwlab-g14-ci-image-publish",
argoApplications: ["argocd/hwlab-g14-dev", "argocd/hwlab-g14-prod"]
});
};
if (args.useDeployImages) {
sourceDescriptor.renderMode = {
imageSource: "deploy.services",
mixedDesiredState: true
};
}
putJson("source.json", sourceDescriptor);
putText("README.md", readme({ source, args }));
putJson("registry/registry.yaml", registryManifest());
putJson("tekton/rbac.yaml", tektonRbac());
@@ -1711,7 +1747,7 @@ async function plannedFiles(args) {
putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations));
putJson(`${runtimePath}/services.yaml`, transformListNamespace(services, namespace, profileLabels, annotations));
putJson(`${runtimePath}/health-contract.yaml`, transformHealthContract(health, namespace, profileLabels, annotations, endpoints.runtimeEndpoint, endpoints.webEndpoint, profile));
putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, source, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile }));
putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, source, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, useDeployImages: args.useDeployImages }));
putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, registryPrefix: args.registryPrefix }));
if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix }));
putJson(`${runtimePath}/g14-frpc.yaml`, g14FrpcManifest({ profile }));
+24 -1
View File
@@ -187,7 +187,16 @@ function publishRecordsFromReport(report, target) {
buildCreatedAt: service.buildCreatedAt ?? null,
buildSource: service.buildSource ?? null,
digest: service.digest,
repositoryDigest: service.repositoryDigest ?? `${image.repository}@${service.digest}`
repositoryDigest: service.repositoryDigest ?? `${image.repository}@${service.digest}`,
componentCommitId: service.componentCommitId ?? null,
componentInputHash: service.componentInputHash ?? null,
dockerfileHash: service.dockerfileHash ?? null,
baseImageReference: service.baseImageReference ?? null,
baseImageDigest: service.baseImageDigest ?? null,
buildArgsHash: service.buildArgsHash ?? null,
ciAffected: service.ciAffected ?? null,
ciReason: service.ciReason ?? [],
reuse: service.reuse ?? null
});
}
@@ -277,6 +286,15 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords,
catalogService.buildCreatedAt = publishRecord?.buildCreatedAt ?? null;
catalogService.buildSource = publishRecord?.buildSource ?? null;
catalogService.digest = publishRecord?.digest ?? "not_published";
catalogService.componentCommitId = publishRecord?.componentCommitId ?? catalogService.componentCommitId ?? null;
catalogService.componentInputHash = publishRecord?.componentInputHash ?? catalogService.componentInputHash ?? null;
catalogService.dockerfileHash = publishRecord?.dockerfileHash ?? catalogService.dockerfileHash ?? null;
catalogService.baseImageReference = publishRecord?.baseImageReference ?? catalogService.baseImageReference ?? null;
catalogService.baseImageDigest = publishRecord?.baseImageDigest ?? catalogService.baseImageDigest ?? null;
catalogService.buildArgsHash = publishRecord?.buildArgsHash ?? catalogService.buildArgsHash ?? null;
catalogService.ciAffected = publishRecord?.ciAffected ?? catalogService.ciAffected ?? null;
catalogService.ciReason = publishRecord?.ciReason ?? catalogService.ciReason ?? [];
catalogService.reuse = publishRecord?.reuse ?? catalogService.reuse ?? null;
catalogService.publishState = publishRecord ? "published" : "skeleton-only";
catalogService.publishEnabled = inventory?.publishEnabled ?? required;
catalogService.artifactRequired = required;
@@ -290,6 +308,11 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords,
imageTag,
buildCreatedAt: catalogService.buildCreatedAt,
digest: catalogService.digest,
componentCommitId: catalogService.componentCommitId,
componentInputHash: catalogService.componentInputHash,
dockerfileHash: catalogService.dockerfileHash,
ciAffected: catalogService.ciAffected,
ciReason: catalogService.ciReason,
publishState: catalogService.publishState,
artifactRequired: catalogService.artifactRequired,
notPublishedReason: catalogService.notPublishedReason
+10 -1
View File
@@ -61,7 +61,13 @@ test("refresh accepts an absolute publish report path", async () => {
digest: digestFor(index),
repositoryDigest: `127.0.0.1:5000/hwlab/${serviceId}@${digestFor(index)}`,
buildCreatedAt: "2026-05-24T00:00:00.000Z",
buildSource: `pikasTech/HWLAB@${commitId}`
buildSource: `pikasTech/HWLAB@${commitId}`,
componentCommitId: commitId,
componentInputHash: `${String(index + 1).padStart(64, "a")}`,
dockerfileHash: `${String(index + 1).padStart(64, "b")}`,
buildArgsHash: `${String(index + 1).padStart(64, "c")}`,
ciAffected: index === 0,
ciReason: index === 0 ? ["component-path-changed"] : ["component-inputs-unchanged"]
}))
}
}, null, 2)}\n`);
@@ -82,4 +88,7 @@ test("refresh accepts an absolute publish report path", async () => {
assert.equal(payload.status, "published");
assert.equal(payload.publishedCount, SERVICE_IDS.length);
assert.deepEqual(payload.wrote, []);
assert.equal(payload.services[0].componentCommitId, commitId);
assert.equal(payload.services[0].ciAffected, true);
assert.equal(payload.services[0].componentInputHash, `${String(1).padStart(64, "a")}`);
});
+396
View File
@@ -0,0 +1,396 @@
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";
const execFileAsync = promisify(execFile);
export const G14_CI_PLAN_VERSION = "v1";
export const DEFAULT_BUILD_SYSTEM_PATHS = Object.freeze([
"scripts/artifact-publish.mjs",
"scripts/g14-artifact-publish.mjs",
"scripts/src/dev-artifact-services.mjs"
]);
export const DEFAULT_RUNTIME_DEP_PATHS = Object.freeze([
"package.json",
"package-lock.json"
]);
export const DEFAULT_SHARED_RUNTIME_PATHS = Object.freeze([
"internal/protocol/",
"internal/build-metadata.mjs"
]);
export const DEFAULT_DOCS_ONLY_PATHS = Object.freeze([
"docs/",
"README.md",
"AGENTS.md"
]);
export const DEFAULT_GITOPS_ONLY_PATHS = Object.freeze([
"deploy/gitops/",
"deploy/frp/",
"scripts/g14-gitops-render.mjs"
]);
const serviceSpecificPaths = Object.freeze({
"hwlab-cloud-api": [
"cmd/hwlab-cloud-api/",
"cmd/hwlab-deepseek-responses-bridge/",
"internal/cloud/",
"internal/db/",
"internal/audit/",
"tools/hwlab-gateway-shell.mjs",
"skills/"
],
"hwlab-cloud-web": ["web/hwlab-cloud-web/"],
"hwlab-agent-mgr": ["cmd/hwlab-agent-mgr/", "internal/agent/", "internal/db/", "internal/audit/"],
"hwlab-agent-worker": ["cmd/hwlab-agent-worker/", "internal/agent/", "internal/db/", "internal/audit/", "skills/"],
"hwlab-gateway": ["cmd/hwlab-gateway/"],
"hwlab-gateway-simu": ["cmd/hwlab-gateway-simu/", "internal/sim/"],
"hwlab-box-simu": ["cmd/hwlab-box-simu/", "internal/sim/"],
"hwlab-patch-panel": ["cmd/hwlab-patch-panel/", "internal/patchpanel/"],
"hwlab-router": ["cmd/hwlab-router/"],
"hwlab-tunnel-client": ["cmd/hwlab-tunnel-client/"],
"hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/"],
"hwlab-cli": ["tools/hwlab-cli/"],
"hwlab-agent-skills": ["skills/"]
});
export async function createG14CiPlan(options = {}) {
const repoRoot = options.repoRoot ?? process.cwd();
const ciJsonPath = options.ciJsonPath ?? "CI.json";
const deployJsonPath = options.deployJsonPath ?? "deploy/deploy.json";
const artifactCatalogPath = options.artifactCatalogPath ?? "deploy/artifact-catalog.dev.json";
const targetRef = options.targetRef ?? "HEAD";
const baseRef = options.baseRef ?? await defaultBaseRef(repoRoot, targetRef);
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 [ciJson, deployJson, artifactCatalog] = await Promise.all([
readJsonIfPresent(repoRoot, ciJsonPath, {}),
readJsonIfPresent(repoRoot, deployJsonPath, {}),
readJsonIfPresent(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 serviceIdResolution = resolveServiceIds({ deployJson, options });
const componentModels = componentModelsForServices(serviceIdResolution.serviceIds, ciJson);
const globalChange = classifyGlobalChange(normalizedChangedPaths);
const dockerfileHash = await hashGitPaths(repoRoot, targetRef, [
...DEFAULT_BUILD_SYSTEM_PATHS,
...DEFAULT_RUNTIME_DEP_PATHS
]);
const catalogByServiceId = new Map((artifactCatalog?.services ?? []).map((service) => [service.serviceId, service]));
const services = [];
for (const model of componentModels) {
const directMatches = matchingPaths(normalizedChangedPaths, model.componentPaths);
const sharedMatches = matchingPaths(normalizedChangedPaths, model.sharedPaths);
const runtimeDepMatches = matchingPaths(normalizedChangedPaths, model.runtimeDeps);
const buildSystemMatches = matchingPaths(normalizedChangedPaths, model.buildSystemPaths);
const imageRelevantChangedPaths = uniqueSorted([
...directMatches,
...sharedMatches,
...runtimeDepMatches,
...buildSystemMatches
].filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item)));
const affected = imageRelevantChangedPaths.length > 0;
const componentInputPaths = uniqueSorted([
...model.componentPaths,
...model.sharedPaths,
...model.runtimeDeps,
...model.buildSystemPaths
]);
const componentCommitId = await lastCommitForPaths(repoRoot, targetRef, componentInputPaths);
const componentInputHash = await hashGitPaths(repoRoot, targetRef, componentInputPaths, {
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath)
});
const buildArgsHash = stableHash({
serviceId: model.serviceId,
baseImage,
registryPrefix,
runtimeKind: model.runtimeKind,
entrypoint: model.entrypoint
});
const catalogRecord = catalogByServiceId.get(model.serviceId) ?? null;
services.push({
serviceId: model.serviceId,
runtimeKind: model.runtimeKind,
entrypoint: model.entrypoint,
componentPaths: model.componentPaths,
sharedPaths: model.sharedPaths,
runtimeDeps: model.runtimeDeps,
buildSystemPaths: model.buildSystemPaths,
componentCommitId,
componentInputHash,
dockerfileHash,
baseImageReference: baseImage,
baseImageDigest: digestFromImageReference(baseImage),
buildArgsHash,
changedPaths: imageRelevantChangedPaths,
affected,
reason: affected ? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches }) : reasonForUnchanged(globalChange),
reuse: reuseCandidate(catalogRecord, affected)
});
}
const affectedServices = services.filter((service) => service.affected).map((service) => service.serviceId);
const reusedServices = services.filter((service) => !service.affected).map((service) => service.serviceId);
return {
planVersion: G14_CI_PLAN_VERSION,
sourceCommitId,
shortCommitId,
baseRef,
targetRef,
compatibility: {
ciJson: ciJsonPath,
deployJson: deployJsonPath,
artifactCatalog: artifactCatalog ? artifactCatalogPath : null,
mode: "advisory-read-only",
currentRenderCompatible: true,
plannerMutatesCiJson: false,
plannerMutatesDeployJson: false,
serviceIdSource: serviceIdResolution.source,
legacyFullBuildFallback: true
},
inputs: {
registryPrefix,
baseImage,
serviceCount: services.length
},
changedPaths: normalizedChangedPaths,
changedPathSummary: globalChange,
imageBuildRequired: affectedServices.length > 0,
affectedServices,
reusedServices,
buildSkippedCount: reusedServices.length,
services,
ciCdPlan: {
willBuild: affectedServices,
willReuse: reusedServices,
willRunGitopsPromote: globalChange.gitopsOnly || affectedServices.length > 0,
noImageBuildReason: affectedServices.length === 0 ? globalChange.summary : null
}
};
}
export function componentModelsForServices(serviceIds, ciJson = {}) {
const overrides = ciJson?.g14CiPlan?.components ?? {};
return serviceIds.map((serviceId) => {
const override = overrides[serviceId] ?? {};
return {
serviceId,
runtimeKind: override.runtimeKind ?? runtimeKindForService(serviceId),
entrypoint: override.entrypoint ?? entrypointForService(serviceId),
componentPaths: uniqueSorted((override.componentPaths ?? serviceSpecificPaths[serviceId] ?? [`cmd/${serviceId}/`]).map(normalizeRepoPath)),
sharedPaths: uniqueSorted((override.sharedPaths ?? DEFAULT_SHARED_RUNTIME_PATHS).map(normalizeRepoPath)),
runtimeDeps: uniqueSorted((override.runtimeDeps ?? DEFAULT_RUNTIME_DEP_PATHS).map(normalizeRepoPath)),
buildSystemPaths: uniqueSorted((override.buildSystemPaths ?? DEFAULT_BUILD_SYSTEM_PATHS).map(normalizeRepoPath))
};
});
}
export function classifyGlobalChange(changedPaths) {
const relevant = changedPaths.filter(Boolean);
const testOnly = relevant.length > 0 && relevant.every(isTestOnlyPath);
const docsOnly = relevant.length > 0 && relevant.every((item) => isDocsOnlyPath(item) || isTestOnlyPath(item));
const gitopsOnly = relevant.length > 0 && relevant.every((item) => isGitOpsOnlyPath(item) || isDocsOnlyPath(item) || isTestOnlyPath(item));
return {
count: relevant.length,
testOnly,
docsOnly,
gitopsOnly,
summary: relevant.length === 0
? "no-source-diff"
: testOnly
? "test-only-change"
: docsOnly
? "docs-only-change"
: gitopsOnly
? "gitops-only-change"
: "runtime-or-build-change"
};
}
export function matchingPaths(changedPaths, patterns) {
return uniqueSorted(changedPaths.filter((filePath) => patterns.some((pattern) => pathMatches(pattern, filePath))));
}
export function normalizeRepoPath(value) {
return String(value ?? "")
.replaceAll("\\", "/")
.replace(/^\.\//u, "")
.replace(/^\/+/, "")
.trim();
}
export function pathMatches(pattern, filePath) {
const normalizedPattern = normalizeRepoPath(pattern);
const normalizedFilePath = normalizeRepoPath(filePath);
if (!normalizedPattern || !normalizedFilePath) return false;
if (normalizedPattern.endsWith("/")) return normalizedFilePath.startsWith(normalizedPattern);
return normalizedFilePath === normalizedPattern;
}
export function isTestOnlyPath(filePath) {
const normalized = normalizeRepoPath(filePath);
return normalized.endsWith(".test.mjs") || normalized.includes("/__tests__/") || normalized.includes("/fixtures/");
}
export function isDocsOnlyPath(filePath) {
const normalized = normalizeRepoPath(filePath);
return DEFAULT_DOCS_ONLY_PATHS.some((pattern) => pathMatches(pattern, normalized)) || normalized.endsWith(".md");
}
export function isGitOpsOnlyPath(filePath) {
const normalized = normalizeRepoPath(filePath);
return DEFAULT_GITOPS_ONLY_PATHS.some((pattern) => pathMatches(pattern, normalized));
}
export async function changedPathsBetween(repoRoot, baseRef, targetRef) {
const diff = await gitValue(repoRoot, ["diff", "--name-only", baseRef, targetRef]);
return diff.split("\n").map((line) => line.trim()).filter(Boolean);
}
export async function hashGitPaths(repoRoot, targetRef, paths, options = {}) {
const normalizedPaths = uniqueSorted(paths.map(normalizeRepoPath).filter(Boolean));
if (normalizedPaths.length === 0) return stableHash({ empty: true });
const output = await gitValue(repoRoot, ["ls-tree", "-r", targetRef, "--", ...normalizedPaths]).catch(() => "");
const lines = output.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => {
const filePath = line.slice(line.indexOf("\t") + 1);
return !options.skipPath?.(filePath);
})
.sort();
return stableHash({ targetRef, entries: lines });
}
export async function lastCommitForPaths(repoRoot, targetRef, paths) {
const normalizedPaths = uniqueSorted(paths.map(normalizeRepoPath).filter(Boolean));
if (normalizedPaths.length === 0) return null;
const value = await gitValue(repoRoot, ["rev-list", "-1", targetRef, "--", ...normalizedPaths]).catch(() => "");
return value.trim() || null;
}
function resolveServiceIds({ deployJson, options }) {
if (Array.isArray(options.services) && options.services.length > 0) {
return { source: "cli-services", serviceIds: uniqueSorted(options.services.map(String)) };
}
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] };
}
async function readJsonIfPresent(repoRoot, relativePath, fallback) {
try {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "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;
}
async function gitValue(repoRoot, args) {
const result = await execFileAsync("git", args, {
cwd: repoRoot,
maxBuffer: 8 * 1024 * 1024,
timeout: 15000
});
return result.stdout.trim();
}
function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches }) {
const reasons = [];
if (directMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("component-path-changed");
if (sharedMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("shared-runtime-changed");
if (runtimeDepMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("runtime-deps-changed");
if (buildSystemMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("build-system-changed");
return reasons.length ? reasons : ["affected"];
}
function reasonForUnchanged(globalChange) {
if (globalChange.summary === "docs-only-change") return ["docs-only-no-image-build"];
if (globalChange.summary === "gitops-only-change") return ["gitops-only-no-image-build"];
if (globalChange.summary === "test-only-change") return ["test-only-no-image-build"];
if (globalChange.summary === "no-source-diff") return ["no-source-diff"];
return ["component-inputs-unchanged"];
}
function reuseCandidate(catalogRecord, affected) {
if (affected) return { status: "not-reused", reason: "component-affected" };
if (!catalogRecord) return { status: "candidate-no-catalog", reason: "no-previous-artifact-record" };
if (!/^sha256:[a-f0-9]{64}$/u.test(catalogRecord.digest ?? "")) {
return { status: "candidate-unverified-digest", image: catalogRecord.image ?? null, digest: catalogRecord.digest ?? null };
}
return {
status: catalogRecord.componentInputHash ? "ready" : "candidate-without-component-metadata",
image: catalogRecord.image,
digest: catalogRecord.digest,
reusedFrom: catalogRecord.componentInputHash ? catalogRecord.componentInputHash : catalogRecord.commitId ?? catalogRecord.imageTag ?? null
};
}
function runtimeKindForService(serviceId) {
if (serviceId === "hwlab-cloud-web") return "cloud-web";
if (serviceId === "hwlab-cli") return "cli";
if (serviceId === "hwlab-agent-skills") return "skills-bundle";
return "node-command";
}
function entrypointForService(serviceId) {
if (serviceId === "hwlab-cloud-web") return "web/hwlab-cloud-web/index.html";
if (serviceId === "hwlab-cli") return "tools/hwlab-cli/bin/hwlab-cli.mjs";
if (serviceId === "hwlab-agent-skills") return "skills/hwlab-agent-runtime/SKILL.md";
return `cmd/${serviceId}/main.mjs`;
}
function digestFromImageReference(image) {
const match = String(image ?? "").match(/@(sha256:[a-f0-9]{64})$/u);
return match ? match[1] : null;
}
function stableHash(value) {
return createHash("sha256").update(stableJson(value)).digest("hex");
}
function stableJson(value) {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function uniqueSorted(values) {
return [...new Set(values.filter(Boolean))].sort();
}
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;
}