// SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower. // Responsibility: YAML-first K8s branch-follower controller, status, and adapter orchestration. import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { isAbsolute } from "node:path"; import { repoRoot, rootPath, type UniDeskConfig } from "./config"; import { runCommand, type CommandResult } from "./command"; import { startJob } from "./jobs"; import type { RenderedCliResult } from "./output"; import { transPath } from "./hwlab-node/runtime-common"; import { configRefGraph, resolveConfigRefString } from "./ops/config-refs"; import { arrayField, asRecord, booleanField, integerField, readYamlRecord, recordField, redactText, shQuote, stringArrayField, stringField, } from "./platform-infra-ops-library"; const DEFAULT_CONFIG_PATH = "config/cicd-branch-followers.yaml"; const SPEC_REF = "PJ2026-01060703"; const SPEC_VERSION = "draft-2026-07-03-p0-branch-follower"; type OutputMode = "human" | "json" | "yaml"; type BranchFollowerAction = "help" | "plan" | "apply" | "status" | "run-once" | "events" | "logs"; type BranchFollowerPhase = | "Observed" | "Noop" | "PendingTrigger" | "Triggering" | "ClosingOut" | "Succeeded" | "Failed" | "Superseded" | "Blocked" | "Skipped"; interface ParsedOptions { action: BranchFollowerAction; configPath: string; followerId: string | null; all: boolean; confirm: boolean; dryRun: boolean; wait: boolean; controller: boolean; live: boolean; noLive: boolean; full: boolean; raw: boolean; output: OutputMode; limit: number; tailBytes: number; timeoutSeconds: number | null; } interface CommandSpec { argv: string[]; timeoutSeconds: number; } interface FollowerSpec { id: string; enabled: boolean; adapter: string; description: string; source: { repository: string; branch: string; branchRef: string; authorityRef: string; snapshotPrefix: string; snapshotRef: string; }; target: { node: string; lane: string; namespace: string; sentinel: string | null; configRefs: Record; }; budgets: { endToEndSeconds: number; statusSeconds: number; triggerSeconds: number; sourceSyncSeconds: number; }; commands: { plan: CommandSpec; status: CommandSpec; trigger: CommandSpec; events: CommandSpec; logs: CommandSpec; }; closeoutChecks: string[]; } interface ControllerSpec { namespace: string; kubeRoute: string; fieldManager: string; serviceAccountName: string; deploymentName: string; configMapName: string; stateConfigMapName: string; leaseName: string; image: string; labels: Record; source: { repository: string; branch: string; gitMirrorReadUrl: string; sourceAuthority: { mode: string; resolver: string; allowHostGit: boolean; allowHostWorkspace: boolean; allowGithubDirectInPipeline: boolean; }; sourceSnapshot: { stageRefPrefix: string; missingObjectPolicy: string; refreshPolicy: string; }; }; loop: { intervalSeconds: number; reconcileTimeoutSeconds: number; }; budgets: { applyWaitSeconds: number; statusSeconds: number; runOnceSeconds: number; }; } interface BranchFollowerRegistry { path: string; rawText: string; rawSha256: string; metadata: { id: string; owner: string; specRef: string; version: string; }; controller: ControllerSpec; followers: FollowerSpec[]; } interface AdapterSummary { ok: boolean; command: string; exitCode: number | null; timedOut: boolean; observedSha: string | null; targetSha: string | null; lastTriggeredSha: string | null; lastSucceededSha: string | null; pipelineRun: string | null; inFlightJob: string | null; aligned: boolean | null; phase: BranchFollowerPhase; message: string; payload: Record | null; stderrTail: string; stdoutTail: string; } interface FollowerState { id: string; adapter: string; enabled: boolean; phase: BranchFollowerPhase; source: { repository: string; branch: string; branchRef: string; snapshotPrefix: string; observedSha: string | null; }; target: { node: string; lane: string; namespace: string; sentinel: string | null; targetSha: string | null; }; lastTriggeredSha: string | null; lastSucceededSha: string | null; pipelineRun: string | null; inFlightJob: string | null; budgetSource: Record; controller: { mode: "local-cli" | "k8s-controller"; stateConfigMap: string; leaseName: string; }; decision: string; dryRun: boolean; updatedAt: string; warnings: string[]; next: Record; command?: Record; } interface K8sStateRead { ok: boolean; stateByFollower: Record>; stateConfigMapPresent: boolean; deployment: Record | null; lease: Record | null; pods: Record | null; errors: string[]; } export function cicdHelp(): unknown { return { command: "cicd branch-follower plan|apply|status|run-once|events|logs", output: "text by default; use --json, --raw, or -o json|yaml for machine output", usage: [ "bun scripts/cli.ts cicd branch-follower plan", "bun scripts/cli.ts cicd branch-follower apply --confirm --wait", "bun scripts/cli.ts cicd branch-follower status", "bun scripts/cli.ts cicd branch-follower status --live", "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run", "bun scripts/cli.ts cicd branch-follower run-once --follower hwlab-jd01-v03 --confirm --wait", "bun scripts/cli.ts cicd branch-follower events --follower agentrun-d601-v02", "bun scripts/cli.ts cicd branch-follower logs --follower web-probe-sentinel-master", ], config: DEFAULT_CONFIG_PATH, spec: `${SPEC_REF} ${SPEC_VERSION}`, description: "Deploy and inspect the YAML-first Kubernetes branch follower that follows HWLAB v0.3, AgentRun v0.2, and the selected web-probe sentinel master lane without using host worktrees as source authority.", }; } export async function runCicdCommand(_config: UniDeskConfig | null, args: string[]): Promise { const top = args[0]; if (top === undefined || isHelpToken(top)) return renderMachine("cicd", cicdHelp(), "json"); if (top !== "branch-follower") { throw new Error("cicd usage: cicd branch-follower plan|apply|status|run-once|events|logs"); } const options = parseOptions(args.slice(1)); const command = commandLabel(options); if (options.action === "help") return renderMachine(command, cicdHelp(), "json"); const registry = readRegistry(options.configPath); switch (options.action) { case "plan": return renderResult(command, buildPlan(registry, options), options); case "apply": return renderResult(command, await applyController(registry, options), options); case "status": return renderResult(command, await buildStatus(registry, options), options); case "run-once": return renderResult(command, await runOnce(registry, options), options); case "events": case "logs": return renderResult(command, runFollowerDrillDown(registry, options), options); case "help": return renderMachine(command, cicdHelp(), "json"); } } function parseOptions(args: string[]): ParsedOptions { const actionToken = args[0]; if (actionToken === undefined || isHelpToken(actionToken)) { return defaultOptions("help", args.slice(actionToken === undefined ? 0 : 1)); } if (!["plan", "apply", "status", "run-once", "events", "logs"].includes(actionToken)) { throw new Error(`cicd branch-follower unknown action: ${actionToken}`); } const action = actionToken as BranchFollowerAction; const options = defaultOptions(action, []); const rest = args.slice(1); for (let index = 0; index < rest.length; index += 1) { const arg = rest[index] ?? ""; if (isHelpToken(arg)) { options.action = "help"; } else if (arg === "--config") { options.configPath = valueOption(rest, ++index, arg); } else if (arg === "--follower" || arg === "--target") { options.followerId = simpleId(valueOption(rest, ++index, arg), arg); } else if (arg === "--all") { options.all = true; } else if (arg === "--confirm") { options.confirm = true; } else if (arg === "--dry-run") { options.dryRun = true; } else if (arg === "--wait") { options.wait = true; } else if (arg === "--controller") { options.controller = true; } else if (arg === "--live") { options.live = true; } else if (arg === "--no-live") { options.noLive = true; } else if (arg === "--full") { options.full = true; } else if (arg === "--raw" || arg === "--json") { options.raw = true; options.full = true; options.output = "json"; } else if (arg === "-o" || arg === "--output") { const value = valueOption(rest, ++index, arg); if (value !== "json" && value !== "yaml" && value !== "wide" && value !== "text") throw new Error(`${arg} must be json, yaml, wide, or text`); options.output = value === "wide" || value === "text" ? "human" : value; if (value === "json" || value === "yaml") { options.raw = true; options.full = true; } } else if (arg === "--limit") { options.limit = positiveInt(valueOption(rest, ++index, arg), arg); } else if (arg === "--tail-bytes" || arg === "--tail") { options.tailBytes = positiveInt(valueOption(rest, ++index, arg), arg); } else if (arg === "--timeout-seconds") { options.timeoutSeconds = positiveInt(valueOption(rest, ++index, arg), arg); } else { throw new Error(`unsupported cicd branch-follower option: ${arg}`); } } if (options.confirm && options.dryRun) throw new Error("cicd branch-follower accepts only one of --confirm or --dry-run"); if (options.action === "apply" && !options.confirm) options.dryRun = true; if (options.action === "run-once" && !options.confirm) options.dryRun = true; if (options.action === "run-once" && options.confirm && !options.all && options.followerId === null) { throw new Error("run-once --confirm requires --all or --follower "); } return options; } function defaultOptions(action: BranchFollowerAction, _args: string[]): ParsedOptions { return { action, configPath: DEFAULT_CONFIG_PATH, followerId: null, all: false, confirm: false, dryRun: false, wait: false, controller: false, live: false, noLive: false, full: false, raw: false, output: "human", limit: 20, tailBytes: 12000, timeoutSeconds: null, }; } function valueOption(args: string[], index: number, option: string): string { const value = args[index]; if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${option} requires a value`); return value; } function simpleId(value: string, option: string): string { if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${option} must be a simple id`); return value; } function positiveInt(value: string, option: string): number { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${option} must be a positive integer`); return parsed; } function isHelpToken(value: string): boolean { return value === "-h" || value === "--help" || value === "help"; } function readRegistry(configPath: string): BranchFollowerRegistry { const absolute = isAbsolute(configPath) ? configPath : rootPath(configPath); const rawText = readFileSync(absolute, "utf8"); const root = readYamlRecord>(absolute, "CicdBranchFollowerRegistry"); const metadata = recordField(root, "metadata", configPath); const controller = parseController(recordField(root, "controller", configPath)); const followers = arrayField(root, "followers", configPath).map(parseFollower); const ids = new Set(); for (const follower of followers) { if (ids.has(follower.id)) throw new Error(`${configPath}.followers has duplicate id ${follower.id}`); ids.add(follower.id); } return { path: configPath, rawText, rawSha256: createHash("sha256").update(rawText).digest("hex"), metadata: { id: stringField(metadata, "id", `${configPath}.metadata`), owner: stringField(metadata, "owner", `${configPath}.metadata`), specRef: stringField(metadata, "specRef", `${configPath}.metadata`), version: stringField(metadata, "version", `${configPath}.metadata`), }, controller, followers, }; } function parseController(root: Record): ControllerSpec { const source = recordField(root, "source", "controller"); const authority = recordField(source, "sourceAuthority", "controller.source"); const snapshot = recordField(source, "sourceSnapshot", "controller.source"); const loop = recordField(root, "loop", "controller"); const budgets = recordField(root, "budgets", "controller"); const result: ControllerSpec = { namespace: stringField(root, "namespace", "controller"), kubeRoute: stringField(root, "kubeRoute", "controller"), fieldManager: stringField(root, "fieldManager", "controller"), serviceAccountName: stringField(root, "serviceAccountName", "controller"), deploymentName: stringField(root, "deploymentName", "controller"), configMapName: stringField(root, "configMapName", "controller"), stateConfigMapName: stringField(root, "stateConfigMapName", "controller"), leaseName: stringField(root, "leaseName", "controller"), image: stringField(root, "image", "controller"), labels: stringMap(recordField(root, "labels", "controller"), "controller.labels"), source: { repository: stringField(source, "repository", "controller.source"), branch: stringField(source, "branch", "controller.source"), gitMirrorReadUrl: stringField(source, "gitMirrorReadUrl", "controller.source"), sourceAuthority: { mode: stringField(authority, "mode", "controller.source.sourceAuthority"), resolver: stringField(authority, "resolver", "controller.source.sourceAuthority"), allowHostGit: booleanField(authority, "allowHostGit", "controller.source.sourceAuthority"), allowHostWorkspace: booleanField(authority, "allowHostWorkspace", "controller.source.sourceAuthority"), allowGithubDirectInPipeline: booleanField(authority, "allowGithubDirectInPipeline", "controller.source.sourceAuthority"), }, sourceSnapshot: { stageRefPrefix: stringField(snapshot, "stageRefPrefix", "controller.source.sourceSnapshot"), missingObjectPolicy: stringField(snapshot, "missingObjectPolicy", "controller.source.sourceSnapshot"), refreshPolicy: stringField(snapshot, "refreshPolicy", "controller.source.sourceSnapshot"), }, }, loop: { intervalSeconds: integerField(loop, "intervalSeconds", "controller.loop"), reconcileTimeoutSeconds: integerField(loop, "reconcileTimeoutSeconds", "controller.loop"), }, budgets: { applyWaitSeconds: integerField(budgets, "applyWaitSeconds", "controller.budgets"), statusSeconds: integerField(budgets, "statusSeconds", "controller.budgets"), runOnceSeconds: integerField(budgets, "runOnceSeconds", "controller.budgets"), }, }; if (result.source.sourceAuthority.allowHostGit || result.source.sourceAuthority.allowHostWorkspace || result.source.sourceAuthority.allowGithubDirectInPipeline) { throw new Error("controller.source.sourceAuthority must disable host git, host workspace, and direct GitHub pipeline fallback"); } return result; } function parseFollower(root: Record, index: number): FollowerSpec { const label = `followers[${index}]`; const source = recordField(root, "source", label); const target = recordField(root, "target", label); const budgets = recordField(root, "budgets", label); const commands = recordField(root, "commands", label); const closeout = recordField(root, "closeout", label); const configRefs = stringMap(recordField(target, "configRefs", `${label}.target`), `${label}.target.configRefs`); return { id: simpleId(stringField(root, "id", label), `${label}.id`), enabled: booleanField(root, "enabled", label), adapter: stringField(root, "adapter", label), description: stringField(root, "description", label), source: { repository: stringField(source, "repository", `${label}.source`), branch: stringField(source, "branch", `${label}.source`), branchRef: stringField(source, "branchRef", `${label}.source`), authorityRef: stringField(source, "authorityRef", `${label}.source`), snapshotPrefix: stringField(source, "snapshotPrefix", `${label}.source`), snapshotRef: stringField(source, "snapshotRef", `${label}.source`), }, target: { node: stringField(target, "node", `${label}.target`), lane: stringField(target, "lane", `${label}.target`), namespace: stringField(target, "namespace", `${label}.target`), sentinel: typeof target.sentinel === "string" && target.sentinel.length > 0 ? target.sentinel : null, configRefs, }, budgets: { endToEndSeconds: integerField(budgets, "endToEndSeconds", `${label}.budgets`), statusSeconds: integerField(budgets, "statusSeconds", `${label}.budgets`), triggerSeconds: integerField(budgets, "triggerSeconds", `${label}.budgets`), sourceSyncSeconds: integerField(budgets, "sourceSyncSeconds", `${label}.budgets`), }, commands: { plan: parseCommand(recordField(commands, "plan", `${label}.commands`), `${label}.commands.plan`), status: parseCommand(recordField(commands, "status", `${label}.commands`), `${label}.commands.status`), trigger: parseCommand(recordField(commands, "trigger", `${label}.commands`), `${label}.commands.trigger`), events: parseCommand(recordField(commands, "events", `${label}.commands`), `${label}.commands.events`), logs: parseCommand(recordField(commands, "logs", `${label}.commands`), `${label}.commands.logs`), }, closeoutChecks: stringArrayField(closeout, "checks", `${label}.closeout`), }; } function parseCommand(root: Record, label: string): CommandSpec { return { argv: stringArrayField(root, "argv", label), timeoutSeconds: integerField(root, "timeoutSeconds", label), }; } function stringMap(root: Record, label: string): Record { const result: Record = {}; for (const [key, value] of Object.entries(root)) { if (typeof value !== "string" || value.length === 0) throw new Error(`${label}.${key} must be a non-empty string`); result[key] = value; } return result; } function buildPlan(registry: BranchFollowerRegistry, options: ParsedOptions): Record { const selected = selectFollowers(registry, options, { includeDisabled: true }); const followers = selected.map((follower) => { const branchValue = safeResolveString(follower.source.branchRef); const graph = configRefGraph([ { id: "source.branch", ref: follower.source.branchRef }, { id: "source.authority", ref: follower.source.authorityRef }, { id: "source.snapshot", ref: follower.source.snapshotRef }, ...Object.entries(follower.target.configRefs).map(([id, ref]) => ({ id: `target.${id}`, ref })), ]); const warnings: string[] = []; if (branchValue !== null && branchValue !== follower.source.branch) warnings.push(`source.branch ${follower.source.branch} differs from ${follower.source.branchRef} value ${branchValue}`); return { id: follower.id, enabled: follower.enabled, adapter: follower.adapter, description: follower.description, source: { repository: follower.source.repository, branch: follower.source.branch, branchRef: follower.source.branchRef, resolvedBranch: branchValue, snapshotPrefix: follower.source.snapshotPrefix, }, target: follower.target, budgets: follower.budgets, commands: redactCommands(follower), closeoutChecks: follower.closeoutChecks, configRefGraph: graph, warnings, }; }); return { ok: true, action: "plan", spec: `${SPEC_REF} ${SPEC_VERSION}`, registry: registrySummary(registry), hostWorktreeAuthority: false, sourceAuthority: { mode: registry.controller.source.sourceAuthority.mode, resolver: registry.controller.source.sourceAuthority.resolver, allowHostGit: registry.controller.source.sourceAuthority.allowHostGit, allowHostWorkspace: registry.controller.source.sourceAuthority.allowHostWorkspace, allowGithubDirectInPipeline: registry.controller.source.sourceAuthority.allowGithubDirectInPipeline, }, controller: { namespace: registry.controller.namespace, kubeRoute: registry.controller.kubeRoute, deploymentName: registry.controller.deploymentName, stateConfigMapName: registry.controller.stateConfigMapName, leaseName: registry.controller.leaseName, image: registry.controller.image, loop: registry.controller.loop, budgets: registry.controller.budgets, }, followers, next: { apply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait", status: "bun scripts/cli.ts cicd branch-follower status", dryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run", }, }; } async function applyController(registry: BranchFollowerRegistry, options: ParsedOptions): Promise> { const manifests = renderControllerManifests(registry); const manifestYaml = `${manifests.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; const manifestBase64 = Buffer.from(manifestYaml, "utf8").toString("base64"); const waitSeconds = options.timeoutSeconds ?? registry.controller.budgets.applyWaitSeconds; const script = [ "set -eu", "tmp=$(mktemp)", "base64 -d >\"$tmp\" <<'UNIDESK_CICD_BRANCH_FOLLOWER_MANIFEST_B64'", manifestBase64, "UNIDESK_CICD_BRANCH_FOLLOWER_MANIFEST_B64", options.dryRun ? `kubectl apply --dry-run=server --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp"` : `kubectl apply --server-side --force-conflicts --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp"`, !options.dryRun && options.wait ? `kubectl -n ${shQuote(registry.controller.namespace)} rollout status deploy/${shQuote(registry.controller.deploymentName)} --timeout=${waitSeconds}s` : "true", `kubectl -n ${shQuote(registry.controller.namespace)} get deploy/${shQuote(registry.controller.deploymentName)} cm/${shQuote(registry.controller.configMapName)} cm/${shQuote(registry.controller.stateConfigMapName)} lease/${shQuote(registry.controller.leaseName)} -o wide 2>/dev/null || true`, ].join("\n"); const result = runKubeScript(registry, options, script, "", (waitSeconds + 15) * 1000); return { ok: result.exitCode === 0, action: "apply", dryRun: options.dryRun, wait: options.wait, registry: registrySummary(registry), objects: manifests.map((item) => objectRef(item)), manifestSha256: createHash("sha256").update(manifestYaml).digest("hex"), controller: { namespace: registry.controller.namespace, route: registry.controller.kubeRoute, deploymentName: registry.controller.deploymentName, stateConfigMapName: registry.controller.stateConfigMapName, leaseName: registry.controller.leaseName, hostWorktreeMounted: false, sourceMode: "k8s-git-mirror-to-emptyDir", }, command: commandCompact(result, options), next: { status: "bun scripts/cli.ts cicd branch-follower status", logs: `bun scripts/cli.ts cicd branch-follower logs --follower ${registry.followers[0]?.id ?? ""}`, dryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run", }, }; } async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOptions): Promise> { const k8s = readK8sState(registry, options); const shouldLive = options.live || (!options.noLive && Object.keys(k8s.stateByFollower).length === 0); const selected = selectFollowers(registry, options, { includeDisabled: true }); const followers = []; for (const follower of selected) { const stored = k8s.stateByFollower[follower.id] ?? {}; const live = shouldLive && follower.enabled ? await readAdapterStatus(follower, options) : null; followers.push(mergeFollowerStatus(registry, follower, stored, live, shouldLive)); } return { ok: k8s.ok && followers.every((item) => item.ok !== false), action: "status", live: shouldLive, registry: registrySummary(registry), controller: controllerStatusSummary(registry, k8s), followers, errors: k8s.errors, next: { apply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait", liveStatus: "bun scripts/cli.ts cicd branch-follower status --live", dryRun: "bun scripts/cli.ts cicd branch-follower run-once --all --dry-run", }, }; } async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions): Promise> { const selected = selectFollowers(registry, options, { includeDisabled: false }); const previous = readK8sState(registry, options); const results: FollowerState[] = []; const stateWriteWarnings: string[] = []; for (const follower of selected) { const oldState = previous.stateByFollower[follower.id] ?? {}; const live = await readAdapterStatus(follower, options); const state = await decideAndMaybeTrigger(registry, follower, oldState, live, options); if (!options.dryRun) { const write = writeFollowerState(registry, state, options); if (write.exitCode !== 0) { const warning = `state write failed for ${follower.id}: ${tailText(write.stderr || write.stdout, 300)}`; state.warnings.push(warning); stateWriteWarnings.push(warning); } } results.push(state); } return { ok: results.every((item) => item.phase !== "Failed" && item.phase !== "Blocked"), action: "run-once", dryRun: options.dryRun, confirm: options.confirm, wait: options.wait, controller: options.controller, registry: registrySummary(registry), followers: results, warnings: stateWriteWarnings, next: { status: "bun scripts/cli.ts cicd branch-follower status", liveStatus: "bun scripts/cli.ts cicd branch-follower status --live", }, }; } function runFollowerDrillDown(registry: BranchFollowerRegistry, options: ParsedOptions): Record { if (options.followerId === null) { return { ok: true, action: options.action, message: "select one follower to run the configured drill-down command", followers: registry.followers.map((follower) => ({ id: follower.id, adapter: follower.adapter, command: (options.action === "events" ? follower.commands.events : follower.commands.logs).argv.join(" "), })), }; } const follower = registry.followers.find((item) => item.id === options.followerId); if (follower === undefined) throw new Error(`unknown follower ${options.followerId}`); const spec = options.action === "events" ? follower.commands.events : follower.commands.logs; const result = runCommand(spec.argv, repoRoot, { timeoutMs: (options.timeoutSeconds ?? spec.timeoutSeconds) * 1000 }); return { ok: result.exitCode === 0, action: options.action, follower: follower.id, adapter: follower.adapter, command: spec.argv.join(" "), result: { exitCode: result.exitCode, timedOut: result.timedOut, stdoutBytes: Buffer.byteLength(result.stdout), stderrBytes: Buffer.byteLength(result.stderr), stdoutTail: redactText(tailText(result.stdout, options.tailBytes)), stderrTail: redactText(tailText(result.stderr, options.tailBytes)), }, next: { status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`, runOnceDryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`, }, }; } async function decideAndMaybeTrigger( registry: BranchFollowerRegistry, follower: FollowerSpec, previous: Record, live: AdapterSummary, options: ParsedOptions, ): Promise { const warnings: string[] = []; if (!live.ok) warnings.push(`status command failed: exitCode=${live.exitCode}${live.timedOut ? " timedOut=true" : ""}`); const observedSha = live.observedSha; const targetSha = live.targetSha; const previousLastTriggered = stringOrNull(previous.lastTriggeredSha); const previousInFlight = stringOrNull(previous.inFlightJob); const previousObserved = stringOrNull(recordAt(previous, ["source"])?.observedSha); const superseded = previousInFlight !== null && previousObserved !== null && observedSha !== null && previousObserved !== observedSha; let phase: BranchFollowerPhase; let decision: string; let triggerCommand: Record | undefined; let inFlightJob: string | null = live.inFlightJob; let lastTriggeredSha = live.lastTriggeredSha ?? previousLastTriggered; let lastSucceededSha = live.lastSucceededSha ?? stringOrNull(previous.lastSucceededSha); if (!follower.enabled) { phase = "Skipped"; decision = "follower disabled"; } else if (observedSha === null) { phase = live.ok ? "Observed" : "Blocked"; decision = "status did not expose an observed source sha; adapter trigger-current remains the dedupe authority"; } else if (superseded) { phase = "Superseded"; decision = `previous in-flight sha ${shortSha(previousObserved)} was superseded by ${shortSha(observedSha)}`; } else if (targetSha !== null && targetSha === observedSha) { phase = "Noop"; decision = "target already matches observed source sha"; lastSucceededSha = observedSha; } else if (previousLastTriggered !== null && previousLastTriggered === observedSha && !options.confirm) { phase = "ClosingOut"; decision = "same sha was already triggered; use status/events/logs for closeout"; } else { phase = "PendingTrigger"; decision = targetSha === null ? "target sha is unknown; trigger-current adapter will dedupe by source snapshot" : `observed ${shortSha(observedSha)} differs from target ${shortSha(targetSha)}`; } if (options.confirm && (phase === "PendingTrigger" || phase === "Superseded" || (phase === "Observed" && observedSha !== null))) { const trigger = await executeTrigger(follower, observedSha, options); triggerCommand = trigger.command; phase = trigger.ok ? (options.wait || options.controller ? "ClosingOut" : "Triggering") : "Failed"; decision = trigger.ok ? `trigger submitted for ${shortSha(observedSha)}` : `trigger failed for ${shortSha(observedSha)}`; inFlightJob = trigger.jobId ?? live.inFlightJob; lastTriggeredSha = observedSha; if (trigger.ok && options.wait && targetSha === observedSha) lastSucceededSha = observedSha; if (!trigger.ok) warnings.push(trigger.message); } if (options.dryRun && phase === "PendingTrigger") decision = `${decision}; dry-run did not trigger`; return { id: follower.id, adapter: follower.adapter, enabled: follower.enabled, phase, source: { repository: follower.source.repository, branch: follower.source.branch, branchRef: follower.source.branchRef, snapshotPrefix: follower.source.snapshotPrefix, observedSha, }, target: { node: follower.target.node, lane: follower.target.lane, namespace: follower.target.namespace, sentinel: follower.target.sentinel, targetSha, }, lastTriggeredSha, lastSucceededSha, pipelineRun: live.pipelineRun, inFlightJob, budgetSource: follower.budgets, controller: { mode: options.controller ? "k8s-controller" : "local-cli", stateConfigMap: registry.controller.stateConfigMapName, leaseName: registry.controller.leaseName, }, decision, dryRun: options.dryRun, updatedAt: new Date().toISOString(), warnings, next: followerNextCommands(follower), command: triggerCommand ?? { status: live.command, exitCode: live.exitCode, timedOut: live.timedOut, }, }; } async function executeTrigger(follower: FollowerSpec, observedSha: string | null, options: ParsedOptions): Promise<{ ok: boolean; message: string; jobId: string | null; command: Record }> { const spec = follower.commands.trigger; const timeoutSeconds = options.timeoutSeconds ?? spec.timeoutSeconds; if (!options.wait && !options.controller) { const job = startJob(`cicd_branch_follower_${safeJobSegment(follower.id)}`, spec.argv, `Trigger ${follower.id} for observed sha ${observedSha ?? "unknown"}`); return { ok: true, message: `started async job ${job.id}`, jobId: job.id, command: { mode: "async-job", argv: spec.argv, jobId: job.id, status: `bun scripts/cli.ts job status ${job.id}`, }, }; } const result = runCommand(spec.argv, repoRoot, { timeoutMs: timeoutSeconds * 1000 }); return { ok: result.exitCode === 0, message: result.exitCode === 0 ? "trigger command completed" : tailText(result.stderr || result.stdout, 500), jobId: null, command: commandCompact(result, options), }; } async function readAdapterStatus(follower: FollowerSpec, options: ParsedOptions): Promise { const spec = follower.commands.status; const timeoutSeconds = options.timeoutSeconds ?? spec.timeoutSeconds; const result = runCommand(spec.argv, repoRoot, { timeoutMs: timeoutSeconds * 1000 }); const rawPayload = parseJsonObject(result.stdout) ?? parseJsonObject(result.stderr); const payload = recoverDumpPayload(rawPayload) ?? rawPayload; const body = payload === null ? null : unwrapEnvelope(payload); const observedSha = firstStringPath(body, [ "summary.sourceCommit", "summary.observedSha", "summary.observedCommit", "sourceHead.commit", "sourceHead.sha", "source.commit", "source.sha", "selectedSource.commit", "selectedSource.sha", "selectedCommit", "sourceCommit", "observedSha", "alignment.sourceCommit", ], ["sourceCommit", "observedSha", "observedCommit", "selectedCommit", "selectedSourceCommit"]); const targetSha = firstStringPath(body, [ "summary.targetCommit", "summary.targetSha", "summary.runtimeCommit", "summary.gitopsCommit", "runtime.sourceCommit", "runtime.commit", "target.commit", "target.sha", "gitops.sourceCommit", "deployedCommit", "currentCommit", "targetSha", "summary.runtimeAlignment.managerSourceCommit", "alignment.runtimeAlignment.managerSourceCommit", "runtime.manager.sourceCommit", "manager.sourceCommit", ], ["targetCommit", "targetSha", "runtimeCommit", "gitopsCommit", "deployedCommit", "currentCommit"]); const lastTriggeredSha = firstStringPath(body, [ "summary.lastTriggeredSha", "summary.lastTriggeredCommit", "trigger.sourceCommit", "pipelineRun.sourceCommit", "lastTriggeredSha", ], ["lastTriggeredSha", "lastTriggeredCommit"]); const lastSucceededSha = firstStringPath(body, [ "summary.lastSucceededSha", "summary.lastSucceededCommit", "lastSucceededSha", "runtime.succeededCommit", ], ["lastSucceededSha", "lastSucceededCommit", "succeededCommit"]); const pipelineRun = firstStringPath(body, [ "summary.pipelineRun", "summary.expectedPipelineRun", "alignment.expectedPipelineRun", "pipelineRun.name", "pipelineRun", "latestPipelineRun.name", ], ["pipelineRun", "pipelineRunName", "expectedPipelineRun"]); const inFlightJob = firstStringPath(body, [ "summary.inFlightJob", "job.id", "inFlightJob", "latestJob.id", ], ["inFlightJob", "jobId"]); const aligned = firstBooleanPath(body, [ "summary.aligned", "alignment.aligned", "aligned", "runtime.aligned", ]); const ok = result.exitCode === 0; const phase = inferPhase(ok, aligned, observedSha, targetSha, result.timedOut); return { ok, command: spec.argv.join(" "), exitCode: result.exitCode, timedOut: result.timedOut, observedSha, targetSha, lastTriggeredSha, lastSucceededSha, pipelineRun, inFlightJob, aligned, phase, message: statusMessage(ok, phase, observedSha, targetSha, result), payload: body, stderrTail: redactText(tailText(result.stderr, 1000)), stdoutTail: redactText(tailText(result.stdout, 1000)), }; } function inferPhase(ok: boolean, aligned: boolean | null, observedSha: string | null, targetSha: string | null, timedOut: boolean): BranchFollowerPhase { if (!ok || timedOut) return "Blocked"; if (aligned === true) return "Succeeded"; if (observedSha !== null && targetSha !== null && observedSha === targetSha) return "Noop"; if (observedSha !== null) return "PendingTrigger"; return "Observed"; } function statusMessage(ok: boolean, phase: BranchFollowerPhase, observedSha: string | null, targetSha: string | null, result: CommandResult): string { if (!ok) return `status command failed: exitCode=${result.exitCode}${result.timedOut ? " timedOut=true" : ""}`; if (phase === "Noop" || phase === "Succeeded") return `target matches ${shortSha(observedSha)}`; if (observedSha !== null && targetSha !== null) return `observed ${shortSha(observedSha)} target ${shortSha(targetSha)}`; if (observedSha !== null) return `observed ${shortSha(observedSha)} target unknown`; return "status command completed; observed sha not exposed in compact payload"; } function mergeFollowerStatus( registry: BranchFollowerRegistry, follower: FollowerSpec, stored: Record, live: AdapterSummary | null, liveRequested: boolean, ): Record { const storedSource = asOptionalRecord(stored.source); const storedTarget = asOptionalRecord(stored.target); const phase = live?.phase ?? stringOrNull(stored.phase) ?? "Observed"; const observedSha = live?.observedSha ?? stringOrNull(storedSource?.observedSha); const targetSha = live?.targetSha ?? stringOrNull(storedTarget?.targetSha); const lastTriggeredSha = live?.lastTriggeredSha ?? stringOrNull(stored.lastTriggeredSha); const lastSucceededSha = live?.lastSucceededSha ?? stringOrNull(stored.lastSucceededSha); return { ok: live === null ? true : live.ok, id: follower.id, enabled: follower.enabled, adapter: follower.adapter, phase, source: { repository: follower.source.repository, branch: follower.source.branch, observedSha, snapshotPrefix: follower.source.snapshotPrefix, }, target: { node: follower.target.node, lane: follower.target.lane, namespace: follower.target.namespace, sentinel: follower.target.sentinel, targetSha, }, lastTriggeredSha, lastSucceededSha, pipelineRun: live?.pipelineRun ?? stringOrNull(stored.pipelineRun), inFlightJob: live?.inFlightJob ?? stringOrNull(stored.inFlightJob), budgetSource: follower.budgets, updatedAt: stringOrNull(stored.updatedAt), stateConfigMap: registry.controller.stateConfigMapName, live: liveRequested, message: live?.message ?? stringOrNull(stored.decision) ?? "no controller state yet", warnings: Array.isArray(stored.warnings) ? stored.warnings.slice(0, 6) : [], next: followerNextCommands(follower), }; } function readK8sState(registry: BranchFollowerRegistry, options: ParsedOptions): K8sStateRead { const errors: string[] = []; const stateResult = kubeJson(registry, options, `kubectl -n ${shQuote(registry.controller.namespace)} get configmap ${shQuote(registry.controller.stateConfigMapName)} -o json`, 10_000); const deploymentResult = kubeJson(registry, options, `kubectl -n ${shQuote(registry.controller.namespace)} get deploy ${shQuote(registry.controller.deploymentName)} -o json`, 10_000); const leaseResult = kubeJson(registry, options, `kubectl -n ${shQuote(registry.controller.namespace)} get lease ${shQuote(registry.controller.leaseName)} -o json`, 10_000); const podSelector = labelSelector(registry.controller.labels); const podsResult = kubePodList(registry, options, podSelector); if (!stateResult.ok && !isNotFoundText(stateResult.error)) errors.push(`state configmap: ${stateResult.error}`); if (!deploymentResult.ok && !isNotFoundText(deploymentResult.error)) errors.push(`deployment: ${deploymentResult.error}`); if (!leaseResult.ok && !isNotFoundText(leaseResult.error)) errors.push(`lease: ${leaseResult.error}`); if (!podsResult.ok && !isNotFoundText(podsResult.error)) errors.push(`pods: ${podsResult.error}`); const stateByFollower: Record> = {}; const data = asOptionalRecord(stateResult.value?.data); if (data !== null) { for (const [key, value] of Object.entries(data)) { if (key.startsWith("_")) continue; if (typeof value !== "string") continue; const parsed = parseJsonObject(value); if (parsed !== null) stateByFollower[key] = parsed; } } return { ok: errors.length === 0, stateByFollower, stateConfigMapPresent: stateResult.value !== null, deployment: deploymentResult.value, lease: leaseResult.value, pods: podsResult.value, errors, }; } function kubeJson(registry: BranchFollowerRegistry, options: ParsedOptions, command: string, timeoutMs: number): { ok: boolean; value: Record | null; error: string } { const result = runKubeScript(registry, options, `set -eu\n${command}`, "", timeoutMs); const value = result.exitCode === 0 ? parseJsonObject(result.stdout) : null; return { ok: result.exitCode === 0 && value !== null, value, error: redactText(tailText(result.stderr || result.stdout, 800)), }; } function kubePodList(registry: BranchFollowerRegistry, options: ParsedOptions, selector: string): { ok: boolean; value: Record | null; error: string } { const command = `kubectl -n ${shQuote(registry.controller.namespace)} get pods -l ${shQuote(selector)} -o name`; const result = runKubeScript(registry, options, `set -eu\n${command}`, "", 10_000); const names = result.stdout .split(/\r?\n/u) .map((line) => line.trim()) .filter((line) => line.length > 0) .map((line) => line.replace(/^pod\//u, "")); return { ok: result.exitCode === 0, value: result.exitCode === 0 ? { items: names.map((name) => ({ metadata: { name } })) } : null, error: redactText(tailText(result.stderr || result.stdout, 800)), }; } function runKubeScript(registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number): CommandResult { if (options.controller) { return runCommand(["sh", "-lc", script], repoRoot, { input, timeoutMs }); } return runCommand([transPath(), registry.controller.kubeRoute, "sh"], repoRoot, { input: `${script}\n`, timeoutMs }); } function writeFollowerState(registry: BranchFollowerRegistry, state: FollowerState, options: ParsedOptions): CommandResult { const json = JSON.stringify(state); const dataPatch = JSON.stringify({ data: { [state.id]: json, _updatedAt: new Date().toISOString(), _specRef: SPEC_REF } }); const script = [ "set -eu", `kubectl -n ${shQuote(registry.controller.namespace)} create configmap ${shQuote(registry.controller.stateConfigMapName)} --from-literal=_createdAt="$(date -Iseconds)" --dry-run=client -o yaml | kubectl apply -f - >/dev/null`, `kubectl -n ${shQuote(registry.controller.namespace)} patch configmap ${shQuote(registry.controller.stateConfigMapName)} --type merge -p ${shQuote(dataPatch)} >/dev/null`, ].join("\n"); return runKubeScript(registry, options, script, "", 10_000); } function renderControllerManifests(registry: BranchFollowerRegistry): Record[] { const labels = registry.controller.labels; const selector = labels; return [ { apiVersion: "v1", kind: "Namespace", metadata: { name: registry.controller.namespace, labels }, }, { apiVersion: "v1", kind: "ServiceAccount", metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels }, }, { apiVersion: "rbac.authorization.k8s.io/v1", kind: "Role", metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels }, rules: [ { apiGroups: [""], resources: ["configmaps", "pods", "events"], verbs: ["get", "list", "watch", "create", "update", "patch"] }, { apiGroups: ["apps"], resources: ["deployments"], verbs: ["get", "list", "watch"] }, { apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch", "create", "update", "patch"] }, { apiGroups: ["coordination.k8s.io"], resources: ["leases"], verbs: ["get", "list", "watch", "create", "update", "patch"] }, ], }, { apiVersion: "rbac.authorization.k8s.io/v1", kind: "RoleBinding", metadata: { name: registry.controller.serviceAccountName, namespace: registry.controller.namespace, labels }, subjects: [{ kind: "ServiceAccount", name: registry.controller.serviceAccountName, namespace: registry.controller.namespace }], roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: registry.controller.serviceAccountName }, }, { apiVersion: "v1", kind: "ConfigMap", metadata: { name: registry.controller.configMapName, namespace: registry.controller.namespace, labels }, data: { "cicd-branch-followers.yaml": registry.rawText }, }, { apiVersion: "v1", kind: "ConfigMap", metadata: { name: registry.controller.stateConfigMapName, namespace: registry.controller.namespace, labels }, data: { _createdAt: new Date().toISOString(), _specRef: SPEC_REF, _registrySha256: registry.rawSha256, }, }, { apiVersion: "coordination.k8s.io/v1", kind: "Lease", metadata: { name: registry.controller.leaseName, namespace: registry.controller.namespace, labels }, spec: { holderIdentity: "unidesk-cicd-branch-follower", leaseDurationSeconds: Math.max(30, registry.controller.loop.reconcileTimeoutSeconds + 30) }, }, { apiVersion: "apps/v1", kind: "Deployment", metadata: { name: registry.controller.deploymentName, namespace: registry.controller.namespace, labels }, spec: { replicas: 1, selector: { matchLabels: selector }, template: { metadata: { labels: selector, annotations: { "unidesk.pikapython.com/spec-ref": SPEC_REF, "unidesk.pikapython.com/registry-sha256": registry.rawSha256, "unidesk.pikapython.com/host-worktree-authority": "false", }, }, spec: { serviceAccountName: registry.controller.serviceAccountName, terminationGracePeriodSeconds: 30, volumes: [ { name: "registry", configMap: { name: registry.controller.configMapName } }, { name: "work", emptyDir: {} }, ], containers: [ { name: "controller", image: registry.controller.image, imagePullPolicy: "IfNotPresent", command: ["/bin/sh", "-lc"], args: [controllerLoopScript()], env: [ { name: "UNIDESK_CICD_BRANCH_FOLLOWER_INTERVAL_SECONDS", value: String(registry.controller.loop.intervalSeconds) }, { name: "UNIDESK_CICD_BRANCH_FOLLOWER_TIMEOUT_SECONDS", value: String(registry.controller.loop.reconcileTimeoutSeconds) }, { name: "UNIDESK_CONTROLLER_GIT_MIRROR_READ_URL", value: registry.controller.source.gitMirrorReadUrl }, { name: "UNIDESK_CONTROLLER_SOURCE_BRANCH", value: registry.controller.source.branch }, ], volumeMounts: [ { name: "registry", mountPath: "/etc/unidesk-cicd-branch-follower", readOnly: true }, { name: "work", mountPath: "/work" }, ], }, ], }, }, }, }, ]; } function controllerLoopScript(): string { return [ "set -eu", "interval=\"${UNIDESK_CICD_BRANCH_FOLLOWER_INTERVAL_SECONDS}\"", "timeout=\"${UNIDESK_CICD_BRANCH_FOLLOWER_TIMEOUT_SECONDS}\"", "while true; do", " started_at=$(date -Iseconds)", " echo \"branch-follower loop started ${started_at}\"", " cd /work", " rm -rf /work/unidesk", " git clone --depth=1 --branch \"${UNIDESK_CONTROLLER_SOURCE_BRANCH}\" \"${UNIDESK_CONTROLLER_GIT_MIRROR_READ_URL}\" /work/unidesk", " cp /etc/unidesk-cicd-branch-follower/cicd-branch-followers.yaml /work/unidesk/config/cicd-branch-followers.yaml", " cd /work/unidesk", " bun scripts/cli.ts cicd branch-follower run-once --all --confirm --wait --controller --config config/cicd-branch-followers.yaml --timeout-seconds \"${timeout}\" --json || true", " echo \"branch-follower loop finished $(date -Iseconds)\"", " cd /work", " sleep \"${interval}\"", "done", ].join("\n"); } function selectFollowers(registry: BranchFollowerRegistry, options: ParsedOptions, opts: { includeDisabled: boolean }): FollowerSpec[] { let selected = registry.followers; if (options.followerId !== null) selected = selected.filter((item) => item.id === options.followerId); else if (!options.all && options.action === "run-once") selected = selected.filter((item) => item.enabled); if (!opts.includeDisabled) selected = selected.filter((item) => item.enabled); if (selected.length === 0) throw new Error(options.followerId === null ? "no followers selected" : `unknown or disabled follower ${options.followerId}`); return selected; } function registrySummary(registry: BranchFollowerRegistry): Record { return { path: registry.path, sha256: registry.rawSha256, metadata: registry.metadata, controller: { namespace: registry.controller.namespace, kubeRoute: registry.controller.kubeRoute, deploymentName: registry.controller.deploymentName, stateConfigMapName: registry.controller.stateConfigMapName, leaseName: registry.controller.leaseName, }, followers: registry.followers.map((item) => item.id), }; } function redactCommands(follower: FollowerSpec): Record { return { plan: follower.commands.plan.argv.join(" "), status: follower.commands.status.argv.join(" "), trigger: follower.commands.trigger.argv.join(" "), events: follower.commands.events.argv.join(" "), logs: follower.commands.logs.argv.join(" "), }; } function controllerStatusSummary(registry: BranchFollowerRegistry, k8s: K8sStateRead): Record { const deploymentStatus = asOptionalRecord(k8s.deployment?.status); const available = numberOrNull(deploymentStatus?.availableReplicas) ?? 0; const replicas = numberOrNull(deploymentStatus?.replicas) ?? 0; const leaseSpec = asOptionalRecord(k8s.lease?.spec); const podItems = Array.isArray(k8s.pods?.items) ? k8s.pods.items.length : null; return { namespace: registry.controller.namespace, route: registry.controller.kubeRoute, deploymentName: registry.controller.deploymentName, deploymentPresent: k8s.deployment !== null, availableReplicas: available, replicas, pods: podItems, stateConfigMapName: registry.controller.stateConfigMapName, stateConfigMapPresent: k8s.stateConfigMapPresent, leaseName: registry.controller.leaseName, leaseHolder: stringOrNull(leaseSpec?.holderIdentity), noHostWorktreeAuthority: true, }; } function followerNextCommands(follower: FollowerSpec): Record { return { status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`, liveStatus: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id} --live`, dryRun: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --dry-run`, trigger: `bun scripts/cli.ts cicd branch-follower run-once --follower ${follower.id} --confirm --wait`, events: `bun scripts/cli.ts cicd branch-follower events --follower ${follower.id}`, logs: `bun scripts/cli.ts cicd branch-follower logs --follower ${follower.id}`, adapterStatus: follower.commands.status.argv.join(" "), }; } function safeResolveString(ref: string): string | null { try { return resolveConfigRefString(ref, ref); } catch { return null; } } function parseJsonObject(text: string): Record | null { const trimmed = text.trim(); if (trimmed.length === 0) return null; try { const parsed = JSON.parse(trimmed) as unknown; return asOptionalRecord(parsed); } catch { const start = trimmed.indexOf("{"); const end = trimmed.lastIndexOf("}"); if (start < 0 || end <= start) return null; try { const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown; return asOptionalRecord(parsed); } catch { return null; } } } function unwrapEnvelope(payload: Record): Record { const data = asOptionalRecord(payload.data); return data ?? payload; } function recoverDumpPayload(payload: Record | null): Record | null { if (payload === null) return null; const data = asOptionalRecord(payload.data); const dump = asOptionalRecord(data?.dump) ?? asOptionalRecord(payload.dump); const path = stringOrNull(dump?.path); if (path === null || !existsSync(path)) return payload; try { return parseJsonObject(readFileSync(path, "utf8")) ?? payload; } catch { return payload; } } function firstStringPath(root: Record | null, paths: string[], fallbackKeys: string[] = []): string | null { if (root === null) return null; for (const path of paths) { const value = valueAt(root, path); if (typeof value === "string" && value.length > 0) return value; } return fallbackKeys.length === 0 ? null : firstStringByKey(root, fallbackKeys); } function firstBooleanPath(root: Record | null, paths: string[]): boolean | null { if (root === null) return null; for (const path of paths) { const value = valueAt(root, path); if (typeof value === "boolean") return value; } return null; } function valueAt(root: unknown, path: string): unknown { let current = root; for (const part of path.split(".")) { if (typeof current !== "object" || current === null || Array.isArray(current)) return undefined; current = (current as Record)[part]; } return current; } function firstStringByKey(root: unknown, keys: string[]): string | null { if (typeof root !== "object" || root === null) return null; if (Array.isArray(root)) { for (const item of root) { const found = firstStringByKey(item, keys); if (found !== null) return found; } return null; } const record = root as Record; for (const key of keys) { const value = record[key]; if (typeof value === "string" && value.length > 0) return value; } for (const value of Object.values(record)) { const found = firstStringByKey(value, keys); if (found !== null) return found; } return null; } function recordAt(root: Record, path: string[]): Record | null { let current: unknown = root; for (const item of path) { if (typeof current !== "object" || current === null || Array.isArray(current)) return null; current = (current as Record)[item]; } return asOptionalRecord(current); } function asOptionalRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; } function stringOrNull(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } function numberOrNull(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } function commandCompact(result: CommandResult, options: ParsedOptions): Record { return { argv: result.command, exitCode: result.exitCode, timedOut: result.timedOut, stdoutBytes: Buffer.byteLength(result.stdout), stderrBytes: Buffer.byteLength(result.stderr), stdoutTail: options.full || result.exitCode !== 0 ? redactText(tailText(result.stdout, options.tailBytes)) : "", stderrTail: options.full || result.exitCode !== 0 ? redactText(tailText(result.stderr, Math.min(options.tailBytes, 4000))) : "", }; } function objectRef(item: Record): Record { const metadata = asRecord(item.metadata, "metadata"); return { kind: stringField(item, "kind", "manifest"), namespace: typeof metadata.namespace === "string" ? metadata.namespace : "-", name: stringField(metadata, "name", "manifest.metadata"), }; } function labelSelector(labels: Record): string { return Object.entries(labels).map(([key, value]) => `${key}=${value}`).join(","); } function isNotFoundText(value: string): boolean { return /notfound|not found|notfound|NotFound/u.test(value); } function shortSha(value: string | null): string { if (value === null) return "-"; return value.length > 12 ? value.slice(0, 12) : value; } function safeJobSegment(value: string): string { return value.replace(/[^A-Za-z0-9_.-]/gu, "_").slice(0, 60); } function tailText(text: string, maxChars: number): string { if (text.length <= maxChars) return text; return text.slice(text.length - maxChars); } function commandLabel(options: ParsedOptions): string { return `cicd branch-follower ${options.action}`; } function renderResult(command: string, payload: Record, options: ParsedOptions): RenderedCliResult { const ok = payload.ok !== false; if (options.output === "json") return renderMachine(command, payload, "json", ok); if (options.output === "yaml") return renderMachine(command, payload, "yaml", ok); return rendered(ok, command, renderHuman(command, payload, options)); } function renderMachine(command: string, value: unknown, mode: "json" | "yaml", ok = true): RenderedCliResult { return rendered(ok, command, mode === "json" ? `${JSON.stringify(value, null, 2)}\n` : `${Bun.YAML.stringify(value)}\n`, mode === "json" ? "application/json" : "application/yaml"); } function rendered(ok: boolean, command: string, renderedText: string, contentType: RenderedCliResult["contentType"] = "text/plain"): RenderedCliResult { return { ok, command, renderedText, contentType }; } function renderHuman(command: string, payload: Record, options: ParsedOptions): string { if (command.endsWith(" plan")) return renderPlanHuman(payload); if (command.endsWith(" apply")) return renderApplyHuman(payload); if (command.endsWith(" status")) return renderStatusHuman(payload, options); if (command.endsWith(" run-once")) return renderRunOnceHuman(payload); if (command.endsWith(" events") || command.endsWith(" logs")) return renderDrillDownHuman(payload); return `${JSON.stringify(payload, null, 2)}\n`; } function renderPlanHuman(payload: Record): string { const followers = arrayRecords(payload.followers); const rows = followers.map((item) => { const source = asOptionalRecord(item.source); const target = asOptionalRecord(item.target); const budgets = asOptionalRecord(item.budgets); return [ item.id, item.enabled, item.adapter, `${source?.repository ?? "-"}@${source?.branch ?? "-"}`, `${target?.node ?? "-"}/${target?.lane ?? "-"}`, budgets?.endToEndSeconds ?? "-", arrayRecords(item.configRefGraph).length, arrayText(item.closeoutChecks), ]; }); const next = asOptionalRecord(payload.next); return [ `CI/CD BRANCH-FOLLOWER PLAN (${payload.ok === false ? "blocked" : "ok"})`, "", table(["FOLLOWER", "ENABLED", "ADAPTER", "SOURCE", "TARGET", "BUDGET", "REFS", "CHECKS"], rows), "", "SOURCE AUTHORITY", `hostWorktreeAuthority=${payload.hostWorktreeAuthority === true ? "true" : "false"} mode=${asOptionalRecord(payload.sourceAuthority)?.mode ?? "-"} resolver=${asOptionalRecord(payload.sourceAuthority)?.resolver ?? "-"}`, "", "NEXT", `apply: ${next?.apply ?? "-"}`, `status: ${next?.status ?? "-"}`, `dry-run: ${next?.dryRun ?? "-"}`, "", ].join("\n"); } function renderApplyHuman(payload: Record): string { const controller = asOptionalRecord(payload.controller); const command = asOptionalRecord(payload.command); const next = asOptionalRecord(payload.next); return [ `CI/CD BRANCH-FOLLOWER APPLY (${payload.ok === false ? "failed" : payload.dryRun === true ? "dry-run" : "ok"})`, "", table( ["NAMESPACE", "ROUTE", "DEPLOYMENT", "STATE_CM", "LEASE", "HOST_WORKTREE"], [[controller?.namespace ?? "-", controller?.route ?? "-", controller?.deploymentName ?? "-", controller?.stateConfigMapName ?? "-", controller?.leaseName ?? "-", controller?.hostWorktreeMounted === true ? "mounted" : "not-mounted"]], ), "", table(["OBJECTS", "MANIFEST_SHA", "EXIT", "TIMED_OUT"], [[arrayRecords(payload.objects).length, shortSha(stringOrNull(payload.manifestSha256)), command?.exitCode ?? "-", command?.timedOut ?? "-"]]), command?.stderrTail ? `\nSTDERR\n${command.stderrTail}` : "", "", "NEXT", `status: ${next?.status ?? "-"}`, `dry-run: ${next?.dryRun ?? "-"}`, "", ].filter((line) => line !== "").join("\n"); } function renderStatusHuman(payload: Record, _options: ParsedOptions): string { const controller = asOptionalRecord(payload.controller); const followers = arrayRecords(payload.followers); const rows = followers.map((item) => { const source = asOptionalRecord(item.source); const target = asOptionalRecord(item.target); const budgets = asOptionalRecord(item.budgetSource); return [ item.id, item.phase, item.adapter, `${source?.branch ?? "-"}:${shortSha(stringOrNull(source?.observedSha))}`, shortSha(stringOrNull(target?.targetSha)), shortSha(stringOrNull(item.lastTriggeredSha)), shortSha(stringOrNull(item.lastSucceededSha)), item.pipelineRun ?? item.inFlightJob ?? "-", budgets?.endToEndSeconds ?? "-", item.message ?? "-", ]; }); const next = asOptionalRecord(payload.next); const errors = Array.isArray(payload.errors) ? payload.errors : []; return [ `CI/CD BRANCH-FOLLOWER STATUS (${payload.ok === false ? "degraded" : "ok"})`, "", table( ["CTRL_NS", "ROUTE", "DEPLOY", "READY", "PODS", "STATE_CM", "LEASE"], [[controller?.namespace ?? "-", controller?.route ?? "-", controller?.deploymentName ?? "-", `${controller?.availableReplicas ?? 0}/${controller?.replicas ?? 0}`, controller?.pods ?? "-", controller?.stateConfigMapPresent === true ? "present" : "missing", controller?.leaseHolder ?? "-"]], ), "", table(["FOLLOWER", "PHASE", "ADAPTER", "OBSERVED", "TARGET", "TRIGGERED", "SUCCEEDED", "IN_FLIGHT", "BUDGET", "MESSAGE"], rows), errors.length === 0 ? "" : `\nERRORS\n${errors.map((item) => `- ${item}`).join("\n")}`, "", "NEXT", `live-status: ${next?.liveStatus ?? "-"}`, `dry-run: ${next?.dryRun ?? "-"}`, "", ].filter((line) => line !== "").join("\n"); } function renderRunOnceHuman(payload: Record): string { const followers = arrayRecords(payload.followers); const rows = followers.map((item) => { const source = asOptionalRecord(item.source); const target = asOptionalRecord(item.target); return [ item.id, item.phase, `${source?.branch ?? "-"}:${shortSha(stringOrNull(source?.observedSha))}`, shortSha(stringOrNull(target?.targetSha)), shortSha(stringOrNull(item.lastTriggeredSha)), item.inFlightJob ?? "-", item.decision ?? "-", ]; }); const next = asOptionalRecord(payload.next); return [ `CI/CD BRANCH-FOLLOWER RUN-ONCE (${payload.ok === false ? "blocked" : payload.dryRun === true ? "dry-run" : "ok"})`, "", table(["FOLLOWER", "PHASE", "OBSERVED", "TARGET", "TRIGGERED", "IN_FLIGHT", "DECISION"], rows), "", "NEXT", `status: ${next?.status ?? "-"}`, `live-status: ${next?.liveStatus ?? "-"}`, "", ].join("\n"); } function renderDrillDownHuman(payload: Record): string { if (payload.follower === undefined) { const followers = arrayRecords(payload.followers); return [ `CI/CD BRANCH-FOLLOWER ${String(payload.action ?? "drill-down").toUpperCase()}`, "", table(["FOLLOWER", "ADAPTER", "COMMAND"], followers.map((item) => [item.id, item.adapter, item.command])), "", ].join("\n"); } const result = asOptionalRecord(payload.result); return [ `CI/CD BRANCH-FOLLOWER ${String(payload.action ?? "drill-down").toUpperCase()} (${payload.ok === false ? "failed" : "ok"})`, "", table(["FOLLOWER", "ADAPTER", "EXIT", "TIMED_OUT", "STDOUT", "STDERR"], [[payload.follower, payload.adapter ?? "-", result?.exitCode ?? "-", result?.timedOut ?? "-", result?.stdoutBytes ?? "-", result?.stderrBytes ?? "-"]]), result?.stdoutTail ? `\nSTDOUT_TAIL\n${result.stdoutTail}` : "", result?.stderrTail ? `\nSTDERR_TAIL\n${result.stderrTail}` : "", "", ].filter((line) => line !== "").join("\n"); } function arrayRecords(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; } function arrayText(value: unknown): string { return Array.isArray(value) ? value.map(String).join(",") : "-"; } function table(headers: readonly string[], rows: readonly (readonly unknown[])[]): string { const normalized = rows.map((row) => headers.map((_, index) => cell(row[index]))); const widths = headers.map((header, index) => Math.max(header.length, ...normalized.map((row) => row[index]?.length ?? 0))); const format = (row: readonly string[]) => row.map((value, index) => value.padEnd(widths[index] ?? 0)).join(" ").trimEnd(); return [format(headers), format(headers.map((header) => "-".repeat(header.length))), ...normalized.map(format)].join("\n"); } function cell(value: unknown): string { if (value === null || value === undefined || value === "") return "-"; const text = String(value).replace(/\s+/gu, " "); return text.length > 96 ? `${text.slice(0, 93)}...` : text; }