Files
pikasTech-unidesk/scripts/src/cicd-reconcile-timeline.ts
T

172 lines
6.7 KiB
TypeScript

// 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<string, unknown> = {}): 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<string, unknown> | undefined, timeline: ReconcileTimeline, followerId: string): Record<string, unknown> | undefined {
const compact = compactReconcileTimeline(timeline, followerId);
if (compact === null) return command;
return { ...(command ?? {}), reconcileTimeline: compact };
}
export function compactReconcileTimeline(value: unknown, followerId?: string | null): Record<string, unknown> | 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<string, unknown>[], 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<string, unknown>)[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<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function arrayRecords(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => 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))}...`;
}