Clarify branch follower status timing contexts

This commit is contained in:
Codex
2026-07-04 14:34:20 +00:00
parent 443602e6f2
commit fa9691c437
6 changed files with 204 additions and 7 deletions
+3 -1
View File
@@ -205,6 +205,7 @@ function compactCloseout(closeout) {
function compactNativePayload(payload) {
const value = recordOrNull(payload);
if (value === null) return null;
const refreshEvidence = compactRefreshEvidence(recordOrNull(recordOrNull(value.nativeCapabilities)?.controlPlaneRefresh));
return {
gitMirror: compactGitMirror(value.gitMirror),
reuseConfig: compactReuseConfig(value.reuseConfig),
@@ -214,7 +215,8 @@ function compactNativePayload(payload) {
planArtifacts: compactPlanArtifacts(value.planArtifacts),
argo: compactArgo(value.argo),
runtime: compactRuntime(value.runtime),
refreshEvidence: compactRefreshEvidence(recordOrNull(recordOrNull(value.nativeCapabilities)?.controlPlaneRefresh)),
refreshEvidence,
refreshEvidenceReason: refreshEvidence === null ? "no stored control-plane-refresh capability evidence; status refresh job is reported separately at top-level refresh" : null,
errors: arrayStrings(value.errors).slice(0, 5),
statusAuthority: stringOrNull(value.statusAuthority),
parsedDownstreamCliOutput: false,
+120 -3
View File
@@ -638,17 +638,20 @@ async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOpti
const stored = k8s.stateByFollower[follower.id] ?? {};
const fallbackStored = refresh === null ? {} : beforeRefreshStateByFollower[follower.id] ?? {};
const live = shouldLive && follower.enabled ? await readAdapterStatus(registry, follower, options) : null;
followers.push(mergeFollowerStatus(registry, follower, stored, live, shouldLive, detailedFollowers, fallbackStored));
followers.push(mergeFollowerStatus(registry, follower, stored, live, wantsLive, detailedFollowers, fallbackStored));
}
return {
ok: k8s.ok && followers.every((item) => item.ok !== false),
action: "status",
live: shouldLive,
live: wantsLive,
liveMode: shouldLive ? "in-cluster-adapter-status" : refresh !== null ? "operator-status-refresh-job" : "stored-state",
liveRefresh: liveRefreshSummary(wantsLive, shouldLive, refresh),
registry: registrySummary(registry),
controller: controllerStatusSummary(registry, k8s),
followers,
refresh,
errors: k8s.errors,
warnings: k8s.warnings,
next: {
apply: "bun scripts/cli.ts cicd branch-follower apply --confirm --wait",
liveStatus: "bun scripts/cli.ts cicd branch-follower status --live",
@@ -2001,6 +2004,7 @@ function mergeFollowerStatus(
message: live?.message ?? stringOrNull(stored.decision) ?? "no controller state yet",
timings: detailed ? timings : compactListTimings(timings),
performance: timingPerformanceSummary(timings),
timingContext: statusTimingContext(timings, stored, live, liveRequested, fallbackStored),
evidence: detailed ? evidence : null,
reconcileTimeline: detailed ? reconcileTimeline : null,
rawStateDiagnostic: detailed ? asOptionalRecord(stored.rawStateDiagnostic) : null,
@@ -2021,6 +2025,7 @@ function mergeFollowerStatus(
function readK8sState(registry: BranchFollowerRegistry, options: ParsedOptions): K8sStateRead {
const errors: string[] = [];
const warnings: string[] = [];
const readTimeoutMs = statusReadTimeoutMs(registry, options);
const stateResult = kubeConfigMapFollowerState(registry, options, readTimeoutMs);
const namespace = registry.controller.namespace;
@@ -2031,7 +2036,7 @@ function readK8sState(registry: BranchFollowerRegistry, options: ParsedOptions):
if (!stateResult.ok) errors.push(`state configmap: ${stateResult.error}`);
if (!deploymentResult.ok && !isNotFoundText(deploymentResult.error)) errors.push(`deployment: ${deploymentResult.error}`);
if (!leaseResult.ok && !isNotFoundText(leaseResult.error)) errors.push(`lease: ${leaseResult.error}`);
if (!podsResult.ok && !isNotFoundText(podsResult.error)) errors.push(`pods: ${podsResult.error}`);
if (!podsResult.ok && !isNotFoundText(podsResult.error)) warnings.push(`pods: ${podsResult.error}`);
return {
ok: errors.length === 0,
stateByFollower: stateResult.stateByFollower,
@@ -2042,6 +2047,7 @@ function readK8sState(registry: BranchFollowerRegistry, options: ParsedOptions):
lease: leaseResult.value,
pods: podsResult.value,
errors,
warnings,
};
}
@@ -2440,7 +2446,11 @@ function runControllerReconcileJob(registry: BranchFollowerRegistry, options: Pa
`kubectl apply --server-side --force-conflicts --field-manager=${shQuote(registry.controller.fieldManager)} -f "$tmp" >/dev/null`,
mode.wait ? waitForJobShell(registry.controller.namespace, jobName, timeoutSeconds) : "true",
].join("\n");
const startedMs = Date.now();
const startedAt = new Date(startedMs).toISOString();
const result = runKubeScript(registry, options, script, "", (timeoutSeconds + registry.controller.budgets.reconcileTransportGraceSeconds) * 1000);
const finishedMs = Date.now();
const finishedAt = new Date(finishedMs).toISOString();
return {
ok: result.exitCode === 0,
name: jobName,
@@ -2449,6 +2459,9 @@ function runControllerReconcileJob(registry: BranchFollowerRegistry, options: Pa
execution: "k8s-native-job",
exitCode: result.exitCode,
timedOut: result.timedOut,
startedAt,
finishedAt,
elapsedMs: finishedMs >= startedMs ? finishedMs - startedMs : null,
message: result.exitCode === 0 ? "reconcile job completed" : redactText(tailText(result.stderr || result.stdout, 800)),
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
@@ -2526,6 +2539,8 @@ function controllerStatusSummary(registry: BranchFollowerRegistry, k8s: K8sState
availableReplicas: available,
replicas,
pods: podItems,
podsReadStatus: k8s.pods === null && k8s.warnings.some((item) => item.startsWith("pods: ")) ? "warning" : "ok",
podsReadWarning: k8s.warnings.find((item) => item.startsWith("pods: ")) ?? null,
stateConfigMapName: registry.controller.stateConfigMapName,
stateConfigMapPresent: k8s.stateConfigMapPresent,
leaseName: registry.controller.leaseName,
@@ -2534,6 +2549,97 @@ function controllerStatusSummary(registry: BranchFollowerRegistry, k8s: K8sState
};
}
function liveRefreshSummary(wantsLive: boolean, shouldLive: boolean, refresh: Record<string, unknown> | null): Record<string, unknown> {
if (refresh === null) {
return {
requested: wantsLive,
executed: false,
mode: shouldLive ? "in-cluster-adapter-status" : wantsLive ? "not-created" : "not-requested",
includedInStoredTotal: false,
note: wantsLive ? "in-cluster live status does not create an operator refresh job" : "stored state read only",
};
}
return {
requested: true,
executed: true,
mode: "operator-status-refresh-job",
name: stringOrNull(refresh.name),
namespace: stringOrNull(refresh.namespace),
ok: refresh.ok === true,
exitCode: numberOrNull(refresh.exitCode),
timedOut: refresh.timedOut === true,
startedAt: stringOrNull(refresh.startedAt),
finishedAt: stringOrNull(refresh.finishedAt),
elapsedMs: numberOrNull(refresh.elapsedMs),
stdoutBytes: numberOrNull(refresh.stdoutBytes),
stderrBytes: numberOrNull(refresh.stderrBytes),
includedInStoredTotal: false,
note: "status refresh observes/writes compact state; it is not a triggered run total",
};
}
function statusTimingContext(
timings: FollowerState["timings"],
stored: Record<string, unknown>,
live: AdapterSummary | null,
liveRequested: boolean,
fallbackStored: Record<string, unknown>,
): Record<string, unknown> {
const storedPayload = asOptionalRecord(asOptionalRecord(stored.command)?.payload);
const fallbackPayload = asOptionalRecord(asOptionalRecord(fallbackStored.command)?.payload);
const livePayload = asOptionalRecord(live?.payload);
const nativePayload = firstRecord(livePayload, storedPayload, fallbackPayload);
return {
storedTiming: {
totalSeconds: timings.totalSeconds,
totalStatus: timings.totalStatus,
totalSource: timings.totalSource,
sourceCommit: timings.sourceCommit,
startedAt: timings.startedAt,
finishedAt: timings.finishedAt,
rangeComplete: timings.startedAt !== null && timings.finishedAt !== null,
missingRangeReason: timings.startedAt === null || timings.finishedAt === null ? "historical timing range missing; do not infer old startedAt/finishedAt" : null,
},
liveRefresh: {
requested: liveRequested,
source: liveRequested ? live === null ? "operator-status-refresh-job" : "in-cluster-adapter-status" : "not-requested",
includedInStoredTotal: false,
note: liveRequested ? "live/status refresh is reported separately and must not be added to stored total" : null,
},
nativeGateTiming: nativeGateTimingSummary(nativePayload, timings),
};
}
function nativeGateTimingSummary(payload: Record<string, unknown> | null, timings: FollowerState["timings"]): Record<string, unknown> {
if (payload === null) {
return {
source: "missing",
includedInStoredTotal: false,
reason: "no native payload in stored or live status",
};
}
const statusRead = asOptionalRecord(asOptionalRecord(payload.timings)?.statusRead);
const sourceSync = asOptionalRecord(payload.sourceSync);
const tekton = asOptionalRecord(payload.tekton);
const argo = asOptionalRecord(payload.argo);
const runtime = asOptionalRecord(payload.runtime);
const argoStage = timings.stages.find((stage) => stage.stage === "argo");
return {
source: "native-status-payload",
includedInStoredTotal: false,
statusReadSeconds: secondsFromMsValue(numberOrNull(statusRead?.elapsedMs)),
gitMirrorSyncSeconds: secondsFromMsValue(numberOrNull(sourceSync?.elapsedMs)),
pipelineRunSeconds: numberOrNull(tekton?.durationSeconds),
argoOperationSeconds: numberOrNull(argo?.operationDurationSeconds),
argoOperationStartedAt: stringOrNull(argo?.operationStartedAt),
argoOperationFinishedAt: stringOrNull(argo?.operationFinishedAt),
argoIncludedInStoredTotal: argoStage?.seconds !== null && argoStage?.source === "argocd",
runtimeReady: runtime?.ready === true,
runtimeAligned: runtime?.aligned === true ? true : runtime?.aligned === false ? false : null,
note: "native gate timings are current observations unless their stage explicitly overlaps stored startedAt/finishedAt",
};
}
function followerNextCommands(follower: FollowerSpec): Record<string, string> {
const next: Record<string, string> = {
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,
@@ -2620,6 +2726,17 @@ function numberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function secondsFromMsValue(value: number | null): number | null {
return value === null ? null : Math.round(value / 100) / 10;
}
function firstRecord(...values: Array<Record<string, unknown> | null>): Record<string, unknown> | null {
for (const value of values) {
if (value !== null) return value;
}
return null;
}
function commandCompact(result: CommandResult, options: ParsedOptions): Record<string, unknown> {
return {
argv: result.command,
+1 -1
View File
@@ -44,7 +44,7 @@ export function followerEvidenceSummary(input: {
return {
pipelineRunRefName: pipelineRefName,
pipeline: compactPipelineEvidence(pipeline),
refreshBoundedReason: refresh === null ? "missing-from-live-and-stored-evidence" : null,
refreshBoundedReason: refresh === null ? "missing-control-plane-refresh-capability-evidence; status refresh job is reported separately at top-level refresh/liveRefresh" : null,
refresh: refresh === null
? null
: {
+72 -2
View File
@@ -109,7 +109,10 @@ function renderStatusHuman(payload: Record<string, unknown>, _options: ParsedOpt
});
const next = asOptionalRecord(payload.next);
const errors = Array.isArray(payload.errors) ? payload.errors : [];
const warnings = Array.isArray(payload.warnings) ? payload.warnings : [];
const liveRefreshRows = liveRefreshRowsForPayload(payload);
const timingRows = followers.flatMap(timingRowsForFollower).slice(0, 48);
const timingContextRows = followers.flatMap(timingContextRowsForFollower).slice(0, 48);
const performanceRows = followers.flatMap(performanceRowsForFollower).slice(0, 24);
const evidenceRows = followers.flatMap(evidenceRowsForFollower).slice(0, 48);
const reconcileRows = followers.flatMap(reconcileRowsForFollower).slice(0, 48);
@@ -118,16 +121,19 @@ function renderStatusHuman(payload: Record<string, unknown>, _options: ParsedOpt
`CI/CD BRANCH-FOLLOWER STATUS (${payload.ok === false ? "degraded" : "ok"})`,
"",
table(
["CTRL_NS", "ROUTE", "DEPLOY", "READY", "PODS", "STATE_CM", "LEASE"],
[[controller?.namespace ?? "-", controller?.route ?? "-", controller?.deploymentName ?? "-", `${controller?.availableReplicas ?? 0}/${controller?.replicas ?? 0}`, controller?.pods ?? "-", controller?.stateConfigMapPresent === true ? "present" : "missing", controller?.leaseHolder ?? "-"]],
["CTRL_NS", "ROUTE", "DEPLOY", "READY", "PODS", "PODS_READ", "STATE_CM", "LEASE"],
[[controller?.namespace ?? "-", controller?.route ?? "-", controller?.deploymentName ?? "-", `${controller?.availableReplicas ?? 0}/${controller?.replicas ?? 0}`, controller?.pods ?? "-", controller?.podsReadStatus ?? "-", controller?.stateConfigMapPresent === true ? "present" : "missing", controller?.leaseHolder ?? "-"]],
),
liveRefreshRows.length === 0 ? "" : `\nLIVE REFRESH\n${table(["MODE", "REQUESTED", "EXECUTED", "JOB", "ELAPSED", "IN_TOTAL"], liveRefreshRows)}`,
"",
table(["FOLLOWER", "PHASE", "ADAPTER", "OBSERVED", "TARGET", "TRIGGERED", "SUCCEEDED", "IN_FLIGHT", "BUDGET", "MESSAGE"], rows),
timingRows.length === 0 ? "" : `\nSTAGE TIMINGS\n${table(["FOLLOWER", "STAGE", "STATUS", "SECONDS", "BUDGET", "OBJECT"], timingRows)}`,
timingContextRows.length === 0 ? "" : `\nTIMING CONTEXT\n${table(["FOLLOWER", "CONTEXT", "SOURCE", "SECONDS", "STARTED", "FINISHED", "IN_TOTAL"], timingContextRows)}`,
performanceRows.length === 0 ? "" : `\nSLOW STAGES\n${table(["FOLLOWER", "STAGE", "STATUS", "SECONDS", "SOURCE", "OBJECT"], performanceRows)}`,
evidenceRows.length === 0 ? "" : `\nEVIDENCE\n${table(["FOLLOWER", "TYPE", "STATUS", "DETAIL", "OBJECT"], evidenceRows)}`,
reconcileRows.length === 0 ? "" : `\nRECONCILE TIMELINE\n${table(["FOLLOWER", "STEP", "STATUS", "SECONDS", "STARTED", "OBJECT"], reconcileRows)}`,
rawStateRows.length === 0 ? "" : `\nRAW STATE DIAGNOSTIC\n${table(["FOLLOWER", "STATE_BYTES", "COMMAND", "TIMELINE", "STEPS", "TIMELINE_BYTES", "REASON"], rawStateRows)}`,
warnings.length === 0 ? "" : `\nWARNINGS\n${warnings.map((item) => `- ${item}`).join("\n")}`,
errors.length === 0 ? "" : `\nERRORS\n${errors.map((item) => `- ${item}`).join("\n")}`,
"",
"NEXT",
@@ -137,6 +143,19 @@ function renderStatusHuman(payload: Record<string, unknown>, _options: ParsedOpt
].filter((line) => line !== "").join("\n");
}
function liveRefreshRowsForPayload(payload: Record<string, unknown>): unknown[][] {
const refresh = asOptionalRecord(payload.liveRefresh);
if (refresh === null) return [];
return [[
stringOrNull(refresh.mode) ?? stringOrNull(payload.liveMode) ?? "-",
refresh.requested === true ? "yes" : "no",
refresh.executed === true ? "yes" : "no",
stringOrNull(refresh.name) ?? "-",
formatMs(numberOrNull(refresh.elapsedMs)),
refresh.includedInStoredTotal === true ? "yes" : "no",
]];
}
function renderRunOnceHuman(payload: Record<string, unknown>): string {
const followers = arrayRecords(payload.followers);
const stateWrites = arrayRecords(payload.stateWrites);
@@ -246,6 +265,53 @@ function performanceRowsForFollower(item: Record<string, unknown>): unknown[][]
]);
}
function timingContextRowsForFollower(item: Record<string, unknown>): unknown[][] {
const context = asOptionalRecord(item.timingContext);
if (context === null) return [];
const stored = asOptionalRecord(context.storedTiming);
const liveRefresh = asOptionalRecord(context.liveRefresh);
const nativeGate = asOptionalRecord(context.nativeGateTiming);
const rows: unknown[][] = [];
if (stored !== null) {
rows.push([
item.id,
"stored",
stringOrNull(stored.totalSource) ?? "-",
formatSeconds(numberOrNull(stored.totalSeconds)),
stringOrNull(stored.startedAt) ?? "-",
stringOrNull(stored.finishedAt) ?? "-",
"yes",
]);
}
if (liveRefresh !== null) {
rows.push([
item.id,
"live-refresh",
stringOrNull(liveRefresh.source) ?? "-",
"-",
"-",
"-",
liveRefresh.includedInStoredTotal === true ? "yes" : "no",
]);
}
if (nativeGate !== null) {
const detail = [
`pipeline=${formatSeconds(numberOrNull(nativeGate.pipelineRunSeconds))}`,
`argo=${formatSeconds(numberOrNull(nativeGate.argoOperationSeconds))}`,
].join(" ");
rows.push([
item.id,
"native-gates",
stringOrNull(nativeGate.source) ?? "-",
detail,
stringOrNull(nativeGate.argoOperationStartedAt) ?? "-",
stringOrNull(nativeGate.argoOperationFinishedAt) ?? "-",
nativeGate.argoIncludedInStoredTotal === true ? "argo" : "no",
]);
}
return rows;
}
function reconcileRowsFromRunOnce(payload: Record<string, unknown>, followers: Record<string, unknown>[]): unknown[][] {
const timeline = asOptionalRecord(payload.reconcileTimeline);
if (timeline !== null) return reconcileRowsForTimeline(timeline, null);
@@ -345,6 +411,10 @@ function formatSeconds(value: number | null): string {
return value === null ? "-" : `${value}s`;
}
function formatMs(value: number | null): string {
return value === null ? "-" : `${Math.round(value / 100) / 10}s`;
}
function boolMatch(value: unknown): string {
return value === true ? "match" : value === false ? "mismatch" : "-";
}
+7
View File
@@ -89,7 +89,10 @@ export function compactListTimings(timings: FollowerState["timings"]): Record<st
budgetSeconds: timings.budgetSeconds,
totalSeconds: timings.totalSeconds,
totalStatus: timings.totalStatus,
totalSource: timings.totalSource,
sourceCommit: timings.sourceCommit,
startedAt: timings.startedAt,
finishedAt: timings.finishedAt,
overBudget: timings.overBudget,
stages: timings.stages.slice(0, 4).map((stage) => ({
stage: stage.stage,
@@ -114,6 +117,10 @@ export function timingPerformanceSummary(timings: FollowerState["timings"]): Rec
return {
budgetSeconds: timings.budgetSeconds,
totalSeconds: timings.totalSeconds,
totalSource: timings.totalSource,
sourceCommit: timings.sourceCommit,
startedAt: timings.startedAt,
finishedAt: timings.finishedAt,
overBudget: timings.overBudget,
slowestStage: slowStages[0] ?? null,
slowStages,
+1
View File
@@ -344,6 +344,7 @@ export interface K8sStateRead {
lease: Record<string, unknown> | null;
pods: Record<string, unknown> | null;
errors: string[];
warnings: string[];
}
export interface K8sFollowerStateRead {