Files
fusion/packages/dashboard/app/utils/worktreeGrouping.ts
Fusion 995165ea60 feat(FN-2944): merge fusion/fn-2944
- test(FN-2944): cover already checked out worktree conflict recovery
- fix(FN-2944): recognize git already checked out worktree conflict
- fix(engine): auto-recover from squash-merge orphan rebase failures

Fusion-Task-Id: FN-2944
2026-04-29 07:10:52 -07:00

125 lines
3.7 KiB
TypeScript

import type { Task } from "@fusion/core";
import { getPathBasename } from "./pathDisplay";
export interface WorktreeGroupData {
label: string;
activeTasks: Task[];
queuedTasks: Task[];
}
/**
* Extract a clean display name from a worktree path.
* e.g. ".worktrees/FN-001" → "FN-001", "/path/to/fn/fn-001" → "fn-001"
*/
export function getWorktreeLabel(worktreePath: string): string {
return getPathBasename(worktreePath) || worktreePath;
}
/**
* Topological sort of tasks by dependency order.
* Mirrors resolveDependencyOrder from @fusion/core but inlined to avoid
* build alias issues (Vite aliases @fusion/core to types.ts only).
*/
function resolveDependencyOrder(tasks: Task[]): string[] {
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const ordered: string[] = [];
const visited = new Set<string>();
const visiting = new Set<string>();
function visit(id: string): void {
if (visited.has(id)) return;
if (visiting.has(id)) return;
visiting.add(id);
const task = taskMap.get(id);
if (task) {
for (const depId of task.dependencies || []) {
if (taskMap.has(depId)) visit(depId);
}
}
visiting.delete(id);
visited.add(id);
ordered.push(id);
}
for (const task of tasks) visit(task.id);
return ordered;
}
/**
* Group in-progress tasks by worktree and collect queued todo tasks
* as visual previews in the "Up Next" group.
*
* Queued tasks (eligible "todo" tasks whose dependencies are all satisfied)
* are always placed in the "Up Next" group — they are never distributed
* to worktree-specific groups since they have no worktree assignment yet.
* The number of queued tasks shown is capped at `maxConcurrent`.
*/
export function groupByWorktree(
inProgressTasks: Task[],
allTasks: Task[],
maxConcurrent: number,
): WorktreeGroupData[] {
// Separate assigned vs unassigned in-progress tasks
const assigned = inProgressTasks.filter((t) => t.worktree);
const unassigned = inProgressTasks.filter((t) => !t.worktree);
// Group assigned tasks by worktree
const worktreeMap = new Map<string, Task[]>();
for (const task of assigned) {
const key = task.worktree!;
const list = worktreeMap.get(key) || [];
list.push(task);
worktreeMap.set(key, list);
}
// Find queued todo tasks: "todo" tasks with all deps satisfied (done or in-review)
const taskById = new Map(allTasks.map((t) => [t.id, t]));
const todoTasks = allTasks.filter((t) => t.column === "todo");
const eligible = todoTasks.filter((t) =>
!t.paused &&
(t.dependencies || []).every((depId) => {
const dep = taskById.get(depId);
return dep && (dep.column === "done" || dep.column === "in-review" || dep.column === "archived");
}),
);
// Order eligible tasks by dependency order
const orderedIds = resolveDependencyOrder(eligible);
const orderedEligible = orderedIds
.map((id) => taskById.get(id))
.filter((t): t is Task => t !== undefined && eligible.includes(t));
// Build groups from worktree map
const groups: WorktreeGroupData[] = [];
const worktreeKeys = Array.from(worktreeMap.keys());
for (const key of worktreeKeys) {
groups.push({
label: getWorktreeLabel(key),
activeTasks: worktreeMap.get(key)!,
queuedTasks: [],
});
}
// Add unassigned group if needed
if (unassigned.length > 0) {
groups.push({
label: "Unassigned",
activeTasks: unassigned,
queuedTasks: [],
});
}
// All eligible queued tasks go into the "Up Next" group (capped at maxConcurrent)
const queued = orderedEligible.slice(0, maxConcurrent);
if (queued.length > 0) {
groups.push({
label: "Up Next",
activeTasks: [],
queuedTasks: queued,
});
}
return groups;
}