From 4df3d5d8bda156dcbe61ecaf2e41710299f7f0c5 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 18:48:40 +0000 Subject: [PATCH] fix: keep branch follower output bounded --- .../references/branch-follower.md | 4 + scripts/native/cicd/read-state-summary.mjs | 73 ++++- scripts/src/cicd-render.ts | 266 ++++++++++++++++++ scripts/src/cicd.ts | 243 +--------------- 4 files changed, 334 insertions(+), 252 deletions(-) create mode 100644 scripts/src/cicd-render.ts diff --git a/.agents/skills/unidesk-cicd/references/branch-follower.md b/.agents/skills/unidesk-cicd/references/branch-follower.md index 3816a66b..45356752 100644 --- a/.agents/skills/unidesk-cicd/references/branch-follower.md +++ b/.agents/skills/unidesk-cicd/references/branch-follower.md @@ -38,6 +38,10 @@ When a repeated runtime pitfall or visibility defect is found during branch-foll Bounded JSON means the operator-facing `--json` payload must remain below the YAML-configured stdout limit in normal successful debug cases. Do not duplicate the same evidence as full top-level objects, compact `targetResult`, full `stateAfter` and target stdout tail at the same time; choose one compact representation by default and put full payload/log drill-down behind explicit commands. +Target-side state summaries used by `status`, `events`, `logs` and `debug-step state-read` must also remain below the transport stdout limit. When exposing stored native payloads, return gate summaries only: git-mirror, Tekton, Argo, runtime and short errors. Do not include full source objects, TaskRun item arrays, plan-artifact arrays, report payloads or full command payloads in the default state summary; a truncated state summary is a visibility defect because the operator can no longer parse the follower state. + +`scripts/src/cicd.ts` should stay a thin branch-follower route/orchestration entry, not a catch-all implementation file. Rendering, debug steps, controller manifests, native K8s helpers, adapter-specific trigger/status logic and large data compactors must live in responsibility-specific modules before `cicd.ts` approaches the 3000-line hard split point. + `status-read`, `events`, `logs` and debug summaries must expose compact closeout gate details when a follower is not aligned: git-mirror readiness, Tekton PipelineRun condition, Argo sync/health, runtime target sha/readiness and short errors. Repeating only phase/observed/target/message is a visibility defect and must be fixed before further rollout tuning. Stage timing rows must not label optional gates as `not-ready` when they are not part of that follower's closeout contract. For sentinel-like followers without a GitOps branch flush gate, git-mirror source snapshot readiness should render as source-ready/ready, while missing GitOps `githubInSync` remains `-`/not-applicable instead of a failure-looking state. diff --git a/scripts/native/cicd/read-state-summary.mjs b/scripts/native/cicd/read-state-summary.mjs index d69243aa..79874e30 100644 --- a/scripts/native/cicd/read-state-summary.mjs +++ b/scripts/native/cicd/read-state-summary.mjs @@ -141,20 +141,72 @@ function compactNativePayload(payload) { const value = recordOrNull(payload); if (value === null) return null; return { - source: recordOrNull(value.source), - sourceSync: recordOrNull(value.sourceSync), - gitMirror: recordOrNull(value.gitMirror), - tekton: recordOrNull(value.tekton), + gitMirror: compactGitMirror(value.gitMirror), + tekton: compactTekton(value.tekton), taskRuns: compactTaskRuns(value.taskRuns), planArtifacts: compactPlanArtifacts(value.planArtifacts), - argo: recordOrNull(value.argo), - runtime: recordOrNull(value.runtime), + argo: compactArgo(value.argo), + runtime: compactRuntime(value.runtime), errors: arrayStrings(value.errors).slice(0, 5), statusAuthority: stringOrNull(value.statusAuthority), parsedDownstreamCliOutput: false, }; } +function compactGitMirror(gitMirror) { + const value = recordOrNull(gitMirror); + if (value === null) return null; + return { + ok: value.ok === true, + sourceSnapshotReady: value.sourceSnapshotReady === true, + pendingFlush: value.pendingFlush === true, + githubInSync: value.githubInSync === true, + sourceBranch: stringOrNull(value.sourceBranch), + gitopsBranch: stringOrNull(value.gitopsBranch), + localSource: stringOrNull(value.localSource), + githubSource: stringOrNull(value.githubSource), + localGitops: stringOrNull(value.localGitops), + githubGitops: stringOrNull(value.githubGitops), + }; +} + +function compactTekton(tekton) { + const value = recordOrNull(tekton); + if (value === null) return null; + return { + name: stringOrNull(value.name), + succeeded: value.succeeded === true ? true : value.succeeded === false ? false : null, + reason: stringOrNull(value.reason), + startTime: stringOrNull(value.startTime), + completionTime: stringOrNull(value.completionTime), + durationSeconds: numberOrNull(value.durationSeconds), + }; +} + +function compactArgo(argo) { + const value = recordOrNull(argo); + if (value === null) return null; + return { + name: stringOrNull(value.name), + syncStatus: stringOrNull(value.syncStatus), + healthStatus: stringOrNull(value.healthStatus), + revision: stringOrNull(value.revision), + ready: value.ready === true, + }; +} + +function compactRuntime(runtime) { + const value = recordOrNull(runtime); + if (value === null) return null; + return { + namespace: stringOrNull(value.namespace), + ready: value.ready === true, + targetSha: stringOrNull(value.targetSha), + expectedSha: stringOrNull(value.expectedSha), + aligned: value.aligned === true ? true : value.aligned === false ? false : null, + }; +} + function compactTaskRuns(taskRuns) { const value = recordOrNull(taskRuns); if (value === null) return null; @@ -165,7 +217,6 @@ function compactTaskRuns(taskRuns) { failedCount: numberOrNull(value.failedCount), activeCount: numberOrNull(value.activeCount), performance: recordOrNull(value.performance), - items: arrayRecords(value.items).slice(0, 16), }; } @@ -178,10 +229,10 @@ function compactPlanArtifacts(planArtifacts) { eventFound: value.eventFound === true, degradedReason: stringOrNull(value.degradedReason), sourceCommitId: stringOrNull(value.sourceCommitId), - affectedServices: arrayStrings(value.affectedServices).slice(0, 40), - rolloutServices: arrayStrings(value.rolloutServices).slice(0, 40), - buildServices: arrayStrings(value.buildServices).slice(0, 40), - reusedServices: arrayStrings(value.reusedServices).slice(0, 40), + affectedServicesCount: arrayStrings(value.affectedServices).length, + rolloutServicesCount: arrayStrings(value.rolloutServices).length, + buildServicesCount: arrayStrings(value.buildServices).length, + reusedServicesCount: arrayStrings(value.reusedServices).length, buildSkippedCount: numberOrNull(value.buildSkippedCount), summary: stringOrNull(value.summary), disclosure: stringOrNull(value.disclosure), diff --git a/scripts/src/cicd-render.ts b/scripts/src/cicd-render.ts new file mode 100644 index 00000000..88626908 --- /dev/null +++ b/scripts/src/cicd-render.ts @@ -0,0 +1,266 @@ +// SPEC: PJ2026-01060703 CI/CD branch follower rendering. +// Responsibility: machine and human output rendering for cicd branch-follower. +import type { RenderedCliResult } from "./output"; +import type { ParsedOptions } from "./cicd-types"; +import { renderDebugStepHuman } from "./cicd-debug"; +import { renderDrillDownHuman } from "./cicd-drilldown-render"; + +export 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)); +} + +export 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(" debug-step")) return renderDebugStepHuman(payload); + if (command.endsWith(" cleanup-state")) return renderCleanupStateHuman(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 : []; + const timingRows = followers.flatMap(timingRowsForFollower).slice(0, 48); + 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), + timingRows.length === 0 ? "" : `\nSTAGE TIMINGS\n${table(["FOLLOWER", "STAGE", "STATUS", "SECONDS", "BUDGET", "OBJECT"], timingRows)}`, + 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 stateWrites = arrayRecords(payload.stateWrites); + 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); + const timingRows = followers.flatMap(timingRowsForFollower).slice(0, 48); + const writeRows = stateWrites.map((item) => [ + item.follower, + item.ok === true ? "ok" : "failed", + item.beforeResourceVersion ?? "-", + item.afterResourceVersion ?? "-", + item.preservedTiming === true ? "yes" : "no", + item.exitCode ?? "-", + item.message ?? "-", + ]); + 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), + timingRows.length === 0 ? "" : `\nSTAGE TIMINGS\n${table(["FOLLOWER", "STAGE", "STATUS", "SECONDS", "BUDGET", "OBJECT"], timingRows)}`, + writeRows.length === 0 ? "" : `\nSTATE WRITES\n${table(["FOLLOWER", "STATUS", "BEFORE_RV", "AFTER_RV", "PRESERVED", "EXIT", "MESSAGE"], writeRows)}`, + "", + "NEXT", + `status: ${next?.status ?? "-"}`, + `live-status: ${next?.liveStatus ?? "-"}`, + "", + ].join("\n"); +} + +function renderCleanupStateHuman(payload: Record): string { + const controller = asOptionalRecord(payload.controller); + const command = asOptionalRecord(payload.command); + const followers = arrayRecords(payload.followers); + const next = asOptionalRecord(payload.next); + const rows = followers.map((item) => [ + item.id, + item.statePresent === true ? "present" : "missing", + item.cleanup ?? "-", + ]); + return [ + `CI/CD BRANCH-FOLLOWER CLEANUP-STATE (${payload.ok === false ? "failed" : payload.dryRun === true ? "dry-run" : "ok"})`, + "", + table( + ["NAMESPACE", "ROUTE", "STATE_CM", "STATE_CM_PRESENT"], + [[controller?.namespace ?? "-", controller?.route ?? "-", controller?.stateConfigMapName ?? "-", payload.stateConfigMapPresent === true ? "present" : "missing"]], + ), + "", + table(["FOLLOWER", "STATE", "CLEANUP"], rows), + command === null ? "" : `\nPATCH\nexit=${command.exitCode ?? "-"} timedOut=${command.timedOut ?? "-"}`, + "", + "NEXT", + `status: ${next?.status ?? "-"}`, + `run-once: ${next?.runOnce ?? "-"}`, + "", + ].filter((line) => line !== "").join("\n"); +} + +function timingRowsForFollower(item: Record): unknown[][] { + const timings = asOptionalRecord(item.timings); + if (timings === null) return []; + const budget = numberOrNull(timings.budgetSeconds); + const rows: unknown[][] = [[ + item.id, + "total", + stringOrNull(timings.totalStatus) ?? "unknown", + formatSeconds(numberOrNull(timings.totalSeconds)), + formatSeconds(budget), + [stringOrNull(timings.totalSource), shortSha(stringOrNull(timings.sourceCommit))].filter((value) => value !== null && value !== "-").join(":") || "-", + ]]; + for (const stage of arrayRecords(timings.stages)) { + rows.push([ + item.id, + stage.stage, + stage.status, + formatSeconds(numberOrNull(stage.seconds)), + formatSeconds(numberOrNull(stage.budgetSeconds)), + stringOrNull(stage.object) ?? "-", + ]); + } + return rows; +} + +function formatSeconds(value: number | null): string { + return value === null ? "-" : `${value}s`; +} + +function asOptionalRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + +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 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 shortSha(value: string | null): string { + if (value === null) return "-"; + return value.length > 12 ? value.slice(0, 12) : value; +} + +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; +} diff --git a/scripts/src/cicd.ts b/scripts/src/cicd.ts index 225aa2ba..f25484d8 100644 --- a/scripts/src/cicd.ts +++ b/scripts/src/cicd.ts @@ -7,6 +7,7 @@ import { repoRoot, rootPath, type UniDeskConfig } from "./config"; import { runCommand, type CommandResult } from "./command"; import { startJob } from "./jobs"; import type { RenderedCliResult } from "./output"; +import { renderMachine, renderResult } from "./cicd-render"; import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes"; import { agentRunImageArtifact, renderAgentRunGitopsFiles } from "./agentrun-manifests"; import { agentRunPipelineRunName, resolveAgentRunLaneTarget } from "./agentrun-lanes"; @@ -21,8 +22,7 @@ import { sentinelPipelineRunName } from "./hwlab-node-web-sentinel-cicd-shared"; import { transPath } from "./hwlab-node/runtime-common"; import { configRefGraph, resolveConfigRefString } from "./ops/config-refs"; import { renderControllerManifests, renderControllerReconcileJob, waitForJobShell } from "./cicd-controller-render"; -import { buildDebugStep, renderDebugStepHuman } from "./cicd-debug"; -import { renderDrillDownHuman } from "./cicd-drilldown-render"; +import { buildDebugStep } from "./cicd-debug"; import { runNativeHwlabControlPlaneRefresh } from "./cicd-hwlab-refresh"; import { nativeCicdScriptLoadShell, readNativeObjectBundle } from "./cicd-native-bundle"; import { runNativeK8sJob, runNativeTektonPipelineRun } from "./cicd-native"; @@ -2730,245 +2730,6 @@ 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(" debug-step")) return renderDebugStepHuman(payload); - if (command.endsWith(" cleanup-state")) return renderCleanupStateHuman(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 : []; - const timingRows = followers.flatMap(timingRowsForFollower).slice(0, 48); - 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), - timingRows.length === 0 ? "" : `\nSTAGE TIMINGS\n${table(["FOLLOWER", "STAGE", "STATUS", "SECONDS", "BUDGET", "OBJECT"], timingRows)}`, - 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 stateWrites = arrayRecords(payload.stateWrites); - 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); - const timingRows = followers.flatMap(timingRowsForFollower).slice(0, 48); - const writeRows = stateWrites.map((item) => [ - item.follower, - item.ok === true ? "ok" : "failed", - item.beforeResourceVersion ?? "-", - item.afterResourceVersion ?? "-", - item.preservedTiming === true ? "yes" : "no", - item.exitCode ?? "-", - item.message ?? "-", - ]); - 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), - timingRows.length === 0 ? "" : `\nSTAGE TIMINGS\n${table(["FOLLOWER", "STAGE", "STATUS", "SECONDS", "BUDGET", "OBJECT"], timingRows)}`, - writeRows.length === 0 ? "" : `\nSTATE WRITES\n${table(["FOLLOWER", "STATUS", "BEFORE_RV", "AFTER_RV", "PRESERVED", "EXIT", "MESSAGE"], writeRows)}`, - "", - "NEXT", - `status: ${next?.status ?? "-"}`, - `live-status: ${next?.liveStatus ?? "-"}`, - "", - ].join("\n"); -} - -function timingRowsForFollower(item: Record): unknown[][] { - const timings = asOptionalRecord(item.timings); - if (timings === null) return []; - const budget = numberOrNull(timings.budgetSeconds); - const rows: unknown[][] = [[ - item.id, - "total", - stringOrNull(timings.totalStatus) ?? "unknown", - formatSeconds(numberOrNull(timings.totalSeconds)), - formatSeconds(budget), - [stringOrNull(timings.totalSource), shortSha(stringOrNull(timings.sourceCommit))].filter((value) => value !== null && value !== "-").join(":") || "-", - ]]; - for (const stage of arrayRecords(timings.stages)) { - rows.push([ - item.id, - stage.stage, - stage.status, - formatSeconds(numberOrNull(stage.seconds)), - formatSeconds(numberOrNull(stage.budgetSeconds)), - stringOrNull(stage.object) ?? "-", - ]); - } - return rows; -} - -function formatSeconds(value: number | null): string { - return value === null ? "-" : `${value}s`; -} - -function renderCleanupStateHuman(payload: Record): string { - const controller = asOptionalRecord(payload.controller); - const command = asOptionalRecord(payload.command); - const followers = arrayRecords(payload.followers); - const next = asOptionalRecord(payload.next); - const rows = followers.map((item) => [ - item.id, - item.statePresent === true ? "present" : "missing", - item.cleanup ?? "-", - ]); - return [ - `CI/CD BRANCH-FOLLOWER CLEANUP-STATE (${payload.ok === false ? "failed" : payload.dryRun === true ? "dry-run" : "ok"})`, - "", - table( - ["NAMESPACE", "ROUTE", "STATE_CM", "STATE_CM_PRESENT"], - [[controller?.namespace ?? "-", controller?.route ?? "-", controller?.stateConfigMapName ?? "-", payload.stateConfigMapPresent === true ? "present" : "missing"]], - ), - "", - table(["FOLLOWER", "STATE", "CLEANUP"], rows), - command === null ? "" : `\nPATCH\nexit=${command.exitCode ?? "-"} timedOut=${command.timedOut ?? "-"}`, - "", - "NEXT", - `status: ${next?.status ?? "-"}`, - `run-once: ${next?.runOnce ?? "-"}`, - "", - ].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; -}