cicd expose branch follower stage timings

This commit is contained in:
Codex
2026-07-03 11:26:28 +00:00
parent c754549df2
commit 63de02e3a2
4 changed files with 263 additions and 10 deletions
+18
View File
@@ -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<string, string>;
command?: Record<string, unknown>;
+238 -9
View File
@@ -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<string, unknown>,
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<string, unknown>;
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<string, unknown> | 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<string,
decision: state.decision,
dryRun: state.dryRun,
updatedAt: state.updatedAt,
timings: compactTimings(state.timings),
warnings: compactStateWarnings(state.warnings),
stateFormat: "compact-v1",
command: compactStateCommand(state.command),
@@ -2053,6 +2067,188 @@ function compactSourcePayload(source: Record<string, unknown> | null): Record<st
};
}
function buildFollowerTimings(
follower: FollowerSpec,
live: AdapterSummary,
triggerCommand: Record<string, unknown> | undefined,
storedTimings?: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, StageTiming>();
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<string, unknown>, _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<string, unknown>, _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, unknown>): 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, unknown>): string {
].join("\n");
}
function timingRowsForFollower(item: Record<string, unknown>): 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, unknown>): string {
const controller = asOptionalRecord(payload.controller);
const command = asOptionalRecord(payload.command);