Files
pikasTech-HWLAB/scripts/src/g14-ci-plan-lib.mjs
T
2026-05-31 14:37:28 +08:00

594 lines
24 KiB
JavaScript

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 V02_ENV_REUSE_RUNTIME_MODE = "env-reuse-git-mirror-checkout";
export const DEFAULT_BUILD_SYSTEM_PATHS = Object.freeze([
"internal/dev-entrypoint/artifact-runtime.mjs",
"scripts/artifact-publish.mjs",
"scripts/g14-artifact-publish.mjs",
"scripts/src/dev-artifact-services.mjs"
]);
export const DEFAULT_ENV_REUSE_LAUNCHER_PATHS = Object.freeze([
"deploy/runtime/launcher/",
"scripts/artifact-publish.mjs",
"scripts/g14-artifact-publish.mjs",
"scripts/src/dev-artifact-services.mjs",
"scripts/src/g14-ci-plan-lib.mjs"
]);
export const DEFAULT_RUNTIME_DEP_PATHS = Object.freeze([
"package.json",
"package-lock.json"
]);
export const DEFAULT_RUNTIME_PACKAGE_FIELDS = Object.freeze([
"dependencies",
"optionalDependencies",
"peerDependencies",
"overrides",
"resolutions",
"engines",
"packageManager",
"type"
]);
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-codex-api-responses-forwarder/",
"cmd/hwlab-deepseek-responses-bridge/",
"internal/cloud/",
"internal/db/",
"internal/audit/",
"skills/hwlab-agent-runtime/"
],
"hwlab-cloud-web": [
"web/hwlab-cloud-web/",
"internal/dev-entrypoint/cloud-web-runtime.mjs",
"internal/dev-entrypoint/cloud-web-proxy.mjs",
"internal/dev-entrypoint/cloud-web-routes.mjs"
],
"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-agent-runtime/"
],
"hwlab-device-pod": ["cmd/hwlab-device-pod/", "internal/device-pod/"],
"hwlab-gateway": ["cmd/hwlab-gateway/"],
"hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"],
"hwlab-agent-skills": ["skills/"]
});
const bunCommandServices = new Set([
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-cloud-api",
"hwlab-codex-api-responses-forwarder",
"hwlab-deepseek-responses-bridge",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-edge-proxy"
]);
export async function createG14CiPlan(options = {}) {
const repoRoot = options.repoRoot ?? process.cwd();
const deployJsonPath = options.deployJsonPath ?? "deploy/deploy.json";
const targetRef = options.targetRef ?? "HEAD";
const baseRef = options.baseRef ?? await defaultBaseRef(repoRoot, targetRef);
const lane = options.lane ?? (options.artifactCatalogPath?.includes(".v02.") ? "v02" : "g14");
const artifactCatalogPath = options.artifactCatalogPath ?? (lane === "v02" ? "deploy/artifact-catalog.v02.json" : "deploy/artifact-catalog.dev.json");
const registryPrefix = options.registryPrefix ?? process.env.HWLAB_DEV_REGISTRY_PREFIX ?? process.env.HWLAB_DEV_REGISTRY ?? "127.0.0.1:5000/hwlab";
const baseImage = options.baseImage ?? process.env.HWLAB_DEV_BASE_IMAGE ?? "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim";
const [deployJson, artifactCatalog] = await Promise.all([
readJsonIfPresent(repoRoot, deployJsonPath, {}),
readJsonIfPresent(repoRoot, artifactCatalogPath, null)
]);
const sourceCommitId = await gitValue(repoRoot, ["rev-parse", targetRef]);
const shortCommitId = await gitValue(repoRoot, ["rev-parse", "--short=7", targetRef]);
const changedPaths = await changedPathsBetween(repoRoot, baseRef, targetRef);
const normalizedChangedPaths = changedPaths.map(normalizeRepoPath).filter(Boolean);
const semanticRuntimeDepPaths = await runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef, normalizedChangedPaths);
const serviceIdResolution = resolveServiceIds({ deployJson, options });
const componentModels = componentModelsForServices(serviceIdResolution.serviceIds);
const envReuseServices = enabledEnvReuseServices(deployJson, lane, serviceIdResolution.serviceIds);
const globalChange = classifyGlobalChange(normalizedChangedPaths);
const dockerfileHash = await hashGitPaths(repoRoot, targetRef, [
...DEFAULT_BUILD_SYSTEM_PATHS,
...DEFAULT_RUNTIME_DEP_PATHS
], { semanticPackageJson: true });
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 rawRuntimeDepMatches = matchingPaths(normalizedChangedPaths, model.runtimeDeps);
const runtimeDepMatches = rawRuntimeDepMatches.filter((item) => semanticRuntimeDepPaths.has(item));
const buildSystemMatches = matchingPaths(normalizedChangedPaths, model.buildSystemPaths);
const envReuse = lane === "v02" && envReuseServices.has(model.serviceId);
const bootSh = envReuse ? bootShForService(deployJson, model.serviceId) : null;
const envInputPaths = envReuse ? uniqueSorted([
...model.runtimeDeps,
...DEFAULT_ENV_REUSE_LAUNCHER_PATHS.map(normalizeRepoPath)
]) : [];
const codeInputPaths = envReuse ? uniqueSorted([
...model.componentPaths,
...model.sharedPaths,
bootSh
]) : [];
const envMatches = envReuse
? matchingPaths(normalizedChangedPaths, envInputPaths)
.filter((item) => !model.runtimeDeps.includes(item) || semanticRuntimeDepPaths.has(item))
: [];
const codeMatches = envReuse ? matchingPaths(normalizedChangedPaths, codeInputPaths) : [];
const relevantEnvMatches = envMatches.filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item));
const relevantCodeMatches = codeMatches.filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item));
const imageRelevantChangedPaths = uniqueSorted([
...directMatches,
...sharedMatches,
...runtimeDepMatches,
...buildSystemMatches
].filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item)));
const catalogRecord = catalogByServiceId.get(model.serviceId) ?? null;
const environmentInputHash = envReuse ? await hashGitPaths(repoRoot, targetRef, envInputPaths, {
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath),
semanticPackageJson: true
}) : null;
const codeInputHash = envReuse ? await hashGitPaths(repoRoot, targetRef, codeInputPaths, {
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath)
}) : null;
const environmentImage = envReuse ? environmentImageFromCatalog(model.serviceId, catalogRecord) : null;
const environmentDigest = envReuse ? environmentDigestFromCatalog(catalogRecord) : null;
const environmentReady = envReuse && /^sha256:[a-f0-9]{64}$/u.test(environmentDigest ?? "") && Boolean(environmentImage);
const envChanged = envReuse ? relevantEnvMatches.length > 0 || !environmentReady : null;
const codeChanged = envReuse ? relevantCodeMatches.length > 0 || catalogRecord?.codeInputHash !== codeInputHash : null;
const catalogReuse = envReuse ? envReuseCandidate(catalogRecord, envChanged) : reuseCandidate(catalogRecord, imageRelevantChangedPaths.length > 0);
const affected = envReuse
? Boolean(envChanged || codeChanged)
: imageRelevantChangedPaths.length > 0 || catalogReuse.status === "candidate-no-catalog" || catalogReuse.status === "candidate-unverified-digest";
const buildRequired = envReuse ? envChanged === true : affected;
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),
semanticPackageJson: true
});
const buildArgsHash = stableHash({
serviceId: model.serviceId,
baseImage,
registryPrefix,
runtimeKind: model.runtimeKind,
entrypoint: model.entrypoint
});
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,
buildRequired,
runtimeMode: envReuse ? V02_ENV_REUSE_RUNTIME_MODE : "service-image",
envReuse,
envChanged,
codeChanged,
environmentInputHash,
codeInputHash,
bootRepo: envReuse ? canonicalBootRepo(deployJson) : null,
bootCommit: envReuse ? sourceCommitId : null,
bootSh,
environmentImage,
environmentDigest,
envChangedPaths: relevantEnvMatches,
codeChangedPaths: relevantCodeMatches,
reason: affected
? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, codeChanged })
: reasonForUnchanged(globalChange),
reuse: catalogReuse
});
}
const affectedServices = services.filter((service) => service.affected).map((service) => service.serviceId);
const buildServices = services.filter((service) => service.buildRequired).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: {
deployJson: deployJsonPath,
artifactCatalog: artifactCatalog ? artifactCatalogPath : null,
lane,
ciContractSource: "scripts/g14-gitops-render.mjs",
mode: "advisory-read-only",
currentRenderCompatible: true,
plannerMutatesDeployJson: false,
serviceIdSource: serviceIdResolution.source,
v02EnvReuseServiceIds: [...envReuseServices],
defaultComponentLazyBuild: true,
envReuseCodeOnlyFastLane: true,
fullBuildFallback: false
},
inputs: {
registryPrefix,
baseImage,
serviceCount: services.length
},
changedPaths: normalizedChangedPaths,
changedPathSummary: globalChange,
imageBuildRequired: buildServices.length > 0,
affectedServices,
rolloutServices: affectedServices,
buildServices,
reusedServices,
buildSkippedCount: services.length - buildServices.length,
services,
ciCdPlan: {
willBuild: buildServices,
willRollout: affectedServices,
willReuse: reusedServices,
willRunGitopsPromote: globalChange.gitopsOnly || affectedServices.length > 0,
noImageBuildReason: buildServices.length === 0
? (affectedServices.length > 0 ? "env-reuse-code-only-rollout" : globalChange.summary)
: null
}
};
}
export function componentModelsForServices(serviceIds) {
return serviceIds.map((serviceId) => {
return {
serviceId,
runtimeKind: runtimeKindForService(serviceId),
entrypoint: entrypointForService(serviceId),
componentPaths: uniqueSorted((serviceSpecificPaths[serviceId] ?? [`cmd/${serviceId}/`]).map(normalizeRepoPath)),
sharedPaths: uniqueSorted(DEFAULT_SHARED_RUNTIME_PATHS.map(normalizeRepoPath)),
runtimeDeps: uniqueSorted(DEFAULT_RUNTIME_DEP_PATHS.map(normalizeRepoPath)),
buildSystemPaths: uniqueSorted(DEFAULT_BUILD_SYSTEM_PATHS.map(normalizeRepoPath))
};
});
}
export async function runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef, changedPaths) {
const result = new Set();
for (const filePath of changedPaths) {
if (filePath === "package-lock.json") {
result.add(filePath);
continue;
}
if (filePath !== "package.json") continue;
if (await packageRuntimeFieldsChanged(repoRoot, baseRef, targetRef, filePath)) result.add(filePath);
}
return result;
}
export async function packageRuntimeFieldsChanged(repoRoot, baseRef, targetRef, filePath = "package.json") {
const [before, after] = await Promise.all([
readJsonFromGit(repoRoot, baseRef, filePath, null),
readJsonFromGit(repoRoot, targetRef, filePath, null)
]);
if (!before || !after) return true;
return stableJson(pickRuntimePackageJson(before)) !== stableJson(pickRuntimePackageJson(after));
}
function pickRuntimePackageJson(packageJson) {
if (!packageJson || typeof packageJson !== "object") return null;
const picked = {};
for (const field of DEFAULT_RUNTIME_PACKAGE_FIELDS) {
if (Object.prototype.hasOwnProperty.call(packageJson, field)) picked[field] = packageJson[field];
}
return picked;
}
async function readJsonFromGit(repoRoot, ref, filePath, fallback) {
try {
const value = await gitValue(repoRoot, ["show", `${ref}:${filePath}`]);
return JSON.parse(value);
} catch {
return fallback;
}
}
export function enabledEnvReuseServices(deployJson, lane, serviceIds) {
if (lane !== "v02") return new Set();
const configured = deployJson?.lanes?.v02?.envReuseServices;
const values = Array.isArray(configured) ? configured : [];
const allowed = new Set(serviceIds);
return new Set(values.filter((serviceId) => allowed.has(serviceId)));
}
export function bootShForService(deployJson, serviceId) {
const configured = deployJson?.lanes?.v02?.bootScripts?.[serviceId];
const bootSh = normalizeRepoPath(configured || `deploy/runtime/boot/${serviceId}.sh`);
if (!bootSh || bootSh.startsWith("/") || bootSh.split("/").includes("..")) {
throw new Error(`invalid v02 boot script for ${serviceId}: ${configured}`);
}
return bootSh;
}
function canonicalBootRepo(deployJson) {
return deployJson?.lanes?.v02?.sourceRepo || "git@github.com:pikasTech/HWLAB.git";
}
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 /\.test\.(mjs|ts)$/u.test(normalized) || 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 = (await Promise.all(output.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map(async (line) => {
const filePath = line.slice(line.indexOf("\t") + 1);
if (options.skipPath?.(filePath)) return null;
if (options.semanticPackageJson === true && filePath === "package.json") {
const packageJson = await readJsonFromGit(repoRoot, targetRef, filePath, null);
return `100644 blob ${stableHash(pickRuntimePackageJson(packageJson))}\t${filePath}`;
}
return line;
})))
.filter(Boolean)
.sort();
return stableHash({ 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 {
const filePath = path.isAbsolute(relativePath) ? relativePath : path.join(repoRoot, relativePath);
return JSON.parse(await readFile(filePath, "utf8"));
} catch (error) {
if (error?.code === "ENOENT") return fallback;
throw error;
}
}
async function defaultBaseRef(repoRoot, targetRef) {
const parent = await gitValue(repoRoot, ["rev-parse", `${targetRef}^`]).catch(() => "");
return parent || targetRef;
}
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, catalogReuse = null, envReuse = false, envChanged = null, codeChanged = null }) {
const reasons = [];
if (envReuse && envChanged) reasons.push("environment-input-changed");
if (envReuse && codeChanged) reasons.push("code-input-changed");
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");
if (catalogReuse?.status === "candidate-no-catalog") reasons.push("catalog-record-missing");
if (catalogReuse?.status === "candidate-unverified-digest") reasons.push("catalog-digest-missing");
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 envReuseCandidate(catalogRecord, envChanged) {
if (envChanged) return { status: "not-reused", reason: "environment-affected" };
if (!catalogRecord) return { status: "candidate-no-catalog", reason: "no-previous-artifact-record" };
const image = environmentImageFromCatalog(catalogRecord.serviceId, catalogRecord);
const digest = environmentDigestFromCatalog(catalogRecord);
if (!/^sha256:[a-f0-9]{64}$/u.test(digest ?? "")) {
return { status: "candidate-unverified-digest", image, digest };
}
return {
status: catalogRecord.environmentInputHash ? "ready" : "candidate-without-environment-metadata",
image,
digest,
reusedFrom: catalogRecord.environmentInputHash ?? catalogRecord.commitId ?? catalogRecord.imageTag ?? null
};
}
function environmentImageFromCatalog(serviceId, catalogRecord) {
const image = catalogRecord?.environmentImage ?? null;
if (image) return image;
const fallback = catalogRecord?.image ?? null;
return typeof fallback === "string" && fallback.includes(`/${serviceId}-env:`) ? fallback : null;
}
function environmentDigestFromCatalog(catalogRecord) {
return catalogRecord?.environmentDigest ?? (catalogRecord?.environmentImage ? catalogRecord?.digest : null) ?? null;
}
function runtimeKindForService(serviceId) {
if (bunCommandServices.has(serviceId)) return "bun-command";
if (serviceId === "hwlab-cloud-web") return "cloud-web";
if (serviceId === "hwlab-agent-skills") return "skills-bundle";
return "node-command";
}
function entrypointForService(serviceId) {
if (bunCommandServices.has(serviceId)) return `cmd/${serviceId}/main.ts`;
if (serviceId === "hwlab-cloud-web") return "web/hwlab-cloud-web/index.html";
if (serviceId === "hwlab-agent-skills") return "skills/hwlab-agent-runtime/SKILL.md";
return `cmd/${serviceId}/main.mjs`;
}
function digestFromImageReference(image) {
const match = String(image ?? "").match(/@(sha256:[a-f0-9]{64})$/u);
return match ? match[1] : null;
}
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;
}