refactor(server): centralize task graph readiness (#840)
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
import type { Task, TaskStore } from "./types.js";
|
import type { Task, TaskStore } from "./types.js";
|
||||||
import { buildChildrenMap, loadScopedTaskGraph, sortByPriorityThenCreated } from "./task-graph.js";
|
import {
|
||||||
|
buildChildrenMap,
|
||||||
|
getTasksById,
|
||||||
|
isTaskExecutableInOrder,
|
||||||
|
loadScopedTaskGraph,
|
||||||
|
sortByPriorityThenCreated,
|
||||||
|
} from "./task-graph.js";
|
||||||
|
|
||||||
export interface ExecutionOrderResult {
|
export interface ExecutionOrderResult {
|
||||||
/** Tasks in execution order (done first, then pending) */
|
/** Tasks in execution order (done first, then pending) */
|
||||||
@@ -24,24 +30,13 @@ export async function computeExecutionOrder(
|
|||||||
): Promise<ExecutionOrderResult> {
|
): Promise<ExecutionOrderResult> {
|
||||||
const graph = await loadScopedTaskGraph(store, scopeId);
|
const graph = await loadScopedTaskGraph(store, scopeId);
|
||||||
|
|
||||||
const simDone = new Set(graph.allTasks.filter((t) => t.status === "done").map((t) => t.id));
|
const simDone = new Set(graph.doneTaskIds);
|
||||||
const remaining = new Set(
|
const remaining = new Set(
|
||||||
graph.candidates
|
graph.candidates
|
||||||
.filter((t) => t.status === "open" || t.status === "in_progress")
|
.filter((t) => t.status === "open" || t.status === "in_progress")
|
||||||
.map((t) => t.id),
|
.map((t) => t.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isReady = (taskId: string): boolean => {
|
|
||||||
const task = graph.taskMap.get(taskId);
|
|
||||||
if (!task) return false;
|
|
||||||
const depsOk = task.deps.every((depId) => simDone.has(depId));
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeline: Task[] = [];
|
const timeline: Task[] = [];
|
||||||
const orderMap = new Map<string, number>();
|
const orderMap = new Map<string, number>();
|
||||||
let orderIdx = 0;
|
let orderIdx = 0;
|
||||||
@@ -56,9 +51,8 @@ export async function computeExecutionOrder(
|
|||||||
|
|
||||||
// Then pending tasks in execution order
|
// Then pending tasks in execution order
|
||||||
while (remaining.size > 0) {
|
while (remaining.size > 0) {
|
||||||
const readyNow = [...remaining]
|
const readyNow = getTasksById(graph, remaining)
|
||||||
.filter(isReady)
|
.filter((task) => isTaskExecutableInOrder(graph, task.id, simDone))
|
||||||
.map((tid) => graph.taskMap.get(tid)!)
|
|
||||||
.sort(sortByPriorityThenCreated);
|
.sort(sortByPriorityThenCreated);
|
||||||
|
|
||||||
if (readyNow.length === 0) break;
|
if (readyNow.length === 0) break;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface TaskGraph {
|
|||||||
taskMap: Map<string, Task>;
|
taskMap: Map<string, Task>;
|
||||||
childrenMap: Map<string, Task[]>;
|
childrenMap: Map<string, Task[]>;
|
||||||
candidateIds: Set<string>;
|
candidateIds: Set<string>;
|
||||||
|
doneTaskIds: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskGraphStore = Pick<TaskStore, "list" | "get" | "getDescendants">;
|
type TaskGraphStore = Pick<TaskStore, "list" | "get" | "getDescendants">;
|
||||||
@@ -35,6 +36,47 @@ export function buildChildrenMap(tasks: Task[]): Map<string, Task[]> {
|
|||||||
return childrenMap;
|
return childrenMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isReadyTask(graph: TaskGraph, task: Task): boolean {
|
||||||
|
return (
|
||||||
|
task.status === "open" &&
|
||||||
|
areTaskDepsDone(graph, task, graph.doneTaskIds) &&
|
||||||
|
areTaskChildrenDone(graph, task.id, graph.doneTaskIds)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlockedTask(graph: TaskGraph, task: Task): boolean {
|
||||||
|
return (
|
||||||
|
task.status !== "draft" &&
|
||||||
|
task.status !== "done" &&
|
||||||
|
task.deps.length > 0 &&
|
||||||
|
!areTaskDepsDone(graph, task, graph.doneTaskIds)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTaskExecutableInOrder(
|
||||||
|
graph: TaskGraph,
|
||||||
|
taskId: string,
|
||||||
|
completedTaskIds: Set<string>,
|
||||||
|
): boolean {
|
||||||
|
const task = graph.taskMap.get(taskId);
|
||||||
|
return (
|
||||||
|
task !== undefined &&
|
||||||
|
areTaskDepsDone(graph, task, completedTaskIds) &&
|
||||||
|
areTaskChildrenDone(graph, task.id, completedTaskIds, { scoped: true })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTasksById(graph: TaskGraph, taskIds: Iterable<string>): Task[] {
|
||||||
|
const tasks: Task[] = [];
|
||||||
|
for (const taskId of taskIds) {
|
||||||
|
const task = graph.taskMap.get(taskId);
|
||||||
|
if (task) {
|
||||||
|
tasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadScopedTaskGraph(
|
export async function loadScopedTaskGraph(
|
||||||
store: TaskGraphStore,
|
store: TaskGraphStore,
|
||||||
scopeId?: string,
|
scopeId?: string,
|
||||||
@@ -48,9 +90,32 @@ export async function loadScopedTaskGraph(
|
|||||||
taskMap: buildTaskMap(allTasks),
|
taskMap: buildTaskMap(allTasks),
|
||||||
childrenMap: buildChildrenMap(allTasks),
|
childrenMap: buildChildrenMap(allTasks),
|
||||||
candidateIds: new Set(candidates.map((task) => task.id)),
|
candidateIds: new Set(candidates.map((task) => task.id)),
|
||||||
|
doneTaskIds: new Set(allTasks.filter((task) => task.status === "done").map((task) => task.id)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function areTaskDepsDone(graph: TaskGraph, task: Task, completedTaskIds: Set<string>): boolean {
|
||||||
|
return task.deps.every((depId) => {
|
||||||
|
const dep = graph.taskMap.get(depId);
|
||||||
|
return dep !== undefined && completedTaskIds.has(depId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function areTaskChildrenDone(
|
||||||
|
graph: TaskGraph,
|
||||||
|
taskId: string,
|
||||||
|
completedTaskIds: Set<string>,
|
||||||
|
options?: { scoped: boolean },
|
||||||
|
): boolean {
|
||||||
|
const children = graph.childrenMap.get(taskId) ?? [];
|
||||||
|
return children.every((child) => {
|
||||||
|
if (options?.scoped === true && !graph.candidateIds.has(child.id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return completedTaskIds.has(child.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadScopedCandidates(
|
async function loadScopedCandidates(
|
||||||
store: TaskGraphStore,
|
store: TaskGraphStore,
|
||||||
allTasks: Task[],
|
allTasks: Task[],
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { readdir, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import type { Task, TaskStore, CreateTaskOptions, TaskStatus } from "./types.js";
|
import type { Task, TaskStore, CreateTaskOptions, TaskStatus } from "./types.js";
|
||||||
import { loadScopedTaskGraph, sortByPriorityThenCreated } from "./task-graph.js";
|
import {
|
||||||
|
isBlockedTask,
|
||||||
|
isReadyTask,
|
||||||
|
loadScopedTaskGraph,
|
||||||
|
sortByPriorityThenCreated,
|
||||||
|
} from "./task-graph.js";
|
||||||
|
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
return randomBytes(4).toString("hex");
|
return randomBytes(4).toString("hex");
|
||||||
@@ -253,34 +258,14 @@ export class FileTaskStore implements TaskStore {
|
|||||||
|
|
||||||
async getReady(scopeId?: string): Promise<Task[]> {
|
async getReady(scopeId?: string): Promise<Task[]> {
|
||||||
const graph = await loadScopedTaskGraph(this, scopeId);
|
const graph = await loadScopedTaskGraph(this, scopeId);
|
||||||
|
return graph.candidates
|
||||||
const isReady = (task: Task): boolean => {
|
.filter((task) => isReadyTask(graph, task))
|
||||||
if (task.status !== "open") return false;
|
.sort(sortByPriorityThenCreated);
|
||||||
const depsReady = task.deps.every((depId) => {
|
|
||||||
const dep = graph.taskMap.get(depId);
|
|
||||||
return dep?.status === "done";
|
|
||||||
});
|
|
||||||
if (!depsReady) return false;
|
|
||||||
const children = graph.childrenMap.get(task.id) ?? [];
|
|
||||||
return children.every((c) => c.status === "done");
|
|
||||||
};
|
|
||||||
|
|
||||||
return graph.candidates.filter(isReady).sort(sortByPriorityThenCreated);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBlocked(scopeId?: string): Promise<Task[]> {
|
async getBlocked(scopeId?: string): Promise<Task[]> {
|
||||||
const graph = await loadScopedTaskGraph(this, scopeId);
|
const graph = await loadScopedTaskGraph(this, scopeId);
|
||||||
|
return graph.candidates.filter((task) => isBlockedTask(graph, task));
|
||||||
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 = graph.taskMap.get(depId);
|
|
||||||
return dep?.status !== "done";
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return graph.candidates.filter(isBlocked);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getClosed(scopeId?: string): Promise<Task[]> {
|
async getClosed(scopeId?: string): Promise<Task[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user