fix: preserve branch follower stage intervals
This commit is contained in:
@@ -37,6 +37,7 @@ import { buildCicdHelp } from "./cicd-help";
|
||||
import { attachReconcileTimeline, compactReconcileTimeline, finishReconcileStep, finishReconcileTimeline, startReconcileStep, startReconcileTimeline } from "./cicd-reconcile-timeline";
|
||||
import { orderFollowersForControllerCloseout, shouldYieldAfterAutomaticTrigger } from "./cicd-reconcile-scheduler";
|
||||
import { buildFollowerTimings, compactListTimings, compactTimings, storedFollowerTimingsForStatus, timingPerformanceSummary } from "./cicd-timings";
|
||||
import { timingAttributionSummary } from "./cicd-timing-attribution";
|
||||
import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerGate, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, TriggerResult } from "./cicd-types";
|
||||
import {
|
||||
arrayField,
|
||||
@@ -1843,6 +1844,8 @@ async function readAdapterStatus(registry: BranchFollowerRegistry, follower: Fol
|
||||
const sourceSyncDetail = sourceSync === null || sourceSync.result.ok ? null : redactText(tailText(sourceSync.result.conditionMessage ?? sourceSync.result.logsTail ?? "unknown", 800));
|
||||
const sourceSyncError = sourceSyncDetail === null ? null : `native source sync failed: ${sourceSyncDetail}`;
|
||||
const bundle = readNativeObjectBundle(registry, follower, options, remainingSeconds(startedAt, timeoutSeconds), runKubeScript);
|
||||
const bundleFinishedMs = Date.now();
|
||||
const bundleStartedMs = Math.max(startedAt, bundleFinishedMs - Math.max(0, bundle.elapsedMs));
|
||||
const observedSha = sourceSyncError === null ? stringOrNull(bundle.source?.commit) : null;
|
||||
const runtimeTargetSha = runtimeTargetShaFromWorkloads(follower.nativeStatus.runtime, bundle.workloads);
|
||||
const pipelineRunName = stringOrNull(asOptionalRecord(bundle.pipelineRun?.metadata)?.name) ?? expectedPipelineRunName(follower, observedSha);
|
||||
@@ -1902,7 +1905,7 @@ async function readAdapterStatus(registry: BranchFollowerRegistry, follower: Fol
|
||||
planArtifacts: bundle.planArtifacts,
|
||||
argo: nativeArgoSummary(bundle.argoApplication),
|
||||
runtime: nativeRuntimeSummary(follower.nativeStatus.runtime, bundle.workloads, observedSha),
|
||||
timings: { statusRead: { elapsedMs: bundle.elapsedMs, budgetSeconds: timeoutSeconds } },
|
||||
timings: { statusRead: { elapsedMs: bundle.elapsedMs, budgetSeconds: timeoutSeconds, startedAt: new Date(bundleStartedMs).toISOString(), finishedAt: new Date(bundleFinishedMs).toISOString() } },
|
||||
errors,
|
||||
statusAuthority: "k8s-native",
|
||||
parsedDownstreamCliOutput: false,
|
||||
@@ -2632,9 +2635,11 @@ function nativeGateTimingSummary(payload: Record<string, unknown> | null, timing
|
||||
statusReadSeconds: secondsFromMsValue(numberOrNull(statusRead?.elapsedMs)),
|
||||
gitMirrorSyncSeconds: secondsFromMsValue(numberOrNull(sourceSync?.elapsedMs)),
|
||||
pipelineRunSeconds: numberOrNull(tekton?.durationSeconds),
|
||||
pipelineRunName: stringOrNull(tekton?.name),
|
||||
pipelineRunStartedAt: stringOrNull(tekton?.startTime),
|
||||
pipelineRunFinishedAt: stringOrNull(tekton?.completionTime),
|
||||
argoOperationSeconds: numberOrNull(argo?.operationDurationSeconds),
|
||||
argoApplication: stringOrNull(argo?.name),
|
||||
argoOperationStartedAt: stringOrNull(argo?.operationStartedAt),
|
||||
argoOperationFinishedAt: stringOrNull(argo?.operationFinishedAt),
|
||||
argoIncludedInStoredTotal: argoStage?.seconds !== null && argoStage?.source === "argocd",
|
||||
@@ -2644,108 +2649,6 @@ function nativeGateTimingSummary(payload: Record<string, unknown> | null, timing
|
||||
};
|
||||
}
|
||||
|
||||
function timingAttributionSummary(timings: FollowerState["timings"], nativeGate: Record<string, unknown>): Record<string, unknown> {
|
||||
const totalStartedMs = timestampMs(timings.startedAt);
|
||||
const totalFinishedMs = timestampMs(timings.finishedAt);
|
||||
const totalSeconds = timings.totalSeconds;
|
||||
if (totalSeconds === null || totalStartedMs === null || totalFinishedMs === null || totalFinishedMs < totalStartedMs) {
|
||||
return {
|
||||
status: "unknown",
|
||||
source: "stored-total-vs-native-intervals",
|
||||
totalSeconds,
|
||||
knownIntervalCoverageSeconds: null,
|
||||
unknownWallClockSeconds: null,
|
||||
reason: "stored total range is missing or invalid; old wall-clock attribution cannot be reconstructed",
|
||||
};
|
||||
}
|
||||
const intervals = [
|
||||
timingIntervalOverlap("pipeline", "tekton", nativeGate.pipelineRunStartedAt, nativeGate.pipelineRunFinishedAt, totalStartedMs, totalFinishedMs),
|
||||
timingIntervalOverlap("argo", "argocd", nativeGate.argoOperationStartedAt, nativeGate.argoOperationFinishedAt, totalStartedMs, totalFinishedMs),
|
||||
].filter((item): item is Record<string, unknown> => item !== null);
|
||||
const coverageSeconds = mergedIntervalCoverageSeconds(intervals);
|
||||
const unknownSeconds = roundSeconds(Math.max(0, totalSeconds - coverageSeconds));
|
||||
const missingHistoricalIntervals = timings.stages.some((stage) => stage.seconds !== null);
|
||||
return {
|
||||
status: unknownSeconds > 0 ? "partial" : "covered",
|
||||
source: "stored-total-vs-native-intervals",
|
||||
totalSeconds,
|
||||
totalStartedAt: timings.startedAt,
|
||||
totalFinishedAt: timings.finishedAt,
|
||||
knownIntervalCoverageSeconds: coverageSeconds,
|
||||
unknownWallClockSeconds: unknownSeconds,
|
||||
intervalCount: intervals.length,
|
||||
intervals: intervals.map(({ startMs: _startMs, endMs: _endMs, overlapStartMs: _overlapStartMs, overlapEndMs: _overlapEndMs, ...item }) => item),
|
||||
reason: unknownSeconds > 0
|
||||
? missingHistoricalIntervals
|
||||
? "stored state lacks historical per-stage intervals for the remaining wall-clock; do not infer wait/idle from current native objects"
|
||||
: "no stored historical native intervals overlap the total range; old wall-clock attribution cannot be reconstructed"
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function timingIntervalOverlap(stage: string, source: string, startedAtValue: unknown, finishedAtValue: unknown, totalStartedMs: number, totalFinishedMs: number): Record<string, unknown> | null {
|
||||
const startedAt = stringOrNull(startedAtValue);
|
||||
const finishedAt = stringOrNull(finishedAtValue);
|
||||
const startMs = timestampMs(startedAt);
|
||||
const endMs = timestampMs(finishedAt);
|
||||
if (startMs === null || endMs === null || endMs < startMs) return null;
|
||||
const overlapStartMs = Math.max(startMs, totalStartedMs);
|
||||
const overlapEndMs = Math.min(endMs, totalFinishedMs);
|
||||
const overlapSeconds = overlapEndMs > overlapStartMs ? roundSeconds((overlapEndMs - overlapStartMs) / 1000) : 0;
|
||||
return {
|
||||
stage,
|
||||
source,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
overlapSeconds,
|
||||
inStoredTotal: overlapSeconds > 0,
|
||||
startMs,
|
||||
endMs,
|
||||
overlapStartMs,
|
||||
overlapEndMs,
|
||||
};
|
||||
}
|
||||
|
||||
function mergedIntervalCoverageSeconds(intervals: Record<string, unknown>[]): number {
|
||||
const ranges = intervals
|
||||
.map((item) => ({
|
||||
startMs: numberOrNull(item.overlapStartMs),
|
||||
endMs: numberOrNull(item.overlapEndMs),
|
||||
overlapSeconds: numberOrNull(item.overlapSeconds),
|
||||
}))
|
||||
.filter((item): item is { startMs: number; endMs: number; overlapSeconds: number } => item.startMs !== null && item.endMs !== null && item.overlapSeconds !== null && item.overlapSeconds > 0)
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
let coveredMs = 0;
|
||||
let currentStart: number | null = null;
|
||||
let currentEnd: number | null = null;
|
||||
for (const range of ranges) {
|
||||
if (currentStart === null || currentEnd === null) {
|
||||
currentStart = range.startMs;
|
||||
currentEnd = range.endMs;
|
||||
continue;
|
||||
}
|
||||
if (range.startMs <= currentEnd) {
|
||||
currentEnd = Math.max(currentEnd, range.endMs);
|
||||
continue;
|
||||
}
|
||||
coveredMs += currentEnd - currentStart;
|
||||
currentStart = range.startMs;
|
||||
currentEnd = range.endMs;
|
||||
}
|
||||
if (currentStart !== null && currentEnd !== null) coveredMs += currentEnd - currentStart;
|
||||
return roundSeconds(coveredMs / 1000);
|
||||
}
|
||||
|
||||
function timestampMs(value: string | null): number | null {
|
||||
if (value === null) return null;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function roundSeconds(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function followerNextCommands(follower: FollowerSpec): Record<string, string> {
|
||||
const next: Record<string, string> = {
|
||||
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
|
||||
|
||||
Reference in New Issue
Block a user