Kahn's algorithm for topological phase sorting by dependency edges with createdAt tiebreaker. groupPhasesByDependencyLevel computes pipeline visualization columns. Handles cycles gracefully.
198 lines
6.1 KiB
TypeScript
198 lines
6.1 KiB
TypeScript
/**
|
|
* Shared utility functions that can be used across frontend and backend.
|
|
*/
|
|
|
|
export interface SortableItem {
|
|
priority: 'low' | 'medium' | 'high';
|
|
createdAt: Date | string;
|
|
}
|
|
|
|
/**
|
|
* Priority order mapping for sorting (higher number = higher priority)
|
|
*/
|
|
const PRIORITY_ORDER = {
|
|
high: 3,
|
|
medium: 2,
|
|
low: 1,
|
|
} as const;
|
|
|
|
/**
|
|
* Sorts items by priority (high to low) then by queue time (oldest first).
|
|
* This ensures high-priority items come first, but within the same priority,
|
|
* items are processed in FIFO order.
|
|
*/
|
|
export function sortByPriorityAndQueueTime<T extends SortableItem>(items: T[]): T[] {
|
|
return [...items].sort((a, b) => {
|
|
// First sort by priority (high to low)
|
|
const priorityDiff = PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority];
|
|
if (priorityDiff !== 0) {
|
|
return priorityDiff;
|
|
}
|
|
|
|
// Within same priority, sort by creation time (oldest first - FIFO)
|
|
const aTime = typeof a.createdAt === 'string' ? new Date(a.createdAt) : a.createdAt;
|
|
const bTime = typeof b.createdAt === 'string' ? new Date(b.createdAt) : b.createdAt;
|
|
return aTime.getTime() - bTime.getTime();
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Topological Phase Sorting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface PhaseForSort {
|
|
id: string;
|
|
createdAt: Date | string;
|
|
}
|
|
|
|
export interface DependencyEdge {
|
|
phaseId: string;
|
|
dependsOnPhaseId: string;
|
|
}
|
|
|
|
/**
|
|
* Topologically sort phases by their dependency edges (Kahn's algorithm).
|
|
* Phases with no dependencies come first. Deterministic tiebreaker: createdAt ascending.
|
|
* Phases involved in cycles are appended at the end, sorted by createdAt.
|
|
*/
|
|
export function topologicalSortPhases<T extends PhaseForSort>(
|
|
phases: T[],
|
|
edges: DependencyEdge[],
|
|
): T[] {
|
|
if (phases.length === 0) return [];
|
|
|
|
const phaseMap = new Map(phases.map((p) => [p.id, p]));
|
|
const inDegree = new Map<string, number>();
|
|
const adjacency = new Map<string, string[]>(); // dependsOnPhaseId -> phaseIds that depend on it
|
|
|
|
for (const p of phases) {
|
|
inDegree.set(p.id, 0);
|
|
adjacency.set(p.id, []);
|
|
}
|
|
|
|
for (const edge of edges) {
|
|
if (!phaseMap.has(edge.phaseId) || !phaseMap.has(edge.dependsOnPhaseId)) continue;
|
|
inDegree.set(edge.phaseId, (inDegree.get(edge.phaseId) ?? 0) + 1);
|
|
adjacency.get(edge.dependsOnPhaseId)!.push(edge.phaseId);
|
|
}
|
|
|
|
// Seed queue with zero in-degree phases, sorted by createdAt
|
|
const queue = phases
|
|
.filter((p) => (inDegree.get(p.id) ?? 0) === 0)
|
|
.sort((a, b) => toTime(a.createdAt) - toTime(b.createdAt));
|
|
|
|
const result: T[] = [];
|
|
|
|
while (queue.length > 0) {
|
|
// Pick the earliest-created from the queue
|
|
queue.sort((a, b) => toTime(a.createdAt) - toTime(b.createdAt));
|
|
const current = queue.shift()!;
|
|
result.push(current);
|
|
|
|
for (const neighborId of adjacency.get(current.id) ?? []) {
|
|
const newDeg = (inDegree.get(neighborId) ?? 1) - 1;
|
|
inDegree.set(neighborId, newDeg);
|
|
if (newDeg === 0) {
|
|
queue.push(phaseMap.get(neighborId)!);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Append any phases caught in cycles, sorted by createdAt
|
|
if (result.length < phases.length) {
|
|
const inResult = new Set(result.map((p) => p.id));
|
|
const remaining = phases
|
|
.filter((p) => !inResult.has(p.id))
|
|
.sort((a, b) => toTime(a.createdAt) - toTime(b.createdAt));
|
|
result.push(...remaining);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function toTime(d: Date | string): number {
|
|
return typeof d === 'string' ? new Date(d).getTime() : d.getTime();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pipeline Column Grouping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface PipelineColumn<T> {
|
|
depth: number;
|
|
phases: T[];
|
|
}
|
|
|
|
/**
|
|
* Groups phases into pipeline columns by dependency depth.
|
|
* Depth 0 = no dependencies, depth N = longest path from a root is N.
|
|
* Phases within a column are sorted by createdAt ascending.
|
|
* Phases in cycles are placed in the last column + 1.
|
|
*/
|
|
export function groupPhasesByDependencyLevel<T extends PhaseForSort>(
|
|
phases: T[],
|
|
edges: DependencyEdge[],
|
|
): PipelineColumn<T>[] {
|
|
if (phases.length === 0) return [];
|
|
|
|
const phaseIds = new Set(phases.map((p) => p.id));
|
|
|
|
// Build adjacency: for each phase, what does it depend on?
|
|
const dependsOn = new Map<string, string[]>();
|
|
for (const p of phases) dependsOn.set(p.id, []);
|
|
for (const edge of edges) {
|
|
if (!phaseIds.has(edge.phaseId) || !phaseIds.has(edge.dependsOnPhaseId)) continue;
|
|
dependsOn.get(edge.phaseId)!.push(edge.dependsOnPhaseId);
|
|
}
|
|
|
|
// Compute depth via recursive longest path with cycle detection
|
|
const depthCache = new Map<string, number>();
|
|
const visiting = new Set<string>();
|
|
|
|
function computeDepth(id: string): number {
|
|
if (depthCache.has(id)) return depthCache.get(id)!;
|
|
if (visiting.has(id)) return -1; // cycle detected
|
|
|
|
visiting.add(id);
|
|
const deps = dependsOn.get(id) ?? [];
|
|
let maxDepth = 0;
|
|
for (const depId of deps) {
|
|
const d = computeDepth(depId);
|
|
if (d === -1) {
|
|
visiting.delete(id);
|
|
return -1;
|
|
}
|
|
maxDepth = Math.max(maxDepth, d + 1);
|
|
}
|
|
visiting.delete(id);
|
|
depthCache.set(id, maxDepth);
|
|
return maxDepth;
|
|
}
|
|
|
|
// Compute depths
|
|
let maxValidDepth = 0;
|
|
for (const p of phases) {
|
|
const d = computeDepth(p.id);
|
|
if (d >= 0) maxValidDepth = Math.max(maxValidDepth, d);
|
|
}
|
|
|
|
// Group by depth
|
|
const columnMap = new Map<number, T[]>();
|
|
for (const p of phases) {
|
|
const d = depthCache.get(p.id);
|
|
const depth = d != null && d >= 0 ? d : maxValidDepth + 1; // cycles go last
|
|
if (!columnMap.has(depth)) columnMap.set(depth, []);
|
|
columnMap.get(depth)!.push(p);
|
|
}
|
|
|
|
// Sort phases within each column by createdAt
|
|
const columns: PipelineColumn<T>[] = [];
|
|
const sortedDepths = [...columnMap.keys()].sort((a, b) => a - b);
|
|
for (const depth of sortedDepths) {
|
|
const phasesInCol = columnMap.get(depth)!;
|
|
phasesInCol.sort((a, b) => toTime(a.createdAt) - toTime(b.createdAt));
|
|
columns.push({ depth, phases: phasesInCol });
|
|
}
|
|
|
|
return columns;
|
|
} |