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-codex-api-responses-forwarder/", "cmd/hwlab-deepseek-responses-bridge/", "internal/cloud/", "internal/db/", "internal/audit/", "tools/", "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-device-pod": ["cmd/hwlab-device-pod/", "internal/device-pod/"], "hwlab-gateway": ["cmd/hwlab-gateway/"], "hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/"], "hwlab-cli": ["tools/hwlab-cli/"], "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 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 [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 serviceIdResolution = resolveServiceIds({ deployJson, options }); const componentModels = componentModelsForServices(serviceIdResolution.serviceIds); 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 catalogRecord = catalogByServiceId.get(model.serviceId) ?? null; const catalogReuse = reuseCandidate(catalogRecord, imageRelevantChangedPaths.length > 0); const affected = imageRelevantChangedPaths.length > 0 || catalogReuse.status === "candidate-no-catalog" || catalogReuse.status === "candidate-unverified-digest"; 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 }); 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, catalogReuse }) : 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: { deployJson: deployJsonPath, artifactCatalog: artifactCatalog ? artifactCatalogPath : null, ciContractSource: "scripts/g14-gitops-render.mjs", mode: "advisory-read-only", currentRenderCompatible: true, plannerMutatesDeployJson: false, serviceIdSource: serviceIdResolution.source, defaultComponentLazyBuild: true, fullBuildFallback: false }, 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) { 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 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 = 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, catalogReuse = null }) { 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"); 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 runtimeKindForService(serviceId) { if (bunCommandServices.has(serviceId)) return "bun-command"; 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 (bunCommandServices.has(serviceId)) return `cmd/${serviceId}/main.ts`; 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; }