fix: Sort pipeline tasks by dependency order instead of priority+createdAt
The Execution tab's PipelinePhaseGroup was using sortByPriorityAndQueueTime which ignores task dependencies entirely. Tasks now sort topologically via topologicalSortPhases, matching the Plan tab's TaskGraph behavior. - Add listInitiativeTaskDependencies tRPC procedure (bulk fetch) - Fetch task deps in PipelineTab, group by phase, pass through - Replace priority sort with topological sort in PipelinePhaseGroup - Show "blocked by N" count on PipelineTaskCard
This commit is contained in:
@@ -183,6 +183,19 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
return edges;
|
return edges;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
listInitiativeTaskDependencies: publicProcedure
|
||||||
|
.input(z.object({ initiativeId: z.string().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const taskRepo = requireTaskRepository(ctx);
|
||||||
|
const tasks = await taskRepo.findByInitiativeId(input.initiativeId);
|
||||||
|
const edges: Array<{ taskId: string; dependsOn: string[] }> = [];
|
||||||
|
for (const t of tasks) {
|
||||||
|
const deps = await taskRepo.getDependencies(t.id);
|
||||||
|
if (deps.length > 0) edges.push({ taskId: t.id, dependsOn: deps });
|
||||||
|
}
|
||||||
|
return edges;
|
||||||
|
}),
|
||||||
|
|
||||||
approveTask: publicProcedure
|
approveTask: publicProcedure
|
||||||
.input(z.object({ taskId: z.string().min(1) }))
|
.input(z.object({ taskId: z.string().min(1) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ interface PipelineGraphProps {
|
|||||||
createdAt: string | Date;
|
createdAt: string | Date;
|
||||||
}>[];
|
}>[];
|
||||||
tasksByPhase: Record<string, SerializedTask[]>;
|
tasksByPhase: Record<string, SerializedTask[]>;
|
||||||
|
taskDepsByPhase: Record<string, Array<{ taskId: string; dependsOn: string[] }>>;
|
||||||
dependencyEdges: DependencyEdge[];
|
dependencyEdges: DependencyEdge[];
|
||||||
detailAgentByPhase?: Map<string, DetailAgentInfo>;
|
detailAgentByPhase?: Map<string, DetailAgentInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PipelineGraph({ columns, tasksByPhase, dependencyEdges, detailAgentByPhase }: PipelineGraphProps) {
|
export function PipelineGraph({ columns, tasksByPhase, taskDepsByPhase, dependencyEdges, detailAgentByPhase }: PipelineGraphProps) {
|
||||||
// Build a set of phase IDs whose dependencies are all completed
|
// Build a set of phase IDs whose dependencies are all completed
|
||||||
const blockedPhaseIds = useMemo(() => {
|
const blockedPhaseIds = useMemo(() => {
|
||||||
const phaseStatusMap = new Map<string, string>();
|
const phaseStatusMap = new Map<string, string>();
|
||||||
@@ -50,6 +51,7 @@ export function PipelineGraph({ columns, tasksByPhase, dependencyEdges, detailAg
|
|||||||
<PipelineStageColumn
|
<PipelineStageColumn
|
||||||
phases={column.phases}
|
phases={column.phases}
|
||||||
tasksByPhase={tasksByPhase}
|
tasksByPhase={tasksByPhase}
|
||||||
|
taskDepsByPhase={taskDepsByPhase}
|
||||||
blockedPhaseIds={blockedPhaseIds}
|
blockedPhaseIds={blockedPhaseIds}
|
||||||
detailAgentByPhase={detailAgentByPhase}
|
detailAgentByPhase={detailAgentByPhase}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import { Loader2, Play } from "lucide-react";
|
import { Loader2, Play } from "lucide-react";
|
||||||
import { StatusDot } from "@/components/StatusDot";
|
import { StatusDot } from "@/components/StatusDot";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { sortByPriorityAndQueueTime } from "@codewalk-district/shared";
|
import {
|
||||||
|
topologicalSortPhases,
|
||||||
|
type DependencyEdge,
|
||||||
|
} from "@codewalk-district/shared";
|
||||||
import { PipelineTaskCard } from "./PipelineTaskCard";
|
import { PipelineTaskCard } from "./PipelineTaskCard";
|
||||||
import type { SerializedTask } from "@/components/TaskRow";
|
import type { SerializedTask } from "@/components/TaskRow";
|
||||||
|
|
||||||
@@ -17,13 +21,37 @@ interface PipelinePhaseGroupProps {
|
|||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
tasks: SerializedTask[];
|
tasks: SerializedTask[];
|
||||||
|
taskDepsRaw: Array<{ taskId: string; dependsOn: string[] }>;
|
||||||
isBlocked: boolean;
|
isBlocked: boolean;
|
||||||
detailAgent?: DetailAgentInfo | null;
|
detailAgent?: DetailAgentInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PipelinePhaseGroup({ phase, tasks, isBlocked, detailAgent }: PipelinePhaseGroupProps) {
|
export function PipelinePhaseGroup({ phase, tasks, taskDepsRaw, isBlocked, detailAgent }: PipelinePhaseGroupProps) {
|
||||||
const queuePhase = trpc.queuePhase.useMutation();
|
const queuePhase = trpc.queuePhase.useMutation();
|
||||||
const sorted = sortByPriorityAndQueueTime(tasks);
|
|
||||||
|
// Sort tasks topologically by dependency order
|
||||||
|
const { sorted, blockedByCountMap } = useMemo(() => {
|
||||||
|
const edges: DependencyEdge[] = [];
|
||||||
|
for (const raw of taskDepsRaw) {
|
||||||
|
for (const depId of raw.dependsOn) {
|
||||||
|
edges.push({ phaseId: raw.taskId, dependsOnPhaseId: depId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sortedTasks = topologicalSortPhases(tasks, edges);
|
||||||
|
|
||||||
|
// Compute blocked-by counts (incomplete dependencies)
|
||||||
|
const countMap = new Map<string, number>();
|
||||||
|
for (const raw of taskDepsRaw) {
|
||||||
|
const incomplete = raw.dependsOn.filter((depId) => {
|
||||||
|
const dep = tasks.find((t) => t.id === depId);
|
||||||
|
return dep && dep.status !== "completed";
|
||||||
|
}).length;
|
||||||
|
if (incomplete > 0) countMap.set(raw.taskId, incomplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sorted: sortedTasks, blockedByCountMap: countMap };
|
||||||
|
}, [tasks, taskDepsRaw]);
|
||||||
|
|
||||||
const canExecute = phase.status === "approved" && !isBlocked;
|
const canExecute = phase.status === "approved" && !isBlocked;
|
||||||
const isDetailing =
|
const isDetailing =
|
||||||
detailAgent?.status === "running" ||
|
detailAgent?.status === "running" ||
|
||||||
@@ -62,7 +90,11 @@ export function PipelinePhaseGroup({ phase, tasks, isBlocked, detailAgent }: Pip
|
|||||||
<p className="px-3 py-1 text-xs text-muted-foreground">No tasks</p>
|
<p className="px-3 py-1 text-xs text-muted-foreground">No tasks</p>
|
||||||
) : (
|
) : (
|
||||||
sorted.map((task) => (
|
sorted.map((task) => (
|
||||||
<PipelineTaskCard key={task.id} task={task} />
|
<PipelineTaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
blockedByCount={blockedByCountMap.get(task.id) ?? 0}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ interface PipelineStageColumnProps {
|
|||||||
status: string;
|
status: string;
|
||||||
}>;
|
}>;
|
||||||
tasksByPhase: Record<string, SerializedTask[]>;
|
tasksByPhase: Record<string, SerializedTask[]>;
|
||||||
|
taskDepsByPhase: Record<string, Array<{ taskId: string; dependsOn: string[] }>>;
|
||||||
blockedPhaseIds: Set<string>;
|
blockedPhaseIds: Set<string>;
|
||||||
detailAgentByPhase?: Map<string, DetailAgentInfo>;
|
detailAgentByPhase?: Map<string, DetailAgentInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PipelineStageColumn({ phases, tasksByPhase, blockedPhaseIds, detailAgentByPhase }: PipelineStageColumnProps) {
|
export function PipelineStageColumn({ phases, tasksByPhase, taskDepsByPhase, blockedPhaseIds, detailAgentByPhase }: PipelineStageColumnProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-64 shrink-0 flex-col gap-3">
|
<div className="flex w-64 shrink-0 flex-col gap-3">
|
||||||
{phases.map((phase) => (
|
{phases.map((phase) => (
|
||||||
@@ -20,6 +21,7 @@ export function PipelineStageColumn({ phases, tasksByPhase, blockedPhaseIds, det
|
|||||||
key={phase.id}
|
key={phase.id}
|
||||||
phase={phase}
|
phase={phase}
|
||||||
tasks={tasksByPhase[phase.id] ?? []}
|
tasks={tasksByPhase[phase.id] ?? []}
|
||||||
|
taskDepsRaw={taskDepsByPhase[phase.id] ?? []}
|
||||||
isBlocked={blockedPhaseIds.has(phase.id)}
|
isBlocked={blockedPhaseIds.has(phase.id)}
|
||||||
detailAgent={detailAgentByPhase?.get(phase.id) ?? null}
|
detailAgent={detailAgentByPhase?.get(phase.id) ?? null}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -47,13 +47,20 @@ function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabPr
|
|||||||
);
|
);
|
||||||
const allTasks = (tasksQuery.data ?? []) as SerializedTask[];
|
const allTasks = (tasksQuery.data ?? []) as SerializedTask[];
|
||||||
|
|
||||||
// Fetch dependency edges
|
// Fetch dependency edges (phase-level)
|
||||||
const depsQuery = trpc.listInitiativePhaseDependencies.useQuery(
|
const depsQuery = trpc.listInitiativePhaseDependencies.useQuery(
|
||||||
{ initiativeId },
|
{ initiativeId },
|
||||||
{ enabled: phases.length > 0 },
|
{ enabled: phases.length > 0 },
|
||||||
);
|
);
|
||||||
const dependencyEdges: DependencyEdge[] = depsQuery.data ?? [];
|
const dependencyEdges: DependencyEdge[] = depsQuery.data ?? [];
|
||||||
|
|
||||||
|
// Fetch task-level dependency edges
|
||||||
|
const taskDepsQuery = trpc.listInitiativeTaskDependencies.useQuery(
|
||||||
|
{ initiativeId },
|
||||||
|
{ enabled: phases.length > 0 },
|
||||||
|
);
|
||||||
|
const taskDepsRaw = taskDepsQuery.data ?? [];
|
||||||
|
|
||||||
// Fetch agents for detail agent tracking
|
// Fetch agents for detail agent tracking
|
||||||
const agentsQuery = trpc.listAgents.useQuery();
|
const agentsQuery = trpc.listAgents.useQuery();
|
||||||
const allAgents = agentsQuery.data ?? [];
|
const allAgents = agentsQuery.data ?? [];
|
||||||
@@ -76,6 +83,23 @@ function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabPr
|
|||||||
return map;
|
return map;
|
||||||
}, [displayTasks]);
|
}, [displayTasks]);
|
||||||
|
|
||||||
|
// Group task dependency edges by phaseId
|
||||||
|
const taskDepsByPhase = useMemo(() => {
|
||||||
|
const taskPhaseMap = new Map<string, string>();
|
||||||
|
for (const t of allTasks) {
|
||||||
|
if (t.phaseId) taskPhaseMap.set(t.id, t.phaseId);
|
||||||
|
}
|
||||||
|
const map: Record<string, Array<{ taskId: string; dependsOn: string[] }>> = {};
|
||||||
|
for (const edge of taskDepsRaw) {
|
||||||
|
const phaseId = taskPhaseMap.get(edge.taskId);
|
||||||
|
if (phaseId) {
|
||||||
|
if (!map[phaseId]) map[phaseId] = [];
|
||||||
|
map[phaseId].push(edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [allTasks, taskDepsRaw]);
|
||||||
|
|
||||||
// Map phaseId → most recent active detail agent
|
// Map phaseId → most recent active detail agent
|
||||||
const detailAgentByPhase = useMemo(() => {
|
const detailAgentByPhase = useMemo(() => {
|
||||||
const map = new Map<string, DetailAgentInfo>();
|
const map = new Map<string, DetailAgentInfo>();
|
||||||
@@ -180,6 +204,7 @@ function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabPr
|
|||||||
<PipelineGraph
|
<PipelineGraph
|
||||||
columns={columns}
|
columns={columns}
|
||||||
tasksByPhase={tasksByPhase}
|
tasksByPhase={tasksByPhase}
|
||||||
|
taskDepsByPhase={taskDepsByPhase}
|
||||||
dependencyEdges={dependencyEdges}
|
dependencyEdges={dependencyEdges}
|
||||||
detailAgentByPhase={detailAgentByPhase}
|
detailAgentByPhase={detailAgentByPhase}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ const statusConfig: Record<string, { icon: typeof Clock; color: string; spin?: b
|
|||||||
|
|
||||||
interface PipelineTaskCardProps {
|
interface PipelineTaskCardProps {
|
||||||
task: SerializedTask;
|
task: SerializedTask;
|
||||||
|
blockedByCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PipelineTaskCard({ task }: PipelineTaskCardProps) {
|
export function PipelineTaskCard({ task, blockedByCount = 0 }: PipelineTaskCardProps) {
|
||||||
const { setSelectedTaskId } = useExecutionContext();
|
const { setSelectedTaskId } = useExecutionContext();
|
||||||
const queueTask = trpc.queueTask.useMutation();
|
const queueTask = trpc.queueTask.useMutation();
|
||||||
|
|
||||||
@@ -32,6 +33,11 @@ export function PipelineTaskCard({ task }: PipelineTaskCardProps) {
|
|||||||
className={cn("h-3.5 w-3.5 shrink-0", config.color, config.spin && "animate-spin")}
|
className={cn("h-3.5 w-3.5 shrink-0", config.color, config.spin && "animate-spin")}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1 truncate text-xs">{task.name}</span>
|
<span className="min-w-0 flex-1 truncate text-xs">{task.name}</span>
|
||||||
|
{blockedByCount > 0 && (
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
blocked by {blockedByCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{task.status === "pending" && (
|
{task.status === "pending" && (
|
||||||
<button
|
<button
|
||||||
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
|||||||
@@ -103,9 +103,11 @@ The initiative detail page has three tabs managed via local state (not URL param
|
|||||||
### Pipeline Components (`src/components/pipeline/`)
|
### Pipeline Components (`src/components/pipeline/`)
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| `PipelineVisualization` | DAG visualization of phase pipeline |
|
| `PipelineTab` | Execution tab entry — fetches tasks, phase deps, task deps |
|
||||||
| `PipelineNode` | Individual phase node in pipeline |
|
| `PipelineGraph` | Horizontal DAG of phase columns with connectors |
|
||||||
| `PipelineEdge` | Dependency edge between nodes |
|
| `PipelineStageColumn` | Single depth column containing phase groups |
|
||||||
|
| `PipelinePhaseGroup` | Phase card with topologically-sorted task list |
|
||||||
|
| `PipelineTaskCard` | Individual task row with status icon, blocked-by count |
|
||||||
|
|
||||||
### Review Components (`src/components/review/`)
|
### Review Components (`src/components/review/`)
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| listPendingApprovals | query | Tasks with status=pending_approval |
|
| listPendingApprovals | query | Tasks with status=pending_approval |
|
||||||
| deleteTask | mutation | Delete a task by ID |
|
| deleteTask | mutation | Delete a task by ID |
|
||||||
| listPhaseTaskDependencies | query | All task dependency edges for tasks in a phase |
|
| listPhaseTaskDependencies | query | All task dependency edges for tasks in a phase |
|
||||||
|
| listInitiativeTaskDependencies | query | All task dependency edges for tasks in an initiative |
|
||||||
| approveTask | mutation | Approve and complete task |
|
| approveTask | mutation | Approve and complete task |
|
||||||
|
|
||||||
### Initiatives
|
### Initiatives
|
||||||
|
|||||||
Reference in New Issue
Block a user