diff --git a/.agents/skills/unidesk-cicd/references/branch-follower.md b/.agents/skills/unidesk-cicd/references/branch-follower.md index 4b572d71..0250c20d 100644 --- a/.agents/skills/unidesk-cicd/references/branch-follower.md +++ b/.agents/skills/unidesk-cicd/references/branch-follower.md @@ -64,7 +64,7 @@ Default `status` output must show follower id, phase, adapter, source branch + o 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. +`timings.totalSeconds` is the authoritative end-to-end wall-clock measurement for a triggered run: measure from `timings.startedAt` until `timings.finishedAt`, or until query time while closeout is still running. 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/src/cicd-types.ts b/scripts/src/cicd-types.ts index 97f86eba..6fcb85e7 100644 --- a/scripts/src/cicd-types.ts +++ b/scripts/src/cicd-types.ts @@ -291,6 +291,8 @@ export interface FollowerState { totalSeconds: number | null; totalStatus: string; totalSource: string; + startedAt: string | null; + finishedAt: string | null; overBudget: boolean | null; stages: StageTiming[]; }; diff --git a/scripts/src/cicd.ts b/scripts/src/cicd.ts index e4f5764a..8d7fb280 100644 --- a/scripts/src/cicd.ts +++ b/scripts/src/cicd.ts @@ -828,7 +828,7 @@ async function decideAndMaybeTrigger( decision, dryRun: options.dryRun, updatedAt: new Date().toISOString(), - timings: buildFollowerTimings(follower, live, triggerCommand), + timings: buildFollowerTimings(follower, live, triggerCommand, null, phase), warnings, next: followerNextCommands(follower), command: triggerCommand ?? { @@ -1034,6 +1034,8 @@ function nativeK8sStageFailure( jobName, sourceCommit: observedSha, ok: false, + startedAt: startedAt === undefined ? null : new Date(startedAt).toISOString(), + finishedAt: new Date().toISOString(), elapsedMs: startedAt === undefined ? job.elapsedMs : Date.now() - startedAt, payload, job, @@ -1109,6 +1111,9 @@ function nativeTektonTriggerResult(input: { ? `native PipelineRun ${input.pipelineRun} is still running; query status/events/logs for closeout` : `native PipelineRun ${input.pipelineRun} submitted`; const ok = !failed && (input.closeout === null || input.closeout.completed === true); + const finishedAt = failed || input.result.timedOut || input.closeout?.completed === true || input.closeout?.timedOut === true + ? new Date().toISOString() + : null; return { ok, completed: input.closeout?.completed === true, @@ -1124,6 +1129,8 @@ function nativeTektonTriggerResult(input: { wait: input.wait, pipelineRunCompleted, stillRunning, + startedAt: new Date(input.startedAt).toISOString(), + finishedAt, elapsedMs: Date.now() - input.startedAt, closeout: input.closeout, statusAuthority: "kubernetes-api-serviceaccount", @@ -1192,6 +1199,9 @@ async function executeNativeSentinelTrigger(registry: BranchFollowerRegistry, fo ? `native sentinel PipelineRun ${pipelineRun} is still running; query status/events/logs for closeout` : `native sentinel PipelineRun ${pipelineRun} submitted`; const ok = !failed && (closeout === null || closeout.completed === true); + const finishedAt = failed || result.timedOut || closeout?.completed === true || closeout?.timedOut === true + ? new Date().toISOString() + : null; return { ok, completed: closeout?.completed === true, @@ -1207,6 +1217,8 @@ async function executeNativeSentinelTrigger(registry: BranchFollowerRegistry, fo wait: options.wait, pipelineRunCompleted, stillRunning, + startedAt: new Date(startedAt).toISOString(), + finishedAt, elapsedMs: Date.now() - startedAt, closeout, statusAuthority: "kubernetes-api-serviceaccount", @@ -1775,7 +1787,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)), + timings: live === null ? asOptionalRecord(stored.timings) : buildFollowerTimings(follower, live, undefined, asOptionalRecord(stored.timings), phase), warnings: Array.isArray(stored.warnings) ? stored.warnings.slice(0, 6) : [], next: followerNextCommands(follower), }; @@ -2072,8 +2084,9 @@ function buildFollowerTimings( live: AdapterSummary, triggerCommand: Record | undefined, storedTimings?: Record | null, + phase?: BranchFollowerPhase, ): FollowerState["timings"] { - const total = totalTimingFromCommand(triggerCommand) ?? totalTimingFromStored(storedTimings); + const total = totalTimingFromCommand(triggerCommand, phase) ?? totalTimingFromStored(storedTimings, phase); const stages = dedupeTimingStages([ ...stageTimingsFromCommand(triggerCommand), ...stageTimingsFromNativePayload(asOptionalRecord(live.payload)), @@ -2083,6 +2096,8 @@ function buildFollowerTimings( totalSeconds: total?.seconds ?? null, totalStatus: total?.status ?? "unknown", totalSource: total?.source ?? "unavailable", + startedAt: total?.startedAt ?? null, + finishedAt: total?.finishedAt ?? null, overBudget: total === null ? null : total.seconds > follower.budgets.endToEndSeconds, stages, }; @@ -2094,6 +2109,8 @@ function compactTimings(timings: FollowerState["timings"]): FollowerState["timin totalSeconds: timings.totalSeconds, totalStatus: timings.totalStatus, totalSource: timings.totalSource, + startedAt: timings.startedAt, + finishedAt: timings.finishedAt, overBudget: timings.overBudget, stages: timings.stages.slice(0, 24).map((stage) => ({ stage: stage.stage, @@ -2106,9 +2123,11 @@ function compactTimings(timings: FollowerState["timings"]): FollowerState["timin }; } -function totalTimingFromCommand(command: Record | undefined): { seconds: number; status: string; source: string } | null { +function totalTimingFromCommand(command: Record | undefined, phase?: BranchFollowerPhase): { seconds: number; status: string; source: string; startedAt: string | null; finishedAt: string | null } | null { if (command === undefined) return null; - const seconds = secondsFromMs(numberOrNull(command.elapsedMs)); + const startedAt = stringOrNull(command.startedAt); + const finishedAt = stringOrNull(command.finishedAt); + const seconds = totalSecondsFromRange(startedAt, finishedAt) ?? secondsFromMs(numberOrNull(command.elapsedMs)); if (seconds === null) return null; const closeout = asOptionalRecord(command.closeout); const exitCode = numberOrNull(command.exitCode); @@ -2122,21 +2141,44 @@ function totalTimingFromCommand(command: Record | undefined): { ? "running" : command.pipelineRunCompleted === true ? "ci-completed" - : "submitted"; - return { seconds, status, source: stringOrNull(command.mode) ?? stringOrNull(command.status) ?? "command" }; + : phase === undefined + ? "submitted" + : phase.toLowerCase(); + return { seconds, status, source: stringOrNull(command.mode) ?? stringOrNull(command.status) ?? "command", startedAt, finishedAt }; } -function totalTimingFromStored(storedTimings: Record | null | undefined): { seconds: number; status: string; source: string } | null { +function totalTimingFromStored(storedTimings: Record | null | undefined, phase?: BranchFollowerPhase): { seconds: number; status: string; source: string; startedAt: string | null; finishedAt: string | null } | null { if (storedTimings === null || storedTimings === undefined) return null; - const seconds = numberOrNull(storedTimings.totalSeconds); + const startedAt = stringOrNull(storedTimings.startedAt); + const finishedAt = stringOrNull(storedTimings.finishedAt); + const seconds = totalSecondsFromRange(startedAt, finishedAt) ?? numberOrNull(storedTimings.totalSeconds); if (seconds === null) return null; return { seconds, - status: stringOrNull(storedTimings.totalStatus) ?? "recorded", + status: finishedAt === null && phase !== undefined && !terminalPhase(phase) ? phase.toLowerCase() : stringOrNull(storedTimings.totalStatus) ?? "recorded", source: stringOrNull(storedTimings.totalSource) ?? "stored-state", + startedAt, + finishedAt, }; } +function totalSecondsFromRange(startedAt: string | null, finishedAt: string | null): number | null { + const startedMs = timestampMs(startedAt); + if (startedMs === null) return null; + const finishedMs = timestampMs(finishedAt) ?? Date.now(); + return finishedMs >= startedMs ? roundSeconds((finishedMs - startedMs) / 1000) : null; +} + +function timestampMs(value: string | null): number | null { + if (value === null) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function terminalPhase(phase: BranchFollowerPhase): boolean { + return phase === "Succeeded" || phase === "Failed" || phase === "Blocked" || phase === "Skipped" || phase === "Noop"; +} + function stageTimingsFromNativePayload(payload: Record | null): StageTiming[] { if (payload === null) return []; const stages: StageTiming[] = [];