60 lines
2.8 KiB
TypeScript
60 lines
2.8 KiB
TypeScript
// SPEC: PJ2026-01060703 CI/CD branch follower controller scheduling.
|
|
// Responsibility: keep source discovery and active closeout ahead of unrelated followers.
|
|
|
|
import type { FollowerSpec, FollowerState, ParsedOptions } from "./cicd-types";
|
|
|
|
const ACTIVE_PHASES = new Set(["PendingTrigger", "Triggering", "Superseded"]);
|
|
const CLOSEOUT_PHASES = new Set(["ClosingOut"]);
|
|
const SOURCE_DISCOVERY_PHASES = new Set(["Observed", "Noop", "Succeeded", "Failed", "Blocked", "Skipped"]);
|
|
|
|
export function orderFollowersForControllerCloseout(
|
|
followers: FollowerSpec[],
|
|
stateByFollower: Record<string, Record<string, unknown>>,
|
|
): FollowerSpec[] {
|
|
return followers
|
|
.map((follower, index) => ({ follower, index, priority: followerPriority(stateByFollower[follower.id]) }))
|
|
.sort((left, right) => left.priority - right.priority || left.index - right.index)
|
|
.map((item) => item.follower);
|
|
}
|
|
|
|
export function shouldYieldAfterAutomaticTrigger(options: ParsedOptions, state: FollowerState): boolean {
|
|
if (!options.inCluster || !options.confirm || options.wait || options.dryRun) return false;
|
|
return state.phase === "Triggering" && state.inFlightJob !== null;
|
|
}
|
|
|
|
function followerPriority(state: Record<string, unknown> | undefined): number {
|
|
if (state === undefined) return 1;
|
|
const phase = typeof state.phase === "string" ? state.phase : null;
|
|
if (typeof state.inFlightJob === "string" && state.inFlightJob.trim() !== "") return 0;
|
|
if (phase !== null && ACTIVE_PHASES.has(phase)) return 0;
|
|
if (isSourceDiscoveryCandidate(phase)) return 1;
|
|
if (hasStoredSourceTargetMismatch(state)) return 2;
|
|
if (hasUnfinishedObservedSource(state)) return 2;
|
|
if (phase !== null && CLOSEOUT_PHASES.has(phase)) return 2;
|
|
return 3;
|
|
}
|
|
|
|
function isSourceDiscoveryCandidate(phase: string | null): boolean {
|
|
return phase === null || SOURCE_DISCOVERY_PHASES.has(phase);
|
|
}
|
|
|
|
function hasStoredSourceTargetMismatch(state: Record<string, unknown>): boolean {
|
|
const observedSha = nestedString(state, "source", "observedSha");
|
|
const targetSha = nestedString(state, "target", "targetSha");
|
|
return observedSha !== null && targetSha !== null && observedSha !== targetSha;
|
|
}
|
|
|
|
function hasUnfinishedObservedSource(state: Record<string, unknown>): boolean {
|
|
const observedSha = nestedString(state, "source", "observedSha");
|
|
const lastSucceededSha = typeof state.lastSucceededSha === "string" ? state.lastSucceededSha : null;
|
|
if (observedSha === null) return false;
|
|
return lastSucceededSha === null || lastSucceededSha !== observedSha;
|
|
}
|
|
|
|
function nestedString(source: Record<string, unknown>, parentKey: string, childKey: string): string | null {
|
|
const parent = source[parentKey];
|
|
if (parent === null || typeof parent !== "object" || Array.isArray(parent)) return null;
|
|
const value = (parent as Record<string, unknown>)[childKey];
|
|
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
}
|