// SPEC: PJ2026-01060703 CI/CD branch follower AgentRun reuse decisions. // Responsibility: compute AgentRun per-service reuse decisions and bounded CI consumption evidence. import { createHash } from "node:crypto"; import { repoRoot } from "./config"; import { runCommand } from "./command"; import type { AgentRunArtifactService } from "./agentrun-manifests"; import type { AgentRunLaneSpec } from "./agentrun-lanes"; import type { FollowerSpec, NativeK8sJobResult } from "./cicd-types"; import { RUNTIME_REUSE_CONFIG_PATH, runtimeReuseService, type RuntimeReuseConfig } from "./cicd-reuse-config"; export interface AgentRunReusePlan { ok: boolean; sourceCommit: string; stageRef: string; decisions: AgentRunReuseDecision[]; decisionSummary: { serviceCount: number; skipImageBuildCount: number; buildImageCount: number; skipImageBuildServices: string[]; buildImageServices: string[]; }; errors: string[]; valuesRedacted: true; } export interface AgentRunReuseDecision { serviceId: string; sourceIdentity: AgentRunIdentityComparison; envIdentity: AgentRunIdentityComparison; runtimeReuse: { enabled: boolean; hit: boolean; reason: string }; envReuse: { enabled: boolean; hit: boolean; reason: string }; skipImageBuild: boolean; buildDecision: "skipImageBuild" | "buildImage"; reusableImageRef: string | null; reusableImageRefKnown: boolean; reason: string; } interface AgentRunIdentityComparison { configured: boolean; pathCount: number; hit: boolean; status: "not-configured" | "base-missing" | "hit" | "miss"; currentMissingCount: number; previousMissingCount: number | null; } export function buildAgentRunReusePlan(follower: FollowerSpec, reuseConfig: RuntimeReuseConfig, sourceCommit: string, timeoutSeconds: number): AgentRunReusePlan { const stageRef = `${follower.source.snapshotPrefix.replace(/\/+$/u, "")}/${sourceCommit}`; const service = runtimeReuseService(reuseConfig, ["agentrun-mgr", "manager"]); const decisions = service === null ? [] : [agentRunReuseDecision(follower.nativeStatus.source.repoPath, stageRef, service, timeoutSeconds)]; const summary = agentRunDecisionSummary(decisions); return { ok: reuseConfig.ok && decisions.length > 0, sourceCommit, stageRef, decisions, decisionSummary: summary, errors: service === null ? [`${RUNTIME_REUSE_CONFIG_PATH} must declare service agentrun-mgr|manager`] : reuseConfig.errors.slice(0, 5), valuesRedacted: true, }; } export function compactAgentRunReusePlan(plan: AgentRunReusePlan): Record { return { ok: plan.ok, sourceCommit: plan.sourceCommit, stageRef: plan.stageRef, decisionSummary: plan.decisionSummary, decisions: plan.decisions.slice(0, 8).map((decision) => ({ serviceId: decision.serviceId, sourceIdentity: compactIdentity(decision.sourceIdentity), envIdentity: compactIdentity(decision.envIdentity), runtimeReuse: { enabled: decision.runtimeReuse.enabled, hit: decision.runtimeReuse.hit }, envReuse: { enabled: decision.envReuse.enabled, hit: decision.envReuse.hit }, skipImageBuild: decision.skipImageBuild, buildDecision: decision.buildDecision, reusableImageRef: decision.reusableImageRef, reusableImageRefKnown: decision.reusableImageRefKnown, reason: decision.reason, })), errors: plan.errors.slice(0, 5), valuesRedacted: true, }; } export function agentRunManagerDecision(plan: AgentRunReusePlan): AgentRunReuseDecision | null { return plan.decisions.find((decision) => decision.serviceId === "agentrun-mgr" || decision.serviceId === "manager") ?? null; } export function applyAgentRunReuseConfig(spec: AgentRunLaneSpec, reuseConfig: RuntimeReuseConfig): AgentRunLaneSpec { const service = runtimeReuseService(reuseConfig, ["agentrun-mgr", "manager"]); const envReuse = service?.envReuse; if (envReuse === undefined || envReuse === null) return spec; if (envReuse.enabled === false) return spec; const imageBuild = spec.deployment.manager.imageBuild; return { ...spec, deployment: { ...spec.deployment, manager: { ...spec.deployment.manager, imageBuild: { ...imageBuild, buildArgs: Object.keys(envReuse.buildArgs).length === 0 ? imageBuild.buildArgs : envReuse.buildArgs, envIdentityFiles: envReuse.envIdentityFiles.length === 0 ? imageBuild.envIdentityFiles : envReuse.envIdentityFiles, }, }, }, }; } export function reusableAgentRunImageArtifact(spec: AgentRunLaneSpec, repoPath: string, sourceCommit: string, timeoutSeconds: number): { image: AgentRunArtifactService | null; evidence: Record } { const artifactRef = `refs/heads/${spec.gitops.branch}:${spec.deployment.artifactCatalogPath}`; const result = runCommand(["git", "--git-dir", repoPath, "show", artifactRef], repoRoot, { timeoutMs: budgetMs(timeoutSeconds) }); if (result.exitCode !== 0) { return { image: null, evidence: { ok: false, status: "missing", artifactRef, reason: shortText(result.stderr || result.stdout || "artifact catalog missing"), valuesRedacted: true, }, }; } const parsed = parseJsonRecord(result.stdout); const services = Array.isArray(parsed?.services) ? parsed.services : []; const service = services.find((item) => record(item).serviceId === "agentrun-mgr"); const row = record(service); const digest = stringOrNull(row.digest) ?? stringOrNull(row.envDigest); const envIdentity = stringOrNull(row.envIdentity) ?? stringOrNull(row.imageTag); const image = stringOrNull(row.image) ?? stringOrNull(row.envImage); if (digest === null || envIdentity === null || image === null) { return { image: null, evidence: { ok: false, status: "invalid", artifactRef, reason: "artifact catalog lacks image/digest/envIdentity for agentrun-mgr", valuesRedacted: true, }, }; } const repositoryDigest = stringOrNull(row.repositoryDigest) ?? `${imageRepository(image)}@${digest}`; const artifact: AgentRunArtifactService = { serviceId: "agentrun-mgr", artifactKind: stringOrNull(row.artifactKind) ?? "env-reuse", status: "reused", image, digest, repositoryDigest, imageTag: envIdentity, envIdentity, envImage: stringOrNull(row.envImage) ?? image, envDigest: stringOrNull(row.envDigest) ?? digest, envRepositoryDigest: stringOrNull(row.envRepositoryDigest) ?? repositoryDigest, bootCommit: sourceCommit, bootScript: stringOrNull(row.bootScript) ?? "deploy/runtime/boot/agentrun-boot.sh", provenance: { sourceCommitId: sourceCommit, previousSourceCommitId: stringOrNull(parsed?.sourceCommitId), source: "unidesk-yaml-only-reuse", artifactRef, valuesPrinted: false, }, }; return { image: artifact, evidence: { ok: true, status: "reused", artifactRef, previousSourceCommitId: stringOrNull(parsed?.sourceCommitId), image, digest, envIdentity, valuesRedacted: true, }, }; } export function skippedAgentRunImageBuildResult(namespace: string, jobName: string, summary: Record): NativeK8sJobResult { return { ok: true, completed: true, failed: false, timedOut: false, created: false, reused: true, jobName, namespace, polls: 0, elapsedMs: 0, logsTail: null, summary, conditionReason: "Skipped", conditionMessage: "reuse-plan skipImageBuild consumed by AgentRun trigger", statusAuthority: "kubernetes-api-serviceaccount", parsedDownstreamCliOutput: false, }; } export function agentRunCiConsumptionEvidence(input: { sourceCommit: string; plan: AgentRunReusePlan; imageBuild: { jobName: string; result: NativeK8sJobResult; payload: Record }; gitopsPublish: { jobName: string; result: NativeK8sJobResult; payload: Record }; }): Record { const decision = agentRunManagerDecision(input.plan); const expectedSkip = input.plan.decisionSummary.skipImageBuildCount; const expectedBuild = input.plan.decisionSummary.buildImageCount; const skipped = decision?.skipImageBuild === true; const imageBuildConsumed = skipped ? input.imageBuild.result.reused === true && input.imageBuild.payload.status === "skipped" : input.imageBuild.result.completed === true && input.imageBuild.payload.status !== "skipped"; return { ok: input.plan.ok && imageBuildConsumed && input.gitopsPublish.result.completed === true, sourceCommit: input.sourceCommit, source: "agentrun-native-trigger", expected: { skipImageBuildCount: expectedSkip, buildImageCount: expectedBuild, skipImageBuildServices: input.plan.decisionSummary.skipImageBuildServices, buildImageServices: input.plan.decisionSummary.buildImageServices, }, observed: { imageBuildDecision: skipped ? "skipImageBuild" : "buildImage", imageBuildStatus: stringOrNull(input.imageBuild.payload.status), imageBuildJobName: input.imageBuild.jobName, imageBuildCreated: input.imageBuild.result.created, imageBuildReused: input.imageBuild.result.reused, gitopsPublishJobName: input.gitopsPublish.jobName, gitopsPublishCompleted: input.gitopsPublish.result.completed, buildServicesCount: skipped ? 0 : 1, reusedServicesCount: skipped ? 1 : 0, buildSkippedCount: skipped ? 1 : 0, }, mismatches: imageBuildConsumed ? [] : [{ reason: "image-build-stage-did-not-consume-reuse-plan", serviceIds: ["agentrun-mgr"] }], valuesRedacted: true, }; } export function compactAgentRunPayload(value: Record | null): Record | null { if (value === null) return null; return { configPath: stringOrNull(value.configPath), sourceCommit: stringOrNull(value.sourceCommit), reusePlan: compactRecord(value.reusePlan), ciConsumption: compactRecord(value.ciConsumption), gitMirrorSync: compactStageRecord(value.gitMirrorSync), imageBuild: compactStageRecord(value.imageBuild), gitopsPublish: compactStageRecord(value.gitopsPublish), gitMirrorFlush: compactStageRecord(value.gitMirrorFlush), valuesRedacted: true, }; } function agentRunReuseDecision(repoPath: string, stageRef: string, service: NonNullable>, timeoutSeconds: number): AgentRunReuseDecision { const baseRef = parentRef(repoPath, stageRef, timeoutSeconds); const runtime = service.runtimeReuse; const envReuse = service.envReuse; const codePaths = runtime?.codeIdentityPaths ?? []; const envPaths = uniqueStrings([...(runtime?.envIdentityPaths ?? []), ...(envReuse?.envIdentityFiles ?? [])]); const sourceIdentity = identityComparison(repoPath, stageRef, baseRef, codePaths, timeoutSeconds); const envIdentity = identityComparison(repoPath, stageRef, baseRef, envPaths, timeoutSeconds); const runtimeEnabled = runtime?.enabled !== false; const envEnabled = envReuse?.enabled !== false; const runtimeHit = runtimeEnabled && sourceIdentity.hit && envIdentity.hit; const envHit = envEnabled && envIdentity.hit; const skipImageBuild = runtimeHit || envHit; return { serviceId: service.id, sourceIdentity, envIdentity, runtimeReuse: { enabled: runtimeEnabled, hit: runtimeHit, reason: runtimeHit ? "source-and-env-identity-hit" : missReason(sourceIdentity, envIdentity, runtimeEnabled) }, envReuse: { enabled: envEnabled, hit: envHit, reason: envHit ? "env-identity-hit" : missReason(null, envIdentity, envEnabled) }, skipImageBuild, buildDecision: skipImageBuild ? "skipImageBuild" : "buildImage", reusableImageRef: null, reusableImageRefKnown: false, reason: runtimeHit ? "runtime-reuse-hit" : envHit ? "env-reuse-hit" : "reuse-miss", }; } function identityComparison(repoPath: string, currentRef: string, baseRef: string | null, paths: string[], timeoutSeconds: number): AgentRunIdentityComparison { const current = identityDigest(repoPath, currentRef, paths, timeoutSeconds); const previous = baseRef === null ? null : identityDigest(repoPath, baseRef, paths, timeoutSeconds); const configured = paths.length > 0; const hit = configured && previous !== null && current.sha256 !== null && previous.sha256 !== null && current.sha256 === previous.sha256; return { configured, pathCount: paths.length, hit, status: !configured ? "not-configured" : previous === null ? "base-missing" : hit ? "hit" : "miss", currentMissingCount: current.missingCount, previousMissingCount: previous?.missingCount ?? null, }; } function identityDigest(repoPath: string, ref: string, paths: string[], timeoutSeconds: number): { sha256: string | null; missingCount: number } { if (paths.length === 0) return { sha256: null, missingCount: 0 }; const hash = createHash("sha256"); let missingCount = 0; for (const path of paths) { const result = runCommand(["git", "--git-dir", repoPath, "ls-tree", "-r", "-z", "--full-tree", ref, "--", path], repoRoot, { timeoutMs: budgetMs(timeoutSeconds) }); const entries = result.exitCode === 0 ? result.stdout.split("\0").filter(Boolean).sort() : []; if (entries.length === 0) missingCount += 1; hash.update(path); hash.update("\0"); for (const entry of entries) { hash.update(entry); hash.update("\0"); } } return { sha256: hash.digest("hex"), missingCount }; } function parentRef(repoPath: string, ref: string, timeoutSeconds: number): string | null { const result = runCommand(["git", "--git-dir", repoPath, "rev-parse", "--verify", `${ref}^`], repoRoot, { timeoutMs: budgetMs(timeoutSeconds) }); const value = result.stdout.trim(); return result.exitCode === 0 && /^[0-9a-f]{40}$/iu.test(value) ? value : null; } function agentRunDecisionSummary(decisions: AgentRunReuseDecision[]): AgentRunReusePlan["decisionSummary"] { const skip = decisions.filter((item) => item.skipImageBuild).map((item) => item.serviceId).sort(); const build = decisions.filter((item) => !item.skipImageBuild).map((item) => item.serviceId).sort(); return { serviceCount: decisions.length, skipImageBuildCount: skip.length, buildImageCount: build.length, skipImageBuildServices: skip, buildImageServices: build, }; } function missReason(sourceIdentity: AgentRunIdentityComparison | null, envIdentity: AgentRunIdentityComparison, enabled: boolean): string { if (!enabled) return "reuse-disabled"; if (sourceIdentity?.configured === false || envIdentity.configured === false) return "identity-not-configured"; if (sourceIdentity?.status === "base-missing" || envIdentity.status === "base-missing") return "base-identity-missing"; if (sourceIdentity?.status === "miss") return "source-identity-miss"; if (envIdentity.status === "miss") return "env-identity-miss"; return "identity-miss"; } function compactIdentity(value: AgentRunIdentityComparison): Record { return { configured: value.configured, pathCount: value.pathCount, hit: value.hit, status: value.status, missing: { current: value.currentMissingCount, previous: value.previousMissingCount, }, }; } function compactStageRecord(value: unknown): Record | null { const stage = record(value); if (Object.keys(stage).length === 0) return null; const result = record(stage.result); const payload = record(stage.payload); return { jobName: stringOrNull(stage.jobName), result: Object.keys(result).length === 0 ? null : { ok: result.ok === true, completed: result.completed === true, failed: result.failed === true, timedOut: result.timedOut === true, created: result.created === true, reused: result.reused === true, jobName: stringOrNull(result.jobName), namespace: stringOrNull(result.namespace), elapsedMs: typeof result.elapsedMs === "number" ? result.elapsedMs : null, conditionReason: stringOrNull(result.conditionReason), conditionMessage: stringOrNull(result.conditionMessage), statusAuthority: stringOrNull(result.statusAuthority), parsedDownstreamCliOutput: false, }, payload: compactRecord(payload), }; } function compactRecord(value: unknown): Record | null { const row = record(value); return Object.keys(row).length === 0 ? null : JSON.parse(JSON.stringify(row)) as Record; } function parseJsonRecord(text: string): Record | null { try { const parsed = JSON.parse(text) as unknown; return record(parsed); } catch { return null; } } function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } function stringOrNull(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } function uniqueStrings(values: string[]): string[] { return [...new Set(values.filter((value) => value.length > 0))]; } function imageRepository(image: string): string { const index = image.lastIndexOf(":"); return index > 0 ? image.slice(0, index) : image; } function shortText(value: string): string { const text = value.replace(/\s+/gu, " ").trim(); return text.length <= 300 ? text : text.slice(0, 300); } function budgetMs(timeoutSeconds: number): number { return Math.max(1, timeoutSeconds) * 1000; }