diff --git a/.agents/skills/unidesk-cicd/references/branch-follower.md b/.agents/skills/unidesk-cicd/references/branch-follower.md index 19f8a147..39ca0292 100644 --- a/.agents/skills/unidesk-cicd/references/branch-follower.md +++ b/.agents/skills/unidesk-cicd/references/branch-follower.md @@ -54,6 +54,8 @@ Argo closeout visibility must include the bounded reason for non-ready health, n Tekton failure visibility must include bounded TaskRun detail, not only PipelineRun `Failed`: failed TaskRuns, active TaskRuns and slow TaskRuns with task name, reason and duration. Without this, performance/failure work cannot move past the PipelineRun gate. +Default stage timing tables must prioritize failed, active and slow TaskRun rows before ordinary succeeded TaskRuns when the row budget is tight. Do not truncate TaskRuns purely by Kubernetes start time if that hides the first failing or slow task. + When Argo exposes operation start/finish timestamps, stage timing rows should report the Argo operation duration directly. Missing timestamps still render `-`; do not infer Argo duration from total elapsed time or from unrelated runtime polling. The automatic controller loop is non-blocking, so closeout acceleration cannot live only in the user-facing `--wait` path. Once a triggered PipelineRun has succeeded and required runtime/GitOps gates are not aligned, the in-cluster controller path should perform the same bounded target-side Argo refresh used by wait closeout; otherwise convergence depends on Argo's background poll interval and can exceed the 120s budget even when Tekton finished quickly. diff --git a/scripts/native/cicd/read-state-summary.mjs b/scripts/native/cicd/read-state-summary.mjs index c104091f..c5a9fffe 100644 --- a/scripts/native/cicd/read-state-summary.mjs +++ b/scripts/native/cicd/read-state-summary.mjs @@ -308,10 +308,41 @@ function compactTimings(timings) { startedAt: stringOrNull(value.startedAt), finishedAt: stringOrNull(value.finishedAt), overBudget: typeof value.overBudget === "boolean" ? value.overBudget : null, - stages: arrayRecords(value.stages).slice(0, maxTimingStages).map(compactStageTiming), + stages: prioritizedStageTimings(arrayRecords(value.stages)).slice(0, maxTimingStages).map(compactStageTiming), }; } +function prioritizedStageTimings(stages) { + const priority = []; + const rest = []; + for (const stage of stages) { + if (isPriorityTaskStage(stage)) priority.push(stage); + else rest.push(stage); + } + const seen = new Set(); + const out = []; + for (const stage of [...priority, ...rest]) { + const key = [ + stringOrNull(stage.stage), + stringOrNull(stage.status), + stringOrNull(stage.source), + stringOrNull(stage.object), + ].filter((item) => item !== null).join("|"); + if (seen.has(key)) continue; + seen.add(key); + out.push(stage); + } + return out; +} + +function isPriorityTaskStage(stage) { + const name = stringOrNull(stage.stage) || ""; + if (!name.startsWith("task:")) return false; + const status = stringOrNull(stage.status) || ""; + const seconds = numberOrNull(stage.seconds); + return status.startsWith("failed") || status === "running" || (seconds !== null && seconds > 60); +} + function compactStageTiming(stage) { return { stage: stringOrNull(stage.stage), diff --git a/scripts/src/cicd-branch-follower.ts b/scripts/src/cicd-branch-follower.ts index f24af72e..ade468fd 100644 --- a/scripts/src/cicd-branch-follower.ts +++ b/scripts/src/cicd-branch-follower.ts @@ -27,6 +27,7 @@ import { runNativeHwlabControlPlaneRefresh } from "./cicd-hwlab-refresh"; import { nativeCicdScriptLoadShell, readNativeObjectBundle } from "./cicd-native-bundle"; import { runNativeK8sJob, runNativeTektonPipelineRun } from "./cicd-native"; import { argoApplicationReady, nativeArgoSummary, nativeGitMirrorReady, nativeGitMirrorRequired, nativeGitMirrorSummary, nativePipelineRunSummary, nativeRuntimeSummary, pipelineRunSucceeded, runtimeTargetShaFromWorkloads, runtimeWorkloadsReady } from "./cicd-native-summary"; +import { prioritizedTaskRunItems } from "./cicd-taskruns"; import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, StageTiming, TriggerResult } from "./cicd-types"; import { arrayField, @@ -2279,10 +2280,7 @@ function stageTimingsFromNativePayload(payload: Record | null): 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; + for (const record of taskRuns === null ? [] : prioritizedTaskRunItems(taskRuns)) { 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))); diff --git a/scripts/src/cicd-debug.ts b/scripts/src/cicd-debug.ts index ead58f8c..e54890e1 100644 --- a/scripts/src/cicd-debug.ts +++ b/scripts/src/cicd-debug.ts @@ -6,6 +6,7 @@ import { runCommand, type CommandResult } from "./command"; import { repoRoot, rootPath } from "./config"; import type { AdapterSummary, BranchFollowerDebugStep, BranchFollowerRegistry, FollowerSpec, FollowerState, K8sStateRead, ParsedOptions } from "./cicd-types"; import { renderControllerDebugJob, waitForJobShell } from "./cicd-controller-render"; +import { taskRunItems } from "./cicd-taskruns"; import { redactText, shQuote } from "./platform-infra-ops-library"; type KubeScriptRunner = (registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number) => CommandResult; @@ -423,26 +424,6 @@ function compactStatusGates(payload: Record | null): Record, mode: "failed" | "active" | "slow"): Record[] { - const explicit = mode === "failed" ? taskRuns.failedItems : mode === "active" ? taskRuns.activeItems : taskRuns.slowItems; - const explicitItems = arrayRecords(explicit); - if (explicitItems.length > 0) return explicitItems.slice(0, 5).map(compactTaskRunItem); - const items = arrayRecords(taskRuns.items); - if (mode === "failed") return items.filter((item) => item.status === "False").slice(0, 5).map(compactTaskRunItem); - if (mode === "active") return items.filter((item) => item.status !== "True" && item.status !== "False").slice(0, 5).map(compactTaskRunItem); - return arrayRecords(asOptionalRecord(taskRuns.performance)?.slowTaskRuns).slice(0, 5).map(compactTaskRunItem); -} - -function compactTaskRunItem(item: Record): Record { - return { - name: stringOrNull(item.name), - pipelineTask: stringOrNull(item.pipelineTask), - status: stringOrNull(item.status), - reason: stringOrNull(item.reason), - durationSeconds: numberOrNull(item.durationSeconds), - }; -} - function arrayRecords(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; } diff --git a/scripts/src/cicd-taskruns.ts b/scripts/src/cicd-taskruns.ts new file mode 100644 index 00000000..8586f743 --- /dev/null +++ b/scripts/src/cicd-taskruns.ts @@ -0,0 +1,74 @@ +// SPEC: PJ2026-01060703 CI/CD TaskRun summaries. +// Responsibility: shared bounded TaskRun prioritization for branch-follower status/debug visibility. + +export type TaskRunMode = "failed" | "active" | "slow"; + +export function taskRunItems(taskRuns: Record, mode: TaskRunMode, limit = 5): Record[] { + const explicit = mode === "failed" ? taskRuns.failedItems : mode === "active" ? taskRuns.activeItems : taskRuns.slowItems; + const explicitItems = arrayRecords(explicit); + if (explicitItems.length > 0) return explicitItems.slice(0, limit).map(compactTaskRunItem); + const items = arrayRecords(taskRuns.items); + if (mode === "failed") return items.filter((item) => item.status === "False").slice(0, limit).map(compactTaskRunItem); + if (mode === "active") return items.filter((item) => item.status !== "True" && item.status !== "False").slice(0, limit).map(compactTaskRunItem); + const slowItems = arrayRecords(asOptionalRecord(taskRuns.performance)?.slowTaskRuns); + if (slowItems.length > 0) return slowItems.slice(0, limit).map(compactTaskRunItem); + return items.filter((item) => { + const seconds = numberOrNull(item.durationSeconds); + return seconds !== null && seconds > 60; + }).slice(0, limit).map(compactTaskRunItem); +} + +export function prioritizedTaskRunItems(taskRuns: Record, limit = 16): Record[] { + const prioritized = [ + ...taskRunItems(taskRuns, "failed", limit), + ...taskRunItems(taskRuns, "active", limit), + ...taskRunItems(taskRuns, "slow", limit), + ...arrayRecords(taskRuns.items).map(compactTaskRunItem), + ]; + const seen = new Set(); + const out: Record[] = []; + for (const item of prioritized) { + const key = taskRunKey(item); + if (seen.has(key)) continue; + seen.add(key); + out.push(item); + if (out.length >= limit) break; + } + return out; +} + +export function compactTaskRunItem(item: Record): Record { + return { + name: stringOrNull(item.name), + pipelineTask: stringOrNull(item.pipelineTask), + status: stringOrNull(item.status), + reason: stringOrNull(item.reason), + durationSeconds: numberOrNull(item.durationSeconds), + }; +} + +function taskRunKey(item: Record): string { + const key = [ + stringOrNull(item.name), + stringOrNull(item.pipelineTask), + stringOrNull(item.status), + stringOrNull(item.reason), + ].filter((value) => value !== null).join("|"); + return key.length > 0 ? key : JSON.stringify(item); +} + +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 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; +}