more dependancy parsing support
This commit is contained in:
691
src/dag.ts
691
src/dag.ts
@@ -1,4 +1,10 @@
|
|||||||
import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types";
|
import type {
|
||||||
|
Task,
|
||||||
|
ExecutionBatch,
|
||||||
|
ExecutionPlan,
|
||||||
|
Project,
|
||||||
|
ParallelGroup,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
// ─── Blocked Tasks ───────────────────────────────────────────────────────────
|
// ─── Blocked Tasks ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -7,25 +13,25 @@ import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types";
|
|||||||
* Returns a Set of blocked task IDs.
|
* Returns a Set of blocked task IDs.
|
||||||
*/
|
*/
|
||||||
export function getBlockedTasks(
|
export function getBlockedTasks(
|
||||||
pendingTasks: Task[],
|
pendingTasks: Task[],
|
||||||
failedTaskIds: Set<string>,
|
failedTaskIds: Set<string>,
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
const blocked = new Set<string>();
|
const blocked = new Set<string>();
|
||||||
|
|
||||||
let changed = true;
|
let changed = true;
|
||||||
while (changed) {
|
while (changed) {
|
||||||
changed = false;
|
changed = false;
|
||||||
for (const task of pendingTasks) {
|
for (const task of pendingTasks) {
|
||||||
if (blocked.has(task.id)) continue;
|
if (blocked.has(task.id)) continue;
|
||||||
const deps = task.dependencies || [];
|
const deps = task.dependencies || [];
|
||||||
if (deps.some((dep) => failedTaskIds.has(dep))) {
|
if (deps.some((dep) => failedTaskIds.has(dep))) {
|
||||||
blocked.add(task.id);
|
blocked.add(task.id);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return blocked;
|
return blocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main Entry ──────────────────────────────────────────────────────────────
|
// ─── Main Entry ──────────────────────────────────────────────────────────────
|
||||||
@@ -35,29 +41,44 @@ export function getBlockedTasks(
|
|||||||
* Returns ordered batches of parallelizable tasks.
|
* Returns ordered batches of parallelizable tasks.
|
||||||
*/
|
*/
|
||||||
export function buildExecutionPlan(
|
export function buildExecutionPlan(
|
||||||
project: Project,
|
project: Project,
|
||||||
completed: Set<string>,
|
completed: Set<string>,
|
||||||
parallelGroup?: number,
|
parallelGroup?: number,
|
||||||
failedTaskIds: Set<string> = new Set(),
|
failedTaskIds: Set<string> = new Set(),
|
||||||
): ExecutionPlan {
|
): ExecutionPlan {
|
||||||
// Filter out already completed tasks
|
// Filter out already completed tasks
|
||||||
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
|
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
|
||||||
|
const skippedTasks = project.tasks.filter((t) => completed.has(t.id));
|
||||||
|
|
||||||
// If parallel_group is explicitly set, use group-based batching
|
// With explicitly declared parallel groups, all groups are independent.
|
||||||
if (parallelGroup !== undefined) {
|
// Since there are no cross-group dependencies by definition, standard
|
||||||
return {
|
// Kahn's algorithm produces the correct plan — tasks ready in any group
|
||||||
batches: buildParallelGroupBatches(pendingTasks, failedTaskIds),
|
// appear in the same batch, and intra-group dependencies (e.g. "21 must
|
||||||
totalTasks: pendingTasks.length,
|
// be done before 22, 23, 24") are respected automatically.
|
||||||
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
// The parallel groups are preserved as metadata for display/documentation.
|
||||||
};
|
if (project.parallelGroups && project.parallelGroups.length > 0) {
|
||||||
}
|
return {
|
||||||
|
batches: buildGroupAwareBatches(project, pendingTasks, failedTaskIds),
|
||||||
|
totalTasks: pendingTasks.length,
|
||||||
|
skippedTasks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Use dependency-based Kahn's algorithm
|
// If parallel_group is explicitly set (legacy config flag), use group-based batching
|
||||||
return {
|
if (parallelGroup !== undefined) {
|
||||||
batches: buildBatches(pendingTasks, failedTaskIds),
|
return {
|
||||||
totalTasks: pendingTasks.length,
|
batches: buildParallelGroupBatchesLegacy(pendingTasks, failedTaskIds),
|
||||||
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
totalTasks: pendingTasks.length,
|
||||||
};
|
skippedTasks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use dependency-based Kahn's algorithm
|
||||||
|
return {
|
||||||
|
batches: buildBatches(pendingTasks, failedTaskIds),
|
||||||
|
totalTasks: pendingTasks.length,
|
||||||
|
skippedTasks,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sequential Plan ─────────────────────────────────────────────────────────
|
// ─── Sequential Plan ─────────────────────────────────────────────────────────
|
||||||
@@ -66,106 +87,165 @@ export function buildExecutionPlan(
|
|||||||
* Build a sequential execution plan (one task per batch)
|
* Build a sequential execution plan (one task per batch)
|
||||||
*/
|
*/
|
||||||
export function buildSequentialPlan(
|
export function buildSequentialPlan(
|
||||||
project: Project,
|
project: Project,
|
||||||
completed: Set<string>,
|
completed: Set<string>,
|
||||||
failedTaskIds: Set<string> = new Set(),
|
failedTaskIds: Set<string> = new Set(),
|
||||||
): ExecutionPlan {
|
): ExecutionPlan {
|
||||||
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
|
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
|
||||||
|
|
||||||
// Mark tasks with failed dependencies as skipped
|
// Mark tasks with failed dependencies as skipped
|
||||||
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||||
const skippedTasks = project.tasks.filter(
|
const skippedTasks = project.tasks.filter(
|
||||||
(t) => completed.has(t.id) || blocked.has(t.id),
|
(t) => completed.has(t.id) || blocked.has(t.id),
|
||||||
);
|
);
|
||||||
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
||||||
|
|
||||||
const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({
|
const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({
|
||||||
tasks: [task],
|
tasks: [task],
|
||||||
batchIndex: i,
|
batchIndex: i,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
batches,
|
batches,
|
||||||
totalTasks: pendingTasks.length,
|
totalTasks: pendingTasks.length,
|
||||||
skippedTasks,
|
skippedTasks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Kahn's Algorithm (Dependency-Based Batching) ────────────────────────────
|
// ─── Kahn's Algorithm (Dependency-Based Batching) ────────────────────────────
|
||||||
|
|
||||||
function buildBatches(
|
function buildBatches(
|
||||||
pendingTasks: Task[],
|
pendingTasks: Task[],
|
||||||
failedTaskIds: Set<string>,
|
failedTaskIds: Set<string>,
|
||||||
): ExecutionBatch[] {
|
): ExecutionBatch[] {
|
||||||
const batches: ExecutionBatch[] = [];
|
const batches: ExecutionBatch[] = [];
|
||||||
const done = new Set<string>();
|
const done = new Set<string>();
|
||||||
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||||
const pendingSet = new Set(pendingTasks.map((t) => t.id));
|
const pendingSet = new Set(pendingTasks.map((t) => t.id));
|
||||||
const remaining = new Set(
|
const remaining = new Set(
|
||||||
pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id),
|
pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
while (remaining.size > 0) {
|
while (remaining.size > 0) {
|
||||||
// Find tasks whose dependencies are all satisfied
|
// Find tasks whose dependencies are all satisfied
|
||||||
const ready: Task[] = [];
|
const ready: Task[] = [];
|
||||||
for (const task of pendingTasks) {
|
for (const task of pendingTasks) {
|
||||||
if (!remaining.has(task.id)) continue;
|
if (!remaining.has(task.id)) continue;
|
||||||
|
|
||||||
const deps = task.dependencies || [];
|
const deps = task.dependencies || [];
|
||||||
const depsSatisfied = deps.every(
|
const depsSatisfied = deps.every(
|
||||||
(dep) => done.has(dep) || !pendingSet.has(dep),
|
(dep) => done.has(dep) || !pendingSet.has(dep),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (depsSatisfied) {
|
if (depsSatisfied) {
|
||||||
ready.push(task);
|
ready.push(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cycle detection: no tasks ready but some remain
|
// Cycle detection: no tasks ready but some remain
|
||||||
if (ready.length === 0) {
|
if (ready.length === 0) {
|
||||||
const cycleTasks = Array.from(remaining);
|
const cycleTasks = Array.from(remaining);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`,
|
`Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
batches.push({ tasks: ready, batchIndex: batches.length });
|
batches.push({ tasks: ready, batchIndex: batches.length });
|
||||||
for (const task of ready) {
|
for (const task of ready) {
|
||||||
done.add(task.id);
|
done.add(task.id);
|
||||||
remaining.delete(task.id);
|
remaining.delete(task.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return batches;
|
return batches;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Parallel Group Batching ─────────────────────────────────────────────────
|
// ─── Group-Aware Batching ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build batches from explicit parallel_group values.
|
* Build batches respecting both explicit parallel groups and intra-group
|
||||||
* Groups execute in ascending order; tasks within a group run concurrently.
|
* dependencies. Since parallel group declarations imply no cross-group
|
||||||
|
* dependencies, all tasks whose dependencies are satisfied — across any
|
||||||
|
* group — can run concurrently in the same batch. This means groups
|
||||||
|
* "proceed independently" as the user specified: tasks from different
|
||||||
|
* groups can appear in the same batch when ready.
|
||||||
|
*
|
||||||
|
* Intra-group dependencies (e.g., "21 must be done before 22, 23, 24")
|
||||||
|
* are handled by Kahn's algorithm: if 21 has deps satisfied but 22 doesn't,
|
||||||
|
* only 21 appears in the current batch.
|
||||||
*/
|
*/
|
||||||
function buildParallelGroupBatches(
|
function buildGroupAwareBatches(
|
||||||
pendingTasks: Task[],
|
_project: Project,
|
||||||
failedTaskIds: Set<string>,
|
pendingTasks: Task[],
|
||||||
|
failedTaskIds: Set<string>,
|
||||||
): ExecutionBatch[] {
|
): ExecutionBatch[] {
|
||||||
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||||
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
||||||
|
|
||||||
const groups = new Map<number, Task[]>();
|
// Standard Kahn's algorithm across ALL tasks — parallel groups are
|
||||||
|
// metadata for display, not scheduling constraints.
|
||||||
|
const pendingSet = new Set(pendingTasks.map((t) => t.id));
|
||||||
|
const done = new Set<string>();
|
||||||
|
const remaining = new Set(activeTasks.map((t) => t.id));
|
||||||
|
const batches: ExecutionBatch[] = [];
|
||||||
|
|
||||||
for (const task of activeTasks) {
|
while (remaining.size > 0) {
|
||||||
const group = task.parallelGroup ?? 0;
|
const ready: Task[] = [];
|
||||||
if (!groups.has(group)) groups.set(group, []);
|
for (const task of activeTasks) {
|
||||||
groups.get(group)!.push(task);
|
if (!remaining.has(task.id)) continue;
|
||||||
}
|
const deps = task.dependencies || [];
|
||||||
|
const depsSatisfied = deps.every(
|
||||||
|
(dep) => done.has(dep) || !pendingSet.has(dep),
|
||||||
|
);
|
||||||
|
if (depsSatisfied) {
|
||||||
|
ready.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]);
|
if (ready.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Dependency cycle detected: ${Array.from(remaining).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return sortedGroups.map(([_groupNum, tasks], i) => ({
|
batches.push({ tasks: ready, batchIndex: batches.length });
|
||||||
tasks,
|
for (const task of ready) {
|
||||||
batchIndex: i,
|
done.add(task.id);
|
||||||
}));
|
remaining.delete(task.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Legacy Parallel Group Batching ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy: build batches from explicit parallel_group values only.
|
||||||
|
* Groups execute in ascending order; tasks within a group run concurrently.
|
||||||
|
* Does NOT respect intra-group dependencies.
|
||||||
|
*/
|
||||||
|
function buildParallelGroupBatchesLegacy(
|
||||||
|
pendingTasks: Task[],
|
||||||
|
failedTaskIds: Set<string>,
|
||||||
|
): ExecutionBatch[] {
|
||||||
|
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||||
|
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
||||||
|
|
||||||
|
const groups = new Map<number, Task[]>();
|
||||||
|
|
||||||
|
for (const task of activeTasks) {
|
||||||
|
const group = task.parallelGroup ?? 0;
|
||||||
|
if (!groups.has(group)) groups.set(group, []);
|
||||||
|
groups.get(group)!.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]);
|
||||||
|
|
||||||
|
return sortedGroups.map(([_groupNum, tasks], i) => ({
|
||||||
|
tasks,
|
||||||
|
batchIndex: i,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Cycle Detection ─────────────────────────────────────────────────────────
|
// ─── Cycle Detection ─────────────────────────────────────────────────────────
|
||||||
@@ -174,51 +254,51 @@ function buildParallelGroupBatches(
|
|||||||
* Detect cycles in the task dependency graph
|
* Detect cycles in the task dependency graph
|
||||||
*/
|
*/
|
||||||
export function detectCycles(project: Project): string[] {
|
export function detectCycles(project: Project): string[] {
|
||||||
const adj = new Map<string, string[]>();
|
const adj = new Map<string, string[]>();
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
adj.set(task.id, task.dependencies || []);
|
adj.set(task.id, task.dependencies || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
const WHITE = 0;
|
const WHITE = 0;
|
||||||
const GRAY = 1;
|
const GRAY = 1;
|
||||||
const BLACK = 2;
|
const BLACK = 2;
|
||||||
const color = new Map<string, number>();
|
const color = new Map<string, number>();
|
||||||
|
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
color.set(task.id, WHITE);
|
color.set(task.id, WHITE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cycleNodes: string[] = [];
|
const cycleNodes: string[] = [];
|
||||||
|
|
||||||
function dfs(node: string): boolean {
|
function dfs(node: string): boolean {
|
||||||
color.set(node, GRAY);
|
color.set(node, GRAY);
|
||||||
const deps = adj.get(node) || [];
|
const deps = adj.get(node) || [];
|
||||||
|
|
||||||
for (const dep of deps) {
|
for (const dep of deps) {
|
||||||
if (!adj.has(dep)) continue;
|
if (!adj.has(dep)) continue;
|
||||||
const depColor = color.get(dep);
|
const depColor = color.get(dep);
|
||||||
|
|
||||||
if (depColor === GRAY) {
|
if (depColor === GRAY) {
|
||||||
cycleNodes.push(dep);
|
cycleNodes.push(dep);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (depColor === WHITE && dfs(dep)) {
|
if (depColor === WHITE && dfs(dep)) {
|
||||||
cycleNodes.push(node);
|
cycleNodes.push(node);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
color.set(node, BLACK);
|
color.set(node, BLACK);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
if (color.get(task.id) === WHITE) {
|
if (color.get(task.id) === WHITE) {
|
||||||
dfs(task.id);
|
dfs(task.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...new Set(cycleNodes)];
|
return [...new Set(cycleNodes)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Ready Tasks ─────────────────────────────────────────────────────────────
|
// ─── Ready Tasks ─────────────────────────────────────────────────────────────
|
||||||
@@ -227,14 +307,14 @@ export function detectCycles(project: Project): string[] {
|
|||||||
* Get tasks that are ready to execute (all dependencies completed)
|
* Get tasks that are ready to execute (all dependencies completed)
|
||||||
*/
|
*/
|
||||||
export function getReadyTasks(
|
export function getReadyTasks(
|
||||||
project: Project,
|
project: Project,
|
||||||
completed: Set<string>,
|
completed: Set<string>,
|
||||||
): Task[] {
|
): Task[] {
|
||||||
return project.tasks.filter((task) => {
|
return project.tasks.filter((task) => {
|
||||||
if (completed.has(task.id)) return false;
|
if (completed.has(task.id)) return false;
|
||||||
const deps = task.dependencies || [];
|
const deps = task.dependencies || [];
|
||||||
return deps.every((dep) => completed.has(dep));
|
return deps.every((dep) => completed.has(dep));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Critical Path ───────────────────────────────────────────────────────────
|
// ─── Critical Path ───────────────────────────────────────────────────────────
|
||||||
@@ -243,70 +323,70 @@ export function getReadyTasks(
|
|||||||
* Calculate the critical path (longest path through the DAG)
|
* Calculate the critical path (longest path through the DAG)
|
||||||
*/
|
*/
|
||||||
export function getCriticalPath(project: Project): Task[] {
|
export function getCriticalPath(project: Project): Task[] {
|
||||||
const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
|
const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
|
||||||
const dist = new Map<string, number>();
|
const dist = new Map<string, number>();
|
||||||
const prev = new Map<string, string | null>();
|
const prev = new Map<string, string | null>();
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
dist.set(task.id, 1);
|
dist.set(task.id, 1);
|
||||||
prev.set(task.id, null);
|
prev.set(task.id, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Topological sort
|
// Topological sort
|
||||||
const sorted: Task[] = [];
|
const sorted: Task[] = [];
|
||||||
const visited = new Set<string>();
|
const visited = new Set<string>();
|
||||||
|
|
||||||
function visit(id: string) {
|
function visit(id: string) {
|
||||||
if (visited.has(id)) return;
|
if (visited.has(id)) return;
|
||||||
visited.add(id);
|
visited.add(id);
|
||||||
const task = taskMap.get(id);
|
const task = taskMap.get(id);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|
||||||
for (const dep of task.dependencies || []) {
|
for (const dep of task.dependencies || []) {
|
||||||
visit(dep);
|
visit(dep);
|
||||||
}
|
}
|
||||||
sorted.push(task);
|
sorted.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
visit(task.id);
|
visit(task.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relax edges
|
// Relax edges
|
||||||
for (const task of sorted) {
|
for (const task of sorted) {
|
||||||
for (const dep of task.dependencies || []) {
|
for (const dep of task.dependencies || []) {
|
||||||
const depDist = dist.get(dep);
|
const depDist = dist.get(dep);
|
||||||
if (depDist === undefined) continue;
|
if (depDist === undefined) continue;
|
||||||
|
|
||||||
const newDist = depDist + 1;
|
const newDist = depDist + 1;
|
||||||
const currentDist = dist.get(task.id) ?? 0;
|
const currentDist = dist.get(task.id) ?? 0;
|
||||||
if (newDist > currentDist) {
|
if (newDist > currentDist) {
|
||||||
dist.set(task.id, newDist);
|
dist.set(task.id, newDist);
|
||||||
prev.set(task.id, dep);
|
prev.set(task.id, dep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trace back from the longest path end
|
// Trace back from the longest path end
|
||||||
let maxTask = project.tasks[0];
|
let maxTask = project.tasks[0];
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
const taskDist = dist.get(task.id) ?? 0;
|
const taskDist = dist.get(task.id) ?? 0;
|
||||||
const maxDist = dist.get(maxTask.id) ?? 0;
|
const maxDist = dist.get(maxTask.id) ?? 0;
|
||||||
if (taskDist > maxDist) {
|
if (taskDist > maxDist) {
|
||||||
maxTask = task;
|
maxTask = task;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const path: Task[] = [];
|
const path: Task[] = [];
|
||||||
let current: string | null = maxTask.id;
|
let current: string | null = maxTask.id;
|
||||||
while (current) {
|
while (current) {
|
||||||
const task = taskMap.get(current);
|
const task = taskMap.get(current);
|
||||||
if (task) path.unshift(task);
|
if (task) path.unshift(task);
|
||||||
current = prev.get(current) || null;
|
current = prev.get(current) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Format Dependency Chain ─────────────────────────────────────────────────
|
// ─── Format Dependency Chain ─────────────────────────────────────────────────
|
||||||
@@ -316,86 +396,86 @@ export function getCriticalPath(project: Project): Task[] {
|
|||||||
* Rooted at tasks with no dependencies, showing what depends on what.
|
* Rooted at tasks with no dependencies, showing what depends on what.
|
||||||
*/
|
*/
|
||||||
export function formatDependencyChain(project: Project): string {
|
export function formatDependencyChain(project: Project): string {
|
||||||
const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
|
const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
lines.push("## Dependency Chain");
|
lines.push("## Dependency Chain");
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
if (project.tasks.length === 0) {
|
if (project.tasks.length === 0) {
|
||||||
lines.push("(no tasks)");
|
lines.push("(no tasks)");
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build reverse dependency map: taskId → [dependent taskIds]
|
// Build reverse dependency map: taskId → [dependent taskIds]
|
||||||
const dependents = new Map<string, string[]>();
|
const dependents = new Map<string, string[]>();
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
dependents.set(task.id, []);
|
dependents.set(task.id, []);
|
||||||
}
|
}
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
for (const dep of task.dependencies) {
|
for (const dep of task.dependencies) {
|
||||||
if (dependents.has(dep)) {
|
if (dependents.has(dep)) {
|
||||||
dependents.get(dep)!.push(task.id);
|
dependents.get(dep)!.push(task.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Root tasks: those with no dependencies
|
// Root tasks: those with no dependencies
|
||||||
const roots = project.tasks.filter((t) => t.dependencies.length === 0);
|
const roots = project.tasks.filter((t) => t.dependencies.length === 0);
|
||||||
const rendered = new Set<string>();
|
const rendered = new Set<string>();
|
||||||
|
|
||||||
function renderNode(taskId: string, prefix: string, isLast: boolean): void {
|
function renderNode(taskId: string, prefix: string, isLast: boolean): void {
|
||||||
const task = taskMap.get(taskId);
|
const task = taskMap.get(taskId);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|
||||||
const alreadyRendered = rendered.has(taskId);
|
const alreadyRendered = rendered.has(taskId);
|
||||||
rendered.add(taskId);
|
rendered.add(taskId);
|
||||||
|
|
||||||
const connector = prefix ? (isLast ? "└── " : "├── ") : "";
|
const connector = prefix ? (isLast ? "└── " : "├── ") : "";
|
||||||
|
|
||||||
if (alreadyRendered) {
|
if (alreadyRendered) {
|
||||||
lines.push(`${prefix}${connector}${task.id} · ${task.title}`);
|
lines.push(`${prefix}${connector}${task.id} · ${task.title}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deps =
|
const deps =
|
||||||
task.dependencies.length > 0
|
task.dependencies.length > 0
|
||||||
? ` ← needs ${task.dependencies.join(", ")}`
|
? ` ← needs ${task.dependencies.join(", ")}`
|
||||||
: " (root)";
|
: " (root)";
|
||||||
|
|
||||||
lines.push(
|
lines.push(
|
||||||
`${prefix}${connector}${task.id} · ${task.title}${prefix ? "" : deps}`,
|
`${prefix}${connector}${task.id} · ${task.title}${prefix ? "" : deps}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const children = (dependents.get(taskId) || [])
|
const children = (dependents.get(taskId) || [])
|
||||||
.filter((c) => c !== taskId)
|
.filter((c) => c !== taskId)
|
||||||
.sort();
|
.sort();
|
||||||
|
|
||||||
for (let i = 0; i < children.length; i++) {
|
for (let i = 0; i < children.length; i++) {
|
||||||
const childPrefix = prefix + (isLast ? " " : "│ ");
|
const childPrefix = prefix + (isLast ? " " : "│ ");
|
||||||
renderNode(children[i], childPrefix, i === children.length - 1);
|
renderNode(children[i], childPrefix, i === children.length - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < roots.length; i++) {
|
for (let i = 0; i < roots.length; i++) {
|
||||||
renderNode(roots[i].id, "", i === roots.length - 1);
|
renderNode(roots[i].id, "", i === roots.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tasks not reached from any root (have deps but no root-traversable path)
|
// Tasks not reached from any root (have deps but no root-traversable path)
|
||||||
const unreached = project.tasks.filter((t) => !rendered.has(t.id));
|
const unreached = project.tasks.filter((t) => !rendered.has(t.id));
|
||||||
if (unreached.length > 0) {
|
if (unreached.length > 0) {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("Orphan tasks (dependencies not in task list):");
|
lines.push("Orphan tasks (dependencies not in task list):");
|
||||||
for (const t of unreached) {
|
for (const t of unreached) {
|
||||||
const deps =
|
const deps =
|
||||||
t.dependencies.length > 0
|
t.dependencies.length > 0
|
||||||
? ` ← needs ${t.dependencies.join(", ")}`
|
? ` ← needs ${t.dependencies.join(", ")}`
|
||||||
: "";
|
: "";
|
||||||
lines.push(` ${t.id} · ${t.title}${deps}`);
|
lines.push(` ${t.id} · ${t.title}${deps}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Format Execution Plan ───────────────────────────────────────────────────
|
// ─── Format Execution Plan ───────────────────────────────────────────────────
|
||||||
@@ -403,27 +483,52 @@ export function formatDependencyChain(project: Project): string {
|
|||||||
/**
|
/**
|
||||||
* Format the execution plan for display
|
* Format the execution plan for display
|
||||||
*/
|
*/
|
||||||
export function formatExecutionPlan(plan: ExecutionPlan): string {
|
/**
|
||||||
const lines: string[] = [];
|
* Format the execution plan for display, optionally with parallel group annotations
|
||||||
lines.push("## Execution Plan");
|
*/
|
||||||
lines.push("");
|
export function formatExecutionPlan(
|
||||||
lines.push(`Total tasks: ${plan.totalTasks}`);
|
plan: ExecutionPlan,
|
||||||
lines.push(`Batches: ${plan.batches.length}`);
|
parallelGroups?: ParallelGroup[],
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("## Execution Plan");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Total tasks: ${plan.totalTasks}`);
|
||||||
|
lines.push(`Batches: ${plan.batches.length}`);
|
||||||
|
|
||||||
if (plan.skippedTasks.length > 0) {
|
// Build a lookup: taskId → group label
|
||||||
lines.push(
|
const groupLabel = new Map<string, string>();
|
||||||
`Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`,
|
if (parallelGroups) {
|
||||||
);
|
for (const g of parallelGroups) {
|
||||||
}
|
for (const id of g.taskIds) {
|
||||||
lines.push("");
|
if (g.label) {
|
||||||
|
groupLabel.set(id, g.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const batch of plan.batches) {
|
if (plan.skippedTasks.length > 0) {
|
||||||
lines.push(`### Batch ${batch.batchIndex + 1}`);
|
lines.push(
|
||||||
for (const task of batch.tasks) {
|
`Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`,
|
||||||
lines.push(`- ${task.id}: ${task.title}`);
|
);
|
||||||
}
|
}
|
||||||
lines.push("");
|
lines.push("");
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
for (const batch of plan.batches) {
|
||||||
|
lines.push(`### Batch ${batch.batchIndex + 1}`);
|
||||||
|
for (const task of batch.tasks) {
|
||||||
|
const annotation = groupLabel.has(task.id)
|
||||||
|
? ` _(${groupLabel.get(task.id)})_`
|
||||||
|
: "";
|
||||||
|
const deps =
|
||||||
|
task.dependencies.length > 0
|
||||||
|
? ` ← needs ${task.dependencies.join(", ")}`
|
||||||
|
: "";
|
||||||
|
lines.push(`- ${task.id}: ${task.title}${annotation}${deps}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
108
src/parser.ts
108
src/parser.ts
@@ -1,6 +1,6 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { Task, Project } from "./types";
|
import type { Task, Project, ParallelGroup } from "./types";
|
||||||
|
|
||||||
// Lazy-loaded yaml package
|
// Lazy-loaded yaml package
|
||||||
let YAML_module: typeof import("yaml") | undefined;
|
let YAML_module: typeof import("yaml") | undefined;
|
||||||
@@ -56,6 +56,7 @@ function parseFioFormat(
|
|||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
const tasks: Task[] = [];
|
const tasks: Task[] = [];
|
||||||
const dependencies: Record<string, string[]> = {};
|
const dependencies: Record<string, string[]> = {};
|
||||||
|
const parallelGroups: ParallelGroup[] = [];
|
||||||
let inTasks = false;
|
let inTasks = false;
|
||||||
let inDeps = false;
|
let inDeps = false;
|
||||||
|
|
||||||
@@ -151,8 +152,9 @@ function parseFioFormat(
|
|||||||
|
|
||||||
// Format 1: Natural language "X depends on A, B, C"
|
// Format 1: Natural language "X depends on A, B, C"
|
||||||
// Supports optional markdown list prefix: "- 13 depends on 17, 18, 19"
|
// Supports optional markdown list prefix: "- 13 depends on 17, 18, 19"
|
||||||
|
// Also handles "also depends on": "- 08 also depends on 05, 06"
|
||||||
const dependsMatch = line.match(
|
const dependsMatch = line.match(
|
||||||
/^(?:\s*[-*]\s+)?(\d+)\s+depends\s+on\s+([\d,\s]+)/i,
|
/^(?:\s*[-*]\s+)?(\d+)\s+(?:also\s+)?depends\s+on\s+([\d,\s]+)/i,
|
||||||
);
|
);
|
||||||
if (dependsMatch) {
|
if (dependsMatch) {
|
||||||
const [, taskId, depsList] = dependsMatch;
|
const [, taskId, depsList] = dependsMatch;
|
||||||
@@ -164,7 +166,11 @@ function parseFioFormat(
|
|||||||
.map((t) => t.padStart(2, "0"));
|
.map((t) => t.padStart(2, "0"));
|
||||||
|
|
||||||
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
|
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
|
||||||
dependencies[taskIdPadded].push(...depIds);
|
for (const depId of depIds) {
|
||||||
|
if (!dependencies[taskIdPadded].includes(depId)) {
|
||||||
|
dependencies[taskIdPadded].push(depId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse meta blocks for task configuration (timeout, etc.)
|
// Parse meta blocks for task configuration (timeout, etc.)
|
||||||
@@ -178,6 +184,91 @@ function parseFioFormat(
|
|||||||
task.timeoutMs = parseTimeoutValue(Number(value), unit);
|
task.timeoutMs = parseTimeoutValue(Number(value), unit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format 2: "X, Y, Z can be done in parallel (label)"
|
||||||
|
// "- 01, 02, 03, 04 can be done in parallel (Play Store prep)"
|
||||||
|
const parallelMatch = line.match(
|
||||||
|
/^(?:\s*[-*]\s+)?((?:0?\d+\s*,\s*)*0?\d+)\s+can\s+be\s+done\s+in\s+parallel(?:\s+\(([^)]+)\))?$/i,
|
||||||
|
);
|
||||||
|
if (parallelMatch) {
|
||||||
|
const [, idsStr, label] = parallelMatch;
|
||||||
|
const taskIds = idsStr
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => /^\d+$/.test(t))
|
||||||
|
.map((t) => t.padStart(2, "0"));
|
||||||
|
|
||||||
|
if (taskIds.length > 0) {
|
||||||
|
parallelGroups.push({
|
||||||
|
index: parallelGroups.length,
|
||||||
|
label: label ? label.trim() : undefined,
|
||||||
|
taskIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format 3: "A must be done before B, C" or "A, B must be done before C"
|
||||||
|
// "- 21 must be done before 22, 23, 24 (backend integration foundation)"
|
||||||
|
// "- 02, 03 must be done before 04"
|
||||||
|
const mustBeforeMatch = line.match(
|
||||||
|
/^(?:\s*[-*]\s+)?((?:0?\d+\s*,\s*)*0?\d+)\s+must\s+be\s+done\s+before\s+((?:0?\d+\s*,\s*)*0?\d+)(?:\s+\(([^)]+)\))?$/i,
|
||||||
|
);
|
||||||
|
if (mustBeforeMatch) {
|
||||||
|
const [, fromIdsStr, toIdsStr] = mustBeforeMatch;
|
||||||
|
const fromIds = fromIdsStr
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => /^\d+$/.test(t))
|
||||||
|
.map((t) => t.padStart(2, "0"));
|
||||||
|
const toIds = toIdsStr
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => /^\d+$/.test(t))
|
||||||
|
.map((t) => t.padStart(2, "0"));
|
||||||
|
|
||||||
|
// Each "to" task depends on ALL "from" tasks
|
||||||
|
for (const toId of toIds) {
|
||||||
|
if (!dependencies[toId]) dependencies[toId] = [];
|
||||||
|
for (const fromId of fromIds) {
|
||||||
|
if (!dependencies[toId].includes(fromId)) {
|
||||||
|
dependencies[toId].push(fromId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format 4: "X, Y, Z depend on A" or "X depends on A, B, C"
|
||||||
|
// "- 22, 23, 24 depend on 21"
|
||||||
|
// "- 05, 06 depend on 02, 03, 04"
|
||||||
|
// "- 08 also depends on 05, 06" ("also" is ignored)
|
||||||
|
// Strip optional "also" before matching
|
||||||
|
const cleanedLine = line.replace(/\balso\b/i, "");
|
||||||
|
const dependOnMatch = cleanedLine.match(
|
||||||
|
/^(?:\s*[-*]\s+)?((?:0?\d+\s*,\s*)*0?\d+)\s+depend(?:s)?\s+on\s+((?:0?\d+\s*,\s*)*0?\d+)(?:\s+\(([^)]+)\))?$/i,
|
||||||
|
);
|
||||||
|
if (dependOnMatch) {
|
||||||
|
const [, fromIdsStr, toIdsStr] = dependOnMatch;
|
||||||
|
const fromIds = fromIdsStr
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => /^\d+$/.test(t))
|
||||||
|
.map((t) => t.padStart(2, "0"));
|
||||||
|
const toIds = toIdsStr
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => /^\d+$/.test(t))
|
||||||
|
.map((t) => t.padStart(2, "0"));
|
||||||
|
|
||||||
|
// Each "from" task depends on ALL "to" tasks
|
||||||
|
for (const fromId of fromIds) {
|
||||||
|
if (!dependencies[fromId]) dependencies[fromId] = [];
|
||||||
|
for (const toId of toIds) {
|
||||||
|
if (!dependencies[fromId].includes(toId)) {
|
||||||
|
dependencies[fromId].push(toId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,9 +294,20 @@ function parseFioFormat(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply parallelGroup to tasks
|
||||||
|
for (const group of parallelGroups) {
|
||||||
|
for (const taskId of group.taskIds) {
|
||||||
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
|
if (task) {
|
||||||
|
task.parallelGroup = group.index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tasks,
|
tasks,
|
||||||
dependencies,
|
dependencies,
|
||||||
|
parallelGroups: parallelGroups.length > 0 ? parallelGroups : undefined,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
sourceDir,
|
sourceDir,
|
||||||
exitCriteria,
|
exitCriteria,
|
||||||
|
|||||||
11
src/types.ts
11
src/types.ts
@@ -29,6 +29,15 @@ export interface Task {
|
|||||||
index?: number;
|
index?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParallelGroup {
|
||||||
|
/** Group index (0-based, determines execution order) */
|
||||||
|
index: number;
|
||||||
|
/** Human-readable label for the group (e.g. "Play Store prep") */
|
||||||
|
label?: string;
|
||||||
|
/** Task IDs in this group — all can run concurrently */
|
||||||
|
taskIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
/** Project-level objective / goal */
|
/** Project-level objective / goal */
|
||||||
objective?: string;
|
objective?: string;
|
||||||
@@ -36,6 +45,8 @@ export interface Project {
|
|||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
/** Explicit dependency map: taskId → [dependency taskIds] */
|
/** Explicit dependency map: taskId → [dependency taskIds] */
|
||||||
dependencies: Record<string, string[]>;
|
dependencies: Record<string, string[]>;
|
||||||
|
/** Explicit parallel groups from "can be done in parallel" declarations */
|
||||||
|
parallelGroups?: ParallelGroup[];
|
||||||
/** Exit criteria (from README ## Exit Criteria section) */
|
/** Exit criteria (from README ## Exit Criteria section) */
|
||||||
exitCriteria?: string[];
|
exitCriteria?: string[];
|
||||||
/** Path to the source task file */
|
/** Path to the source task file */
|
||||||
|
|||||||
Reference in New Issue
Block a user