164 lines
7.0 KiB
TypeScript
164 lines
7.0 KiB
TypeScript
// 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<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-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<string, unknown>[] {
|
|
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<string, unknown> = {
|
|
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<string, TimingInterval>();
|
|
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;
|
|
}
|