Merge pull request #1538 from pikasTech/fix/1534-timing-attribution-visibility

fix: expose branch follower timing attribution gap
This commit is contained in:
Lyon
2026-07-04 23:32:58 +08:00
committed by GitHub
2 changed files with 123 additions and 1 deletions
+107 -1
View File
@@ -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}`,
+16
View File
@@ -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;
}