172 lines
6.7 KiB
TypeScript
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))}...`;
|
|
}
|