// SPEC: PJ2026-01060703 CI/CD branch follower timing attribution. // Responsibility: derive bounded wall-clock attribution from stored totals and stage/native intervals. import type { FollowerState, StageTiming } from "./cicd-types"; export function timingAttributionSummary(timings: FollowerState["timings"], nativeGate: Record): Record { 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-stage-intervals", totalSeconds, knownIntervalCoverageSeconds: null, unknownWallClockSeconds: null, missingIntervalStages: [], reason: "stored total range is missing or invalid; old wall-clock attribution cannot be reconstructed", }; } const stageIntervals = timings.stages .map((stage) => timingIntervalOverlap(stage.stage, stage.source, stage.object, stage.startedAt, stage.finishedAt, totalStartedMs, totalFinishedMs)) .filter((item): item is TimingInterval => item !== null); const nativeIntervals = [ timingIntervalOverlap("pipelinerun", "tekton-native-gate", String(nativeGate.pipelineRunObject ?? nativeGate.pipelineRunName ?? ""), nativeGate.pipelineRunStartedAt, nativeGate.pipelineRunFinishedAt, totalStartedMs, totalFinishedMs), timingIntervalOverlap("argo", "argocd-native-gate", String(nativeGate.argoApplication ?? ""), nativeGate.argoOperationStartedAt, nativeGate.argoOperationFinishedAt, totalStartedMs, totalFinishedMs), ].filter((item): item is TimingInterval => item !== null); const intervals = dedupeIntervals([...stageIntervals, ...nativeIntervals]); const coverageSeconds = mergedIntervalCoverageSeconds(intervals); const unknownSeconds = roundSeconds(Math.max(0, totalSeconds - coverageSeconds)); const missingIntervalCount = countMissingStageIntervals(timings.stages); const missingIntervalStages = missingStageIntervals(timings.stages); return { status: unknownSeconds > 0 ? "partial" : "covered", source: "stored-total-vs-stage-intervals", totalSeconds, totalStartedAt: timings.startedAt, totalFinishedAt: timings.finishedAt, knownIntervalCoverageSeconds: coverageSeconds, unknownWallClockSeconds: unknownSeconds, intervalCount: intervals.length, stageIntervalCount: stageIntervals.length, nativeIntervalCount: nativeIntervals.length, excludedIntervalCount: intervals.filter((item) => !item.inStoredTotal).length, missingIntervalCount, missingIntervalReason: missingIntervalCount > 0 ? "missing-startedAt-or-finishedAt" : null, missingIntervalStages, intervals: intervals .filter((item) => item.inStoredTotal) .map(({ startMs: _startMs, endMs: _endMs, overlapStartMs: _overlapStartMs, overlapEndMs: _overlapEndMs, ...item }) => item), reason: unknownSeconds > 0 ? missingIntervalCount > 0 ? "some timed stages lack startedAt/finishedAt; remaining wall-clock must stay unknown until future state records those intervals" : "no stored historical stage intervals cover the remaining wall-clock; old data cannot be reconstructed" : null, }; } type TimingInterval = { stage: string; source: string; object: string | null; startedAt: string; finishedAt: string; overlapSeconds: number; inStoredTotal: boolean; startMs: number; endMs: number; overlapStartMs: number; overlapEndMs: number; }; function timingIntervalOverlap(stage: string, source: string, object: unknown, startedAtValue: unknown, finishedAtValue: unknown, totalStartedMs: number, totalFinishedMs: number): TimingInterval | null { const startedAt = stringOrNull(startedAtValue); const finishedAt = stringOrNull(finishedAtValue); const startMs = timestampMs(startedAt); const endMs = timestampMs(finishedAt); if (startedAt === null || finishedAt === null || 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, object: stringOrNull(object), startedAt, finishedAt, overlapSeconds, inStoredTotal: overlapSeconds > 0, startMs, endMs, overlapStartMs, overlapEndMs, }; } function missingStageIntervals(stages: readonly StageTiming[]): Record[] { return stages .filter((stage) => stage.seconds !== null && (timestampMs(stage.startedAt ?? null) === null || timestampMs(stage.finishedAt ?? null) === null)) .slice(0, 3) .map((stage) => { const row: Record = { stage: stage.stage, source: stage.source, seconds: stage.seconds, }; if (stage.object !== null) row.object = stage.object; return row; }); } function countMissingStageIntervals(stages: readonly StageTiming[]): number { return stages.filter((stage) => stage.seconds !== null && (timestampMs(stage.startedAt ?? null) === null || timestampMs(stage.finishedAt ?? null) === null)).length; } function dedupeIntervals(intervals: TimingInterval[]): TimingInterval[] { const byKey = new Map(); for (const interval of intervals) { const key = `${interval.stage}\t${interval.source}\t${interval.startedAt}\t${interval.finishedAt}`; const previous = byKey.get(key); if (previous === undefined || previous.inStoredTotal === false && interval.inStoredTotal === true) byKey.set(key, interval); } return [...byKey.values()]; } function mergedIntervalCoverageSeconds(intervals: TimingInterval[]): number { const ranges = intervals .filter((item) => item.overlapSeconds > 0) .map((item) => ({ startMs: item.overlapStartMs, endMs: item.overlapEndMs })) .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 stringOrNull(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; }