refactor(server): extract shared task graph utilities (#839)
* refactor(server): extract checkout status projection * refactor(server): extract shared task graph utilities
This commit is contained in:
@@ -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<ExecutionOrderResult> {
|
||||
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<string, Task[]>();
|
||||
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<string, number>();
|
||||
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<string, number>,
|
||||
): Map<string, Task[]> {
|
||||
const childrenMap = new Map<string, Task[]>();
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Task, TaskStore } from "./types.js";
|
||||
|
||||
export interface TaskGraph {
|
||||
allTasks: Task[];
|
||||
candidates: Task[];
|
||||
taskMap: Map<string, Task>;
|
||||
childrenMap: Map<string, Task[]>;
|
||||
candidateIds: Set<string>;
|
||||
}
|
||||
|
||||
type TaskGraphStore = Pick<TaskStore, "list" | "get" | "getDescendants">;
|
||||
|
||||
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<string, Task> {
|
||||
return new Map(tasks.map((task) => [task.id, task]));
|
||||
}
|
||||
|
||||
export function buildChildrenMap(tasks: Task[]): Map<string, Task[]> {
|
||||
const childrenMap = new Map<string, Task[]>();
|
||||
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<TaskGraph> {
|
||||
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<Task[]> {
|
||||
if (!scopeId) {
|
||||
return allTasks;
|
||||
}
|
||||
|
||||
const scopeTask = await store.get(scopeId);
|
||||
const descendants = await store.getDescendants(scopeId);
|
||||
return scopeTask ? [scopeTask, ...descendants] : descendants;
|
||||
}
|
||||
@@ -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<Task[]> {
|
||||
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<string, Task[]>();
|
||||
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<Task[]> {
|
||||
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<Task[]> {
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user