Files
Codewalkers/packages/shared/src/utils.ts
Lukas May 47c3162581 feat(shared): Add topological sort and pipeline column grouping for phases
Kahn's algorithm for topological phase sorting by dependency edges with
createdAt tiebreaker. groupPhasesByDependencyLevel computes pipeline
visualization columns. Handles cycles gracefully.
2026-02-09 22:33:34 +01:00

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;
}