// SPEC: PJ2026-01060703 CI/CD branch follower reconcile timeline visibility. // Responsibility: bounded controller-loop timing summaries for branch-follower state/status output. export type ReconcileTimelineStep = { follower: string; step: string; status: string; startedAt: string; finishedAt?: string; elapsedMs?: number; observedSha?: string; targetSha?: string; phase?: string; pipelineRun?: string; object?: string; message?: string; reason?: string; exitCode?: number; }; export type ReconcileTimeline = { startedAt: string; finishedAt?: string; elapsedMs?: number; controller: boolean; dryRun: boolean; confirm: boolean; wait: boolean; followerCount: number; followers: string[]; bounded: true; steps: ReconcileTimelineStep[]; }; export type ReconcileStepMarker = { readonly step: ReconcileTimelineStep; readonly startedMs: number; }; export function startReconcileTimeline(input: { controller: boolean; dryRun: boolean; confirm: boolean; wait: boolean; followerIds: string[] }): ReconcileTimeline { return { startedAt: new Date().toISOString(), controller: input.controller, dryRun: input.dryRun, confirm: input.confirm, wait: input.wait, followerCount: input.followerIds.length, followers: input.followerIds.slice(0, 8), bounded: true, steps: [], }; } export function finishReconcileTimeline(timeline: ReconcileTimeline): ReconcileTimeline { const finishedMs = Date.now(); timeline.finishedAt = new Date(finishedMs).toISOString(); timeline.elapsedMs = elapsedMs(timeline.startedAt, finishedMs); timeline.steps = compactSteps(timeline.steps, null, 32); return timeline; } export function startReconcileStep(timeline: ReconcileTimeline, follower: string, step: string): ReconcileStepMarker { const startedMs = Date.now(); const record: ReconcileTimelineStep = { follower: safeText(follower, 80) ?? "-", step: safeText(step, 80) ?? "-", status: "running", startedAt: new Date(startedMs).toISOString(), }; timeline.steps.push(record); return { step: record, startedMs }; } export function finishReconcileStep(marker: ReconcileStepMarker, fields: Record = {}): ReconcileTimelineStep { const finishedMs = Date.now(); marker.step.finishedAt = new Date(finishedMs).toISOString(); marker.step.elapsedMs = Math.max(0, finishedMs - marker.startedMs); marker.step.status = safeText(fields.status, 80) ?? (fields.ok === false ? "failed" : "ok"); setText(marker.step, "observedSha", fields.observedSha, 80); setText(marker.step, "targetSha", fields.targetSha, 80); setText(marker.step, "phase", fields.phase, 80); setText(marker.step, "pipelineRun", fields.pipelineRun, 120); setText(marker.step, "object", fields.object, 120); setText(marker.step, "message", fields.message, 180); setText(marker.step, "reason", fields.reason, 180); const exitCode = numberOrNull(fields.exitCode); if (exitCode !== null) marker.step.exitCode = exitCode; return marker.step; } export function attachReconcileTimeline(command: Record | undefined, timeline: ReconcileTimeline, followerId: string): Record | undefined { const compact = compactReconcileTimeline(timeline, followerId); if (compact === null) return command; return { ...(command ?? {}), reconcileTimeline: compact }; } export function compactReconcileTimeline(value: unknown, followerId?: string | null): Record | null { const source = asRecord(value); if (source === null) return null; const steps = compactSteps(arrayRecords(source.steps), followerId ?? null, followerId === undefined || followerId === null ? 32 : 16); return { startedAt: safeText(source.startedAt, 80), finishedAt: safeText(source.finishedAt, 80), elapsedMs: numberOrNull(source.elapsedMs), controller: source.controller === true, dryRun: source.dryRun === true, confirm: source.confirm === true, wait: source.wait === true, followerCount: numberOrNull(source.followerCount), followers: Array.isArray(source.followers) ? source.followers.map((item) => safeText(item, 80)).filter((item): item is string => item !== null).slice(0, 8) : [], bounded: true, omittedStepCount: Math.max(0, arrayRecords(source.steps).length - steps.length), steps, }; } function compactSteps(values: Record[], followerId: string | null, maxSteps: number): ReconcileTimelineStep[] { const filtered = values .filter((item) => followerId === null || item.follower === "*" || item.follower === followerId) .slice(-maxSteps); return filtered.map((item) => { const step: ReconcileTimelineStep = { follower: safeText(item.follower, 80) ?? "-", step: safeText(item.step, 80) ?? "-", status: safeText(item.status, 80) ?? "-", startedAt: safeText(item.startedAt, 80) ?? "-", }; setText(step, "finishedAt", item.finishedAt, 80); const elapsed = numberOrNull(item.elapsedMs); if (elapsed !== null) step.elapsedMs = elapsed; setText(step, "observedSha", item.observedSha, 80); setText(step, "targetSha", item.targetSha, 80); setText(step, "phase", item.phase, 80); setText(step, "pipelineRun", item.pipelineRun, 120); setText(step, "object", item.object, 120); setText(step, "message", item.message, 180); setText(step, "reason", item.reason, 180); const exitCode = numberOrNull(item.exitCode); if (exitCode !== null) step.exitCode = exitCode; return step; }); } function setText(target: ReconcileTimelineStep, key: keyof ReconcileTimelineStep, value: unknown, maxLength: number): void { const text = safeText(value, maxLength); if (text !== null) (target as Record)[key] = text; } function elapsedMs(startedAt: string, finishedMs: number): number | undefined { const startedMs = Date.parse(startedAt); if (!Number.isFinite(startedMs)) return undefined; return Math.max(0, finishedMs - startedMs); } function asRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } function arrayRecords(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; } function numberOrNull(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } function safeText(value: unknown, maxLength: number): string | null { if (typeof value !== "string" || value.length === 0) return null; const text = value.replace(/\s+/gu, " "); return text.length <= maxLength ? text : `${text.slice(0, Math.max(0, maxLength - 3))}...`; }