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 fallbackPayload = asOptionalRecord(asOptionalRecord(fallbackStored.command)?.payload);
|
||||||
const livePayload = asOptionalRecord(live?.payload);
|
const livePayload = asOptionalRecord(live?.payload);
|
||||||
const nativePayload = firstRecord(livePayload, storedPayload, fallbackPayload);
|
const nativePayload = firstRecord(livePayload, storedPayload, fallbackPayload);
|
||||||
|
const nativeGateTiming = nativeGateTimingSummary(nativePayload, timings);
|
||||||
return {
|
return {
|
||||||
storedTiming: {
|
storedTiming: {
|
||||||
totalSeconds: timings.totalSeconds,
|
totalSeconds: timings.totalSeconds,
|
||||||
@@ -2606,7 +2607,8 @@ function statusTimingContext(
|
|||||||
includedInStoredTotal: false,
|
includedInStoredTotal: false,
|
||||||
note: liveRequested ? "live/status refresh is reported separately and must not be added to stored total" : null,
|
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)),
|
statusReadSeconds: secondsFromMsValue(numberOrNull(statusRead?.elapsedMs)),
|
||||||
gitMirrorSyncSeconds: secondsFromMsValue(numberOrNull(sourceSync?.elapsedMs)),
|
gitMirrorSyncSeconds: secondsFromMsValue(numberOrNull(sourceSync?.elapsedMs)),
|
||||||
pipelineRunSeconds: numberOrNull(tekton?.durationSeconds),
|
pipelineRunSeconds: numberOrNull(tekton?.durationSeconds),
|
||||||
|
pipelineRunStartedAt: stringOrNull(tekton?.startTime),
|
||||||
|
pipelineRunFinishedAt: stringOrNull(tekton?.completionTime),
|
||||||
argoOperationSeconds: numberOrNull(argo?.operationDurationSeconds),
|
argoOperationSeconds: numberOrNull(argo?.operationDurationSeconds),
|
||||||
argoOperationStartedAt: stringOrNull(argo?.operationStartedAt),
|
argoOperationStartedAt: stringOrNull(argo?.operationStartedAt),
|
||||||
argoOperationFinishedAt: stringOrNull(argo?.operationFinishedAt),
|
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> {
|
function followerNextCommands(follower: FollowerSpec): Record<string, string> {
|
||||||
const next: Record<string, string> = {
|
const next: Record<string, string> = {
|
||||||
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
|
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 stored = asOptionalRecord(context.storedTiming);
|
||||||
const liveRefresh = asOptionalRecord(context.liveRefresh);
|
const liveRefresh = asOptionalRecord(context.liveRefresh);
|
||||||
const nativeGate = asOptionalRecord(context.nativeGateTiming);
|
const nativeGate = asOptionalRecord(context.nativeGateTiming);
|
||||||
|
const attribution = asOptionalRecord(context.timingAttribution);
|
||||||
const rows: unknown[][] = [];
|
const rows: unknown[][] = [];
|
||||||
if (stored !== null) {
|
if (stored !== null) {
|
||||||
rows.push([
|
rows.push([
|
||||||
@@ -309,6 +310,21 @@ function timingContextRowsForFollower(item: Record<string, unknown>): unknown[][
|
|||||||
nativeGate.argoIncludedInStoredTotal === true ? "argo" : "no",
|
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;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user