diff --git a/packages/server/src/tasks/execution-order.ts b/packages/server/src/tasks/execution-order.ts index ecd56bcfc..197d6c9c1 100644 --- a/packages/server/src/tasks/execution-order.ts +++ b/packages/server/src/tasks/execution-order.ts @@ -1,13 +1,5 @@ import type { Task, TaskStore } from "./types.js"; - -function sortByPriorityThenCreated(a: Task, b: Task): number { - if (a.priority !== undefined && b.priority === undefined) return -1; - if (a.priority === undefined && b.priority !== undefined) return 1; - if (a.priority !== undefined && b.priority !== undefined) { - if (a.priority !== b.priority) return a.priority - b.priority; - } - return a.created.localeCompare(b.created); -} +import { buildChildrenMap, loadScopedTaskGraph, sortByPriorityThenCreated } from "./task-graph.js"; export interface ExecutionOrderResult { /** Tasks in execution order (done first, then pending) */ @@ -30,45 +22,22 @@ export async function computeExecutionOrder( store: TaskStore, scopeId?: string, ): Promise { - const allTasks = await store.list(); - const taskMap = new Map(allTasks.map((t) => [t.id, t])); + const graph = await loadScopedTaskGraph(store, scopeId); - // Get scoped tasks - let candidates: Task[]; - if (scopeId) { - const scopeTask = await store.get(scopeId); - const descendants = await store.getDescendants(scopeId); - candidates = scopeTask ? [scopeTask, ...descendants] : descendants; - } else { - candidates = allTasks; - } - - // Build children map - const childrenMap = new Map(); - for (const t of allTasks) { - if (t.parentId) { - const siblings = childrenMap.get(t.parentId) ?? []; - siblings.push(t); - childrenMap.set(t.parentId, siblings); - } - } - - const candidateIds = new Set(candidates.map((t) => t.id)); - - // Simulate execution: track done status - // Include all done tasks (not just in scope) for dep resolution - const simDone = new Set(allTasks.filter((t) => t.status === "done").map((t) => t.id)); + const simDone = new Set(graph.allTasks.filter((t) => t.status === "done").map((t) => t.id)); const remaining = new Set( - candidates.filter((t) => t.status === "open" || t.status === "in_progress").map((t) => t.id), + graph.candidates + .filter((t) => t.status === "open" || t.status === "in_progress") + .map((t) => t.id), ); const isReady = (taskId: string): boolean => { - const task = taskMap.get(taskId); + const task = graph.taskMap.get(taskId); if (!task) return false; - // All deps done (deps can be outside scope) const depsOk = task.deps.every((depId) => simDone.has(depId)); - // All children done (only consider children in scope) - const children = (childrenMap.get(taskId) ?? []).filter((c) => candidateIds.has(c.id)); + const children = (graph.childrenMap.get(taskId) ?? []).filter((c) => + graph.candidateIds.has(c.id), + ); const childrenOk = children.every((c) => simDone.has(c.id)); return depsOk && childrenOk; }; @@ -77,8 +46,7 @@ export async function computeExecutionOrder( const orderMap = new Map(); let orderIdx = 0; - // Done tasks first (by created date = historical execution order) - const done = candidates + const done = graph.candidates .filter((t) => t.status === "done") .sort((a, b) => a.created.localeCompare(b.created)); for (const t of done) { @@ -90,10 +58,10 @@ export async function computeExecutionOrder( while (remaining.size > 0) { const readyNow = [...remaining] .filter(isReady) - .map((tid) => taskMap.get(tid)!) + .map((tid) => graph.taskMap.get(tid)!) .sort(sortByPriorityThenCreated); - if (readyNow.length === 0) break; // No more can be done (cycle or blocked) + if (readyNow.length === 0) break; const next = readyNow[0]; timeline.push(next); @@ -115,17 +83,8 @@ export function buildSortedChildrenMap( tasks: Task[], orderMap: Map, ): Map { - const childrenMap = new Map(); + const childrenMap = buildChildrenMap(tasks); - for (const t of tasks) { - if (t.parentId) { - const siblings = childrenMap.get(t.parentId) ?? []; - siblings.push(t); - childrenMap.set(t.parentId, siblings); - } - } - - // Sort each group by execution order for (const [parentId, children] of childrenMap) { children.sort((a, b) => { const orderA = orderMap.get(a.id) ?? Infinity; diff --git a/packages/server/src/tasks/task-graph.ts b/packages/server/src/tasks/task-graph.ts new file mode 100644 index 000000000..fb7472cbb --- /dev/null +++ b/packages/server/src/tasks/task-graph.ts @@ -0,0 +1,66 @@ +import type { Task, TaskStore } from "./types.js"; + +export interface TaskGraph { + allTasks: Task[]; + candidates: Task[]; + taskMap: Map; + childrenMap: Map; + candidateIds: Set; +} + +type TaskGraphStore = Pick; + +export function sortByPriorityThenCreated(a: Task, b: Task): number { + if (a.priority !== undefined && b.priority === undefined) return -1; + if (a.priority === undefined && b.priority !== undefined) return 1; + if (a.priority !== undefined && b.priority !== undefined) { + if (a.priority !== b.priority) return a.priority - b.priority; + } + return a.created.localeCompare(b.created); +} + +export function buildTaskMap(tasks: Task[]): Map { + return new Map(tasks.map((task) => [task.id, task])); +} + +export function buildChildrenMap(tasks: Task[]): Map { + const childrenMap = new Map(); + for (const task of tasks) { + if (task.parentId) { + const siblings = childrenMap.get(task.parentId) ?? []; + siblings.push(task); + childrenMap.set(task.parentId, siblings); + } + } + return childrenMap; +} + +export async function loadScopedTaskGraph( + store: TaskGraphStore, + scopeId?: string, +): Promise { + const allTasks = await store.list(); + const candidates = await loadScopedCandidates(store, allTasks, scopeId); + + return { + allTasks, + candidates, + taskMap: buildTaskMap(allTasks), + childrenMap: buildChildrenMap(allTasks), + candidateIds: new Set(candidates.map((task) => task.id)), + }; +} + +async function loadScopedCandidates( + store: TaskGraphStore, + allTasks: Task[], + scopeId?: string, +): Promise { + if (!scopeId) { + return allTasks; + } + + const scopeTask = await store.get(scopeId); + const descendants = await store.getDescendants(scopeId); + return scopeTask ? [scopeTask, ...descendants] : descendants; +} diff --git a/packages/server/src/tasks/task-store.ts b/packages/server/src/tasks/task-store.ts index 320ed0df7..1adc8bda5 100644 --- a/packages/server/src/tasks/task-store.ts +++ b/packages/server/src/tasks/task-store.ts @@ -2,27 +2,12 @@ import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises"; import { join } from "node:path"; import { randomBytes } from "node:crypto"; import type { Task, TaskStore, CreateTaskOptions, TaskStatus } from "./types.js"; +import { loadScopedTaskGraph, sortByPriorityThenCreated } from "./task-graph.js"; function generateId(): string { return randomBytes(4).toString("hex"); } -function sortByPriorityThenCreated(a: Task, b: Task): number { - // Tasks with priority come before tasks without - if (a.priority !== undefined && b.priority === undefined) return -1; - if (a.priority === undefined && b.priority !== undefined) return 1; - - // If both have priority, lower number = higher priority - if (a.priority !== undefined && b.priority !== undefined) { - if (a.priority !== b.priority) { - return a.priority - b.priority; - } - } - - // Fall back to created date (oldest first) - return a.created.localeCompare(b.created); -} - function serializeTask(task: Task): string { const frontmatterLines = [ "---", @@ -267,82 +252,40 @@ export class FileTaskStore implements TaskStore { } async getReady(scopeId?: string): Promise { - const allTasks = await this.list(); - const taskMap = new Map(allTasks.map((t) => [t.id, t])); - - let candidates: Task[]; - if (scopeId) { - // Include the scoped task itself and all its descendants (children tree) - const scopeTask = await this.get(scopeId); - const descendants = await this.getDescendants(scopeId); - candidates = scopeTask ? [scopeTask, ...descendants] : descendants; - } else { - candidates = allTasks; - } - - // Build children map for quick lookup - const childrenMap = new Map(); - for (const t of allTasks) { - if (t.parentId) { - const siblings = childrenMap.get(t.parentId) ?? []; - siblings.push(t); - childrenMap.set(t.parentId, siblings); - } - } + const graph = await loadScopedTaskGraph(this, scopeId); const isReady = (task: Task): boolean => { if (task.status !== "open") return false; - // All deps must be done const depsReady = task.deps.every((depId) => { - const dep = taskMap.get(depId); + const dep = graph.taskMap.get(depId); return dep?.status === "done"; }); if (!depsReady) return false; - // All children must be done (if any exist) - const children = childrenMap.get(task.id) ?? []; + const children = graph.childrenMap.get(task.id) ?? []; return children.every((c) => c.status === "done"); }; - // Sort by priority first (lower = higher priority), then created date - return candidates.filter(isReady).sort(sortByPriorityThenCreated); + return graph.candidates.filter(isReady).sort(sortByPriorityThenCreated); } async getBlocked(scopeId?: string): Promise { - const allTasks = await this.list(); - const taskMap = new Map(allTasks.map((t) => [t.id, t])); - - let candidates: Task[]; - if (scopeId) { - const scopeTask = await this.get(scopeId); - const descendants = await this.getDescendants(scopeId); - candidates = scopeTask ? [scopeTask, ...descendants] : descendants; - } else { - candidates = allTasks; - } + const graph = await loadScopedTaskGraph(this, scopeId); const isBlocked = (task: Task): boolean => { if (task.status === "draft" || task.status === "done") return false; if (task.deps.length === 0) return false; return task.deps.some((depId) => { - const dep = taskMap.get(depId); + const dep = graph.taskMap.get(depId); return dep?.status !== "done"; }); }; - return candidates.filter(isBlocked); + return graph.candidates.filter(isBlocked); } async getClosed(scopeId?: string): Promise { - let candidates: Task[]; - if (scopeId) { - const scopeTask = await this.get(scopeId); - const descendants = await this.getDescendants(scopeId); - candidates = scopeTask ? [scopeTask, ...descendants] : descendants; - } else { - candidates = await this.list(); - } + const { candidates } = await loadScopedTaskGraph(this, scopeId); - // Sort by created date (most recent first) for consistent ordering return candidates .filter((t) => t.status === "done") .sort((a, b) => b.created.localeCompare(a.created));