diff --git a/.agents/skills/unidesk-cicd/references/branch-follower.md b/.agents/skills/unidesk-cicd/references/branch-follower.md index fbc69c3a..4b572d71 100644 --- a/.agents/skills/unidesk-cicd/references/branch-follower.md +++ b/.agents/skills/unidesk-cicd/references/branch-follower.md @@ -60,7 +60,11 @@ The normal convergence budget is 120 seconds per source change. A follower may r ## Status Contract -Default `status` output must show follower id, phase, adapter, source branch + observed sha, target sha, last triggered sha, last succeeded sha, in-flight job/PipelineRun, budget source and next drill-down commands. +Default `status` output must show follower id, phase, adapter, source branch + observed sha, target sha, last triggered sha, last succeeded sha, in-flight job/PipelineRun, budget source, timing summary and next drill-down commands. + +Stage timing must be queryable through normal CLI output, not only raw JSON. `status` and `run-once` print a bounded `STAGE TIMINGS` table with `total`, `status-read`, git-mirror, Kubernetes Job, PipelineRun, TaskRun, Argo, runtime and closeout rows when available. `followers[].timings` remains available in `--raw`/JSON for machine consumers. + +`timings.totalSeconds` is the authoritative end-to-end wall-clock measurement for a triggered run: measure from the adapter/controller starting that source-change operation until success, failure, or closeout timeout. Do not compute total by summing stage rows, because stage rows can overlap, omit external waiting, or be reported by different native objects. State machine phases are `Observed`, `Noop`, `PendingTrigger`, `Triggering`, `ClosingOut`, `Succeeded`, `Failed`, `Superseded`, `Blocked`, and `Skipped`. diff --git a/scripts/native/cicd/submit-pipelinerun.mjs b/scripts/native/cicd/submit-pipelinerun.mjs index 00a9d552..fa39929e 100644 --- a/scripts/native/cicd/submit-pipelinerun.mjs +++ b/scripts/native/cicd/submit-pipelinerun.mjs @@ -10,6 +10,7 @@ const port = Number(process.env.KUBERNETES_SERVICE_PORT || "443"); const token = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8").trim(); const ca = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"); const manifest = JSON.parse(Buffer.from(readFileSync(0, "utf8").replace(/\s+/g, ""), "base64").toString("utf8")); +const startedAt = Date.now(); function request(method, path, body, contentType = "application/json") { return new Promise((resolve, reject) => { @@ -116,6 +117,7 @@ const output = { terminal, stillRunning: !terminal, timedOutWait: shouldWait && !terminal, + elapsedMs: Date.now() - startedAt, pipelineRun: compact(latest.object), statusAuthority: "kubernetes-api-serviceaccount", parsedDownstreamCliOutput: false, diff --git a/scripts/src/cicd-types.ts b/scripts/src/cicd-types.ts index 3209bca7..97f86eba 100644 --- a/scripts/src/cicd-types.ts +++ b/scripts/src/cicd-types.ts @@ -202,6 +202,7 @@ export interface NativeObjectBundle { errors: string[]; exitCode: number | null; timedOut: boolean; + elapsedMs: number; stdoutTail: string; stderrTail: string; } @@ -244,6 +245,15 @@ export interface NativeK8sJobResult { parsedDownstreamCliOutput: false; } +export interface StageTiming { + stage: string; + status: string; + seconds: number | null; + budgetSeconds: number | null; + source: string; + object: string | null; +} + export interface FollowerState { id: string; adapter: string; @@ -276,6 +286,14 @@ export interface FollowerState { decision: string; dryRun: boolean; updatedAt: string; + timings: { + budgetSeconds: number; + totalSeconds: number | null; + totalStatus: string; + totalSource: string; + overBudget: boolean | null; + stages: StageTiming[]; + }; warnings: string[]; next: Record; command?: Record; diff --git a/scripts/src/cicd.ts b/scripts/src/cicd.ts index fc078376..e4f5764a 100644 --- a/scripts/src/cicd.ts +++ b/scripts/src/cicd.ts @@ -22,7 +22,7 @@ import { transPath } from "./hwlab-node/runtime-common"; import { configRefGraph, resolveConfigRefString } from "./ops/config-refs"; import { renderControllerManifests, renderControllerReconcileJob, waitForJobShell } from "./cicd-controller-render"; import { runNativeK8sJob, runNativeTektonPipelineRun } from "./cicd-native"; -import type { AdapterSummary, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeObjectBundle, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, TriggerResult } from "./cicd-types"; +import type { AdapterSummary, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeObjectBundle, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, StageTiming, TriggerResult } from "./cicd-types"; import { arrayField, asRecord, @@ -828,6 +828,7 @@ async function decideAndMaybeTrigger( decision, dryRun: options.dryRun, updatedAt: new Date().toISOString(), + timings: buildFollowerTimings(follower, live, triggerCommand), warnings, next: followerNextCommands(follower), command: triggerCommand ?? { @@ -890,7 +891,7 @@ async function executeNativeHwlabNodeTrigger(registry: BranchFollowerRegistry, f const startedAt = Date.now(); const sync = runNativeGitMirrorStage(follower, observedSha, "sync", Math.min(remainingSeconds(startedAt, timeoutSeconds), Math.max(5, follower.budgets.sourceSyncSeconds))); if (sync !== null && !sync.result.ok) { - return nativeK8sStageFailure(follower, observedSha, "git-mirror-sync", sync.jobName, sync.result, { action: "sync" }, "native git-mirror sync failed"); + return nativeK8sStageFailure(follower, observedSha, "git-mirror-sync", sync.jobName, sync.result, { action: "sync" }, "native git-mirror sync failed", startedAt); } const result = runNativeTektonPipelineRun(namespace, pipelineRun, manifest, options.wait, remainingSeconds(startedAt, timeoutSeconds)); const payload = parseJsonObject(result.stdout) ?? {}; @@ -900,7 +901,7 @@ async function executeNativeHwlabNodeTrigger(registry: BranchFollowerRegistry, f ? runNativeGitMirrorStage(follower, observedSha, "flush", remainingSeconds(startedAt, timeoutSeconds)) : null; if (flush !== null && !flush.result.ok) { - return nativeK8sStageFailure(follower, observedSha, "git-mirror-flush", flush.jobName, flush.result, { action: "flush" }, "native git-mirror flush failed"); + return nativeK8sStageFailure(follower, observedSha, "git-mirror-flush", flush.jobName, flush.result, { action: "flush" }, "native git-mirror flush failed", startedAt); } const closeout = !failed && options.wait && pipelineRunCompleted ? await waitNativeFollowerCloseout(registry, follower, observedSha, options, remainingSeconds(startedAt, timeoutSeconds)) @@ -913,6 +914,7 @@ async function executeNativeHwlabNodeTrigger(registry: BranchFollowerRegistry, f stageRef: `${follower.source.snapshotPrefix.replace(/\/+$/u, "")}/${observedSha}`, wait: options.wait, result, + startedAt, payload: { ...payload, nativeCapabilities: { @@ -938,7 +940,7 @@ async function executeNativeAgentRunTrigger(registry: BranchFollowerRegistry, fo const jobPrefix = `agentrun-bf-${spec.nodeId.toLowerCase()}-${spec.lane}`; const sync = runNativeGitMirrorStage(follower, observedSha, "sync", Math.min(remainingSeconds(startedAt, timeoutSeconds), Math.max(5, follower.budgets.sourceSyncSeconds))); if (sync !== null && !sync.result.ok) { - return nativeK8sStageFailure(follower, observedSha, "git-mirror-sync", sync.jobName, sync.result, { action: "sync" }, "native AgentRun git-mirror sync failed"); + return nativeK8sStageFailure(follower, observedSha, "git-mirror-sync", sync.jobName, sync.result, { action: "sync" }, "native AgentRun git-mirror sync failed", startedAt); } const buildJob = `${jobPrefix}-build-${observedSha.slice(0, 12)}`.slice(0, 63); const build = runNativeK8sJob(spec.ci.namespace, buildJob, yamlLaneK3sBuildImageJobManifest(spec, observedSha, buildJob), Math.min(remainingSeconds(startedAt, timeoutSeconds), Math.max(60, spec.deployment.manager.imageBuild.timeoutSeconds)), "buildkit"); @@ -946,7 +948,7 @@ async function executeNativeAgentRunTrigger(registry: BranchFollowerRegistry, fo const digest = stringOrNull(buildPayload.digest); const envIdentity = stringOrNull(buildPayload.envIdentity); if (!build.ok || digest === null || envIdentity === null) { - return nativeK8sStageFailure(follower, observedSha, "image-build", buildJob, build, buildPayload, "native AgentRun image build failed"); + return nativeK8sStageFailure(follower, observedSha, "image-build", buildJob, build, buildPayload, "native AgentRun image build failed", startedAt); } const image = agentRunImageArtifact(spec, { sourceCommit: observedSha, envIdentity, digest, status: stringOrNull(buildPayload.status) ?? "built" }); const renderedFiles = renderAgentRunGitopsFiles(spec, { sourceCommit: observedSha, image }); @@ -954,11 +956,11 @@ async function executeNativeAgentRunTrigger(registry: BranchFollowerRegistry, fo const publish = runNativeK8sJob(spec.gitMirror.namespace, publishJob, yamlLaneGitopsPublishJobManifest(spec, renderedFiles, publishJob), remainingSeconds(startedAt, timeoutSeconds), "publish"); const publishPayload = yamlLaneGitopsPublishPayloadFromProbe({ logsTail: stringOrNull(publish.logsTail) ?? "" }); if (!publish.ok || publishPayload.ok === false || stringOrNull(publishPayload.gitopsCommit) === null) { - return nativeK8sStageFailure(follower, observedSha, "gitops-publish", publishJob, publish, publishPayload, "native AgentRun GitOps publish failed"); + return nativeK8sStageFailure(follower, observedSha, "gitops-publish", publishJob, publish, publishPayload, "native AgentRun GitOps publish failed", startedAt); } const flush = runNativeGitMirrorStage(follower, observedSha, "flush", remainingSeconds(startedAt, timeoutSeconds)); if (flush !== null && !flush.result.ok) { - return nativeK8sStageFailure(follower, observedSha, "git-mirror-flush", flush.jobName, flush.result, { action: "flush" }, "native AgentRun git-mirror flush failed"); + return nativeK8sStageFailure(follower, observedSha, "git-mirror-flush", flush.jobName, flush.result, { action: "flush" }, "native AgentRun git-mirror flush failed", startedAt); } const pipelineRun = agentRunPipelineRunName(spec, observedSha); const tektonResult = runNativeTektonPipelineRun(follower.nativeStatus.tekton.namespace, pipelineRun, yamlLanePipelineRunManifest(spec, observedSha, pipelineRun), options.wait, remainingSeconds(startedAt, timeoutSeconds)); @@ -976,13 +978,14 @@ async function executeNativeAgentRunTrigger(registry: BranchFollowerRegistry, fo stageRef, wait: options.wait, result: tektonResult, + startedAt, payload: { ...tektonPayload, agentrun: { configPath, gitMirrorSync: sync === null ? null : { jobName: sync.jobName, payload: sync.result }, - imageBuild: { jobName: buildJob, payload: buildPayload }, - gitopsPublish: { jobName: publishJob, payload: publishPayload }, + imageBuild: { jobName: buildJob, result: build, payload: buildPayload }, + gitopsPublish: { jobName: publishJob, result: publish, payload: publishPayload }, gitMirrorFlush: flush === null ? null : { jobName: flush.jobName, payload: flush.result }, }, }, @@ -1010,6 +1013,7 @@ function nativeK8sStageFailure( job: NativeK8sJobResult, payload: Record, message: string, + startedAt?: number, ): TriggerResult { const detail = [ message, @@ -1030,6 +1034,7 @@ function nativeK8sStageFailure( jobName, sourceCommit: observedSha, ok: false, + elapsedMs: startedAt === undefined ? job.elapsedMs : Date.now() - startedAt, payload, job, statusAuthority: "kubernetes-api-serviceaccount", @@ -1084,6 +1089,7 @@ function nativeTektonTriggerResult(input: { stageRef: string; wait: boolean; result: CommandResult; + startedAt: number; payload: Record; closeout: NativeCloseoutWaitResult | null; successMessage: string; @@ -1118,6 +1124,7 @@ function nativeTektonTriggerResult(input: { wait: input.wait, pipelineRunCompleted, stillRunning, + elapsedMs: Date.now() - input.startedAt, closeout: input.closeout, statusAuthority: "kubernetes-api-serviceaccount", parsedDownstreamCliOutput: false, @@ -1200,6 +1207,7 @@ async function executeNativeSentinelTrigger(registry: BranchFollowerRegistry, fo wait: options.wait, pipelineRunCompleted, stillRunning, + elapsedMs: Date.now() - startedAt, closeout, statusAuthority: "kubernetes-api-serviceaccount", parsedDownstreamCliOutput: false, @@ -1387,6 +1395,7 @@ async function readAdapterStatus(registry: BranchFollowerRegistry, follower: Fol planArtifacts: bundle.planArtifacts, argo: nativeArgoSummary(bundle.argoApplication), runtime: nativeRuntimeSummary(follower.nativeStatus.runtime, bundle.workloads), + timings: { statusRead: { elapsedMs: bundle.elapsedMs, budgetSeconds: timeoutSeconds } }, errors: bundle.errors, statusAuthority: "k8s-native", parsedDownstreamCliOutput: false, @@ -1452,6 +1461,7 @@ function readNativeObjectBundle(registry: BranchFollowerRegistry, follower: Foll "export NATIVE_CICD_SCRIPT_DIR REPO_PATH SOURCE_BRANCH REPOSITORY SNAPSHOT_PREFIX GITOPS_BRANCH TEKTON_NAMESPACE PIPELINE_RUN_PREFIX ARGO_NAMESPACE ARGO_APPLICATION WORKLOAD_REFS_B64", "\"$tmpdir/read-native-bundle.sh\"", ].join("\n"); + const startedAt = Date.now(); const result = runKubeScript(registry, options, script, "", Math.max(5, timeoutSeconds) * 1000); const parsed = parseNativeBundleLines(result.stdout); const sourceRecord = asOptionalRecord(parsed.objects.source); @@ -1475,6 +1485,7 @@ function readNativeObjectBundle(registry: BranchFollowerRegistry, follower: Foll ], exitCode: result.exitCode, timedOut: result.timedOut, + elapsedMs: Date.now() - startedAt, stdoutTail: redactText(tailText(result.stdout, 1000)), stderrTail: redactText(tailText(result.stderr, 1000)), }; @@ -1633,6 +1644,7 @@ function nativePipelineRunSummary(pipelineRun: Record | null): reason: stringOrNull(condition?.reason), startTime: stringOrNull(status?.startTime), completionTime: stringOrNull(status?.completionTime), + durationSeconds: numberOrNull(status?.durationSeconds), }; } @@ -1763,6 +1775,7 @@ function mergeFollowerStatus( stateConfigMap: registry.controller.stateConfigMapName, live: liveRequested, message: live?.message ?? stringOrNull(stored.decision) ?? "no controller state yet", + timings: live === null ? asOptionalRecord(stored.timings) : buildFollowerTimings(follower, live, undefined, asOptionalRecord(stored.timings)), warnings: Array.isArray(stored.warnings) ? stored.warnings.slice(0, 6) : [], next: followerNextCommands(follower), }; @@ -1950,6 +1963,7 @@ function compactFollowerStateForConfigMap(state: FollowerState): Record | null): Record | undefined, + storedTimings?: Record | null, +): FollowerState["timings"] { + const total = totalTimingFromCommand(triggerCommand) ?? totalTimingFromStored(storedTimings); + const stages = dedupeTimingStages([ + ...stageTimingsFromCommand(triggerCommand), + ...stageTimingsFromNativePayload(asOptionalRecord(live.payload)), + ]).slice(0, 24); + return { + budgetSeconds: follower.budgets.endToEndSeconds, + totalSeconds: total?.seconds ?? null, + totalStatus: total?.status ?? "unknown", + totalSource: total?.source ?? "unavailable", + overBudget: total === null ? null : total.seconds > follower.budgets.endToEndSeconds, + stages, + }; +} + +function compactTimings(timings: FollowerState["timings"]): FollowerState["timings"] { + return { + budgetSeconds: timings.budgetSeconds, + totalSeconds: timings.totalSeconds, + totalStatus: timings.totalStatus, + totalSource: timings.totalSource, + overBudget: timings.overBudget, + stages: timings.stages.slice(0, 24).map((stage) => ({ + stage: stage.stage, + status: stage.status, + seconds: stage.seconds, + budgetSeconds: stage.budgetSeconds, + source: stage.source, + object: stage.object, + })), + }; +} + +function totalTimingFromCommand(command: Record | undefined): { seconds: number; status: string; source: string } | null { + if (command === undefined) return null; + const seconds = secondsFromMs(numberOrNull(command.elapsedMs)); + if (seconds === null) return null; + const closeout = asOptionalRecord(command.closeout); + const exitCode = numberOrNull(command.exitCode); + const status = command.ok === false || (exitCode !== null && exitCode !== 0) + ? "failed" + : command.timedOut === true || closeout?.timedOut === true + ? "timed-out" + : closeout?.completed === true || command.completed === true + ? "completed" + : command.stillRunning === true + ? "running" + : command.pipelineRunCompleted === true + ? "ci-completed" + : "submitted"; + return { seconds, status, source: stringOrNull(command.mode) ?? stringOrNull(command.status) ?? "command" }; +} + +function totalTimingFromStored(storedTimings: Record | null | undefined): { seconds: number; status: string; source: string } | null { + if (storedTimings === null || storedTimings === undefined) return null; + const seconds = numberOrNull(storedTimings.totalSeconds); + if (seconds === null) return null; + return { + seconds, + status: stringOrNull(storedTimings.totalStatus) ?? "recorded", + source: stringOrNull(storedTimings.totalSource) ?? "stored-state", + }; +} + +function stageTimingsFromNativePayload(payload: Record | null): StageTiming[] { + if (payload === null) return []; + const stages: StageTiming[] = []; + const statusRead = asOptionalRecord(asOptionalRecord(payload.timings)?.statusRead); + stages.push(stageTiming("status-read", "ok", secondsFromMs(numberOrNull(statusRead?.elapsedMs)), numberOrNull(statusRead?.budgetSeconds), "native-status", null)); + const gitMirror = asOptionalRecord(payload.gitMirror); + if (gitMirror !== null) { + const status = gitMirror.pendingFlush === true ? "pending-flush" : gitMirror.githubInSync === true && gitMirror.sourceSnapshotReady === true ? "ready" : "not-ready"; + stages.push(stageTiming("git-mirror", status, null, null, "git-mirror-cache", stringOrNull(gitMirror.gitopsBranch) ?? stringOrNull(gitMirror.sourceBranch))); + } + const tekton = asOptionalRecord(payload.tekton); + if (tekton !== null) { + const status = tekton.succeeded === true ? "succeeded" : tekton.succeeded === false ? `failed:${stringOrNull(tekton.reason) ?? "unknown"}` : "running"; + stages.push(stageTiming("pipelinerun", status, numberOrNull(tekton.durationSeconds), null, "tekton", stringOrNull(tekton.name))); + } + const taskRuns = asOptionalRecord(payload.taskRuns); + const taskRunItems = taskRuns !== null && Array.isArray(taskRuns.items) ? taskRuns.items : []; + for (const item of taskRunItems) { + const record = asOptionalRecord(item); + if (record === null) continue; + const name = stringOrNull(record.pipelineTask) ?? stringOrNull(record.name) ?? "unknown"; + const status = record.status === "True" ? "succeeded" : record.status === "False" ? `failed:${stringOrNull(record.reason) ?? "unknown"}` : "running"; + stages.push(stageTiming(`task:${name}`, status, numberOrNull(record.durationSeconds), null, "tekton-taskrun", stringOrNull(record.name))); + } + const argo = asOptionalRecord(payload.argo); + if (argo !== null) { + stages.push(stageTiming("argo", `${stringOrNull(argo.syncStatus) ?? "unknown"}/${stringOrNull(argo.healthStatus) ?? "unknown"}`, null, null, "argocd", stringOrNull(argo.name))); + } + const runtime = asOptionalRecord(payload.runtime); + if (runtime !== null) { + stages.push(stageTiming("runtime", runtime.ready === true ? "ready" : "not-ready", null, null, "kubernetes-workload", stringOrNull(runtime.namespace))); + } + return stages; +} + +function stageTimingsFromCommand(command: Record | undefined): StageTiming[] { + if (command === undefined) return []; + const stages: StageTiming[] = []; + const phase = stringOrNull(command.phase); + const jobStage = phase === null ? null : k8sJobTiming(phase, asOptionalRecord(command.job), stringOrNull(command.jobName)); + if (jobStage !== null) stages.push(jobStage); + const payload = asOptionalRecord(command.payload); + if (payload !== null) { + const capabilities = asOptionalRecord(payload.nativeCapabilities); + for (const stage of [ + k8sJobTiming("git-mirror-sync", asOptionalRecord(capabilities?.gitMirrorSync)), + k8sJobTiming("git-mirror-flush", asOptionalRecord(capabilities?.gitMirrorFlush)), + ]) { + if (stage !== null) stages.push(stage); + } + const agentrun = asOptionalRecord(payload.agentrun); + const agentrunSync = asOptionalRecord(agentrun?.gitMirrorSync); + const agentrunFlush = asOptionalRecord(agentrun?.gitMirrorFlush); + const imageBuild = asOptionalRecord(agentrun?.imageBuild); + const gitopsPublish = asOptionalRecord(agentrun?.gitopsPublish); + for (const stage of [ + k8sJobTiming("git-mirror-sync", asOptionalRecord(agentrunSync?.payload), stringOrNull(agentrunSync?.jobName)), + k8sJobTiming("image-build", asOptionalRecord(imageBuild?.result), stringOrNull(imageBuild?.jobName)), + k8sJobTiming("gitops-publish", asOptionalRecord(gitopsPublish?.result), stringOrNull(gitopsPublish?.jobName)), + k8sJobTiming("git-mirror-flush", asOptionalRecord(agentrunFlush?.payload), stringOrNull(agentrunFlush?.jobName)), + ]) { + if (stage !== null) stages.push(stage); + } + const tektonSeconds = secondsFromMs(numberOrNull(payload.elapsedMs)); + if (tektonSeconds !== null) { + const status = payload.completed === true ? "completed" : payload.failed === true ? "failed" : payload.stillRunning === true ? "running" : "submitted"; + stages.push(stageTiming("pipelinerun-wait", status, tektonSeconds, null, "tekton-submit", stringOrNull(command.pipelineRun))); + } + } + const closeout = asOptionalRecord(command.closeout); + if (closeout !== null) { + const status = closeout.completed === true ? "completed" : closeout.timedOut === true ? "timed-out" : "pending"; + stages.push(stageTiming("closeout", status, secondsFromMs(numberOrNull(closeout.elapsedMs)), null, "k8s-native-closeout", stringOrNull(command.pipelineRun))); + } + return stages; +} + +function k8sJobTiming(stage: string, job: Record | null, objectOverride?: string | null): StageTiming | null { + if (job === null) return null; + const status = job.completed === true + ? job.reused === true ? "reused" : "completed" + : job.failed === true + ? "failed" + : job.timedOut === true + ? "timed-out" + : "running"; + return stageTiming(stage, status, secondsFromMs(numberOrNull(job.elapsedMs)), null, "kubernetes-job", objectOverride ?? stringOrNull(job.jobName)); +} + +function stageTiming(stage: string, status: string, seconds: number | null, budgetSeconds: number | null, source: string, object: string | null): StageTiming { + return { stage, status, seconds, budgetSeconds, source, object }; +} + +function dedupeTimingStages(stages: StageTiming[]): StageTiming[] { + const byKey = new Map(); + for (const stage of stages) { + if (stage.stage.length === 0) continue; + const key = `${stage.stage}\t${stage.object ?? ""}`; + const previous = byKey.get(key); + if (previous === undefined || previous.seconds === null && stage.seconds !== null) byKey.set(key, stage); + } + return [...byKey.values()]; +} + +function secondsFromMs(value: number | null): number | null { + return value === null ? null : roundSeconds(value / 1000); +} + +function roundSeconds(value: number): number { + return Math.round(value * 10) / 10; +} + function writeFollowerState(registry: BranchFollowerRegistry, state: FollowerState, options: ParsedOptions): CommandResult { const json = JSON.stringify(compactFollowerStateForConfigMap(state)); const dataPatch = JSON.stringify({ data: { [state.id]: json, _updatedAt: new Date().toISOString(), _specRef: SPEC_REF } }); @@ -2450,6 +2646,7 @@ function renderStatusHuman(payload: Record, _options: ParsedOpt }); 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"})`, "", @@ -2459,6 +2656,7 @@ function renderStatusHuman(payload: Record, _options: ParsedOpt ), "", 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", @@ -2484,10 +2682,12 @@ function renderRunOnceHuman(payload: Record): string { ]; }); const next = asOptionalRecord(payload.next); + const timingRows = followers.flatMap(timingRowsForFollower).slice(0, 48); 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)}`, "", "NEXT", `status: ${next?.status ?? "-"}`, @@ -2496,6 +2696,35 @@ function renderRunOnceHuman(payload: Record): string { ].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) ?? "-", + ]]; + 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);