fix: expose branch follower timing attribution gap
This commit is contained in:
@@ -2589,6 +2589,7 @@ function statusTimingContext(
|
||||
const fallbackPayload = asOptionalRecord(asOptionalRecord(fallbackStored.command)?.payload);
|
||||
const livePayload = asOptionalRecord(live?.payload);
|
||||
const nativePayload = firstRecord(livePayload, storedPayload, fallbackPayload);
|
||||
const nativeGateTiming = nativeGateTimingSummary(nativePayload, timings);
|
||||
return {
|
||||
storedTiming: {
|
||||
totalSeconds: timings.totalSeconds,
|
||||
@@ -2606,7 +2607,8 @@ function statusTimingContext(
|
||||
includedInStoredTotal: false,
|
||||
note: liveRequested ? "live/status refresh is reported separately and must not be added to stored total" : null,
|
||||
},
|
||||
nativeGateTiming: nativeGateTimingSummary(nativePayload, timings),
|
||||
nativeGateTiming,
|
||||
timingAttribution: timingAttributionSummary(timings, nativeGateTiming),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2630,6 +2632,8 @@ function nativeGateTimingSummary(payload: Record<string, unknown> | null, timing
|
||||
statusReadSeconds: secondsFromMsValue(numberOrNull(statusRead?.elapsedMs)),
|
||||
gitMirrorSyncSeconds: secondsFromMsValue(numberOrNull(sourceSync?.elapsedMs)),
|
||||
pipelineRunSeconds: numberOrNull(tekton?.durationSeconds),
|
||||
pipelineRunStartedAt: stringOrNull(tekton?.startTime),
|
||||
pipelineRunFinishedAt: stringOrNull(tekton?.completionTime),
|
||||
argoOperationSeconds: numberOrNull(argo?.operationDurationSeconds),
|
||||
argoOperationStartedAt: stringOrNull(argo?.operationStartedAt),
|
||||
argoOperationFinishedAt: stringOrNull(argo?.operationFinishedAt),
|
||||
@@ -2640,6 +2644,108 @@ 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}`,
|
||||
|
||||
@@ -271,6 +271,7 @@ function timingContextRowsForFollower(item: Record<string, unknown>): unknown[][
|
||||
const stored = asOptionalRecord(context.storedTiming);
|
||||
const liveRefresh = asOptionalRecord(context.liveRefresh);
|
||||
const nativeGate = asOptionalRecord(context.nativeGateTiming);
|
||||
const attribution = asOptionalRecord(context.timingAttribution);
|
||||
const rows: unknown[][] = [];
|
||||
if (stored !== null) {
|
||||
rows.push([
|
||||
@@ -309,6 +310,21 @@ function timingContextRowsForFollower(item: Record<string, unknown>): unknown[][
|
||||
nativeGate.argoIncludedInStoredTotal === true ? "argo" : "no",
|
||||
]);
|
||||
}
|
||||
if (attribution !== null) {
|
||||
const detail = [
|
||||
`known=${formatSeconds(numberOrNull(attribution.knownIntervalCoverageSeconds))}`,
|
||||
`unknown=${formatSeconds(numberOrNull(attribution.unknownWallClockSeconds))}`,
|
||||
].join(" ");
|
||||
rows.push([
|
||||
item.id,
|
||||
"attribution",
|
||||
stringOrNull(attribution.source) ?? "-",
|
||||
detail,
|
||||
stringOrNull(attribution.totalStartedAt) ?? "-",
|
||||
stringOrNull(attribution.totalFinishedAt) ?? "-",
|
||||
stringOrNull(attribution.status) ?? "-",
|
||||
]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user