diff --git a/apps/app/src/components/views/board-view/components/kanban-card.tsx b/apps/app/src/components/views/board-view/components/kanban-card.tsx index 3ba39a06..ceeceb54 100644 --- a/apps/app/src/components/views/board-view/components/kanban-card.tsx +++ b/apps/app/src/components/views/board-view/components/kanban-card.tsx @@ -57,9 +57,11 @@ import { Brain, Wand2, Archive, + Lock, } from "lucide-react"; import { CountUpTimer } from "@/components/ui/count-up-timer"; import { getElectronAPI } from "@/lib/electron"; +import { getBlockingDependencies } from "@/lib/dependency-resolver"; import { parseAgentContext, AgentTaskInfo, @@ -143,7 +145,15 @@ export const KanbanCard = memo(function KanbanCard({ const [agentInfo, setAgentInfo] = useState(null); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [currentTime, setCurrentTime] = useState(() => Date.now()); - const { kanbanCardDetailLevel, useWorktrees } = useAppStore(); + const { kanbanCardDetailLevel, enableDependencyBlocking, features, useWorktrees } = useAppStore(); + + // Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies) + const blockingDependencies = useMemo(() => { + if (!enableDependencyBlocking || feature.status !== "backlog") { + return []; + } + return getBlockingDependencies(feature, features); + }, [enableDependencyBlocking, feature, features]); const showSteps = kanbanCardDetailLevel === "standard" || @@ -328,7 +338,7 @@ export const KanbanCard = memo(function KanbanCard({
- P{feature.priority} + {feature.priority === 1 ? "H" : feature.priority === 2 ? "M" : "L"}
@@ -364,62 +374,97 @@ export const KanbanCard = memo(function KanbanCard({ )} - {/* Status badges row */} - {(feature.skipTests || feature.error || isJustFinished) && ( + {/* Skip Tests (Manual) indicator badge - positioned at top right */} + {feature.skipTests && !feature.error && feature.status === "backlog" && ( + + + +
+ +
+
+ +

Manual verification required

+
+
+
+ )} + + {/* Error indicator badge */} + {feature.error && ( + + + +
+ +
+
+ +

{feature.error}

+
+
+
+ )} + + {/* Blocked by dependencies badge - positioned at top right */} + {blockingDependencies.length > 0 && !feature.error && !feature.skipTests && feature.status === "backlog" && ( + + + +
+ +
+
+ +

Blocked by {blockingDependencies.length} incomplete {blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}

+

+ {blockingDependencies.map(depId => { + const dep = features.find(f => f.id === depId); + return dep?.description || depId; + }).join(', ')} +

+
+
+
+ )} + + {/* Just Finished indicator badge */} + {isJustFinished && (
- {/* Skip Tests (Manual) indicator badge */} - {feature.skipTests && !feature.error && ( - - - -
- -
-
- -

Manual verification required

-
-
-
- )} - - {/* Error indicator badge */} - {feature.error && ( - - - -
- -
-
- -

{feature.error}

-
-
-
- )} - - {/* Just Finished indicator badge */} - {isJustFinished && ( -
- -
- )} +
)} @@ -447,7 +492,7 @@ export const KanbanCard = memo(function KanbanCard({ )} {!isCurrentAutoTask && feature.status === "backlog" && ( -
+
- {onViewOutput && ( + <> +
- )} + {onViewOutput && ( + + )} +
+
+ +
+ + )} + {!isCurrentAutoTask && feature.status === "in_progress" && ( + <> +
+ + + + + + { + e.stopPropagation(); + onEdit(); + }} + data-testid={`edit-feature-${feature.id}`} + className="text-xs" + > + + Edit + + {onViewOutput && ( + { + e.stopPropagation(); + onViewOutput(); + }} + data-testid={`view-logs-${feature.id}`} + className="text-xs" + > + + View Logs + + )} + + +
+
- )} - {!isCurrentAutoTask && feature.status === "in_progress" && ( -
- - - - - - { - e.stopPropagation(); - onEdit(); - }} - data-testid={`edit-feature-${feature.id}`} - className="text-xs" - > - - Edit - - {onViewOutput && ( - { - e.stopPropagation(); - onViewOutput(); - }} - data-testid={`view-logs-${feature.id}`} - className="text-xs" - > - - View Logs - - )} - { - e.stopPropagation(); - handleDeleteClick(e as unknown as React.MouseEvent); - }} - data-testid={`delete-feature-${feature.id}`} - > - - Delete - - - -
+ )}
{isDraggable && ( diff --git a/apps/app/src/components/views/board-view/hooks/use-board-actions.ts b/apps/app/src/components/views/board-view/hooks/use-board-actions.ts index 5a5c24a3..8a5f2d04 100644 --- a/apps/app/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/app/src/components/views/board-view/hooks/use-board-actions.ts @@ -11,6 +11,7 @@ import { getElectronAPI } from "@/lib/electron"; import { toast } from "sonner"; import { useAutoMode } from "@/hooks/use-auto-mode"; import { truncateDescription } from "@/lib/utils"; +import { getBlockingDependencies } from "@/lib/dependency-resolver"; interface UseBoardActionsProps { currentProject: { path: string; id: string } | null; @@ -74,6 +75,7 @@ export function useBoardActions({ removeFeature, moveFeature, useWorktrees, + enableDependencyBlocking, } = useAppStore(); const autoMode = useAutoMode(); @@ -344,6 +346,21 @@ export function useBoardActions({ return false; } + // Check for blocking dependencies and show warning if enabled + if (enableDependencyBlocking) { + const blockingDeps = getBlockingDependencies(feature, features); + if (blockingDeps.length > 0) { + const depDescriptions = blockingDeps.map(depId => { + const dep = features.find(f => f.id === depId); + return dep ? truncateDescription(dep.description, 40) : depId; + }).join(", "); + + toast.warning("Starting feature with incomplete dependencies", { + description: `This feature depends on: ${depDescriptions}`, + }); + } + } + const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), @@ -355,7 +372,7 @@ export function useBoardActions({ await handleRunFeature(feature); return true; }, - [autoMode, updateFeature, persistFeatureUpdate, handleRunFeature] + [autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature] ); const handleVerifyFeature = useCallback( diff --git a/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts index 1bde18be..f09a0135 100644 --- a/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts +++ b/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts @@ -1,5 +1,6 @@ import { useMemo, useCallback } from "react"; import { Feature } from "@/store/app-store"; +import { resolveDependencies } from "@/lib/dependency-resolver"; import { pathsEqual } from "@/lib/utils"; type ColumnId = Feature["status"]; @@ -105,12 +106,13 @@ export function useBoardColumnFeatures({ } }); - // Sort backlog by priority: 1 (high) -> 2 (medium) -> 3 (low) -> no priority - map.backlog.sort((a, b) => { - const aPriority = a.priority ?? 999; // Features without priority go last - const bPriority = b.priority ?? 999; - return aPriority - bPriority; - }); + // Apply dependency-aware sorting to backlog + // This ensures features appear in dependency order (dependencies before dependents) + // Within the same dependency level, features are sorted by priority + if (map.backlog.length > 0) { + const { orderedFeatures } = resolveDependencies(map.backlog); + map.backlog = orderedFeatures; + } return map; }, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]); diff --git a/apps/app/src/components/views/settings-view.tsx b/apps/app/src/components/views/settings-view.tsx index bb56262d..a127ba0f 100644 --- a/apps/app/src/components/views/settings-view.tsx +++ b/apps/app/src/components/views/settings-view.tsx @@ -30,6 +30,8 @@ export function SettingsView() { setProjectTheme, defaultSkipTests, setDefaultSkipTests, + enableDependencyBlocking, + setEnableDependencyBlocking, useWorktrees, setUseWorktrees, showProfilesOnly, @@ -118,9 +120,11 @@ export function SettingsView() { ); diff --git a/apps/app/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx b/apps/app/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx index 4f2dc8e5..5623004f 100644 --- a/apps/app/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx +++ b/apps/app/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx @@ -1,23 +1,27 @@ import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; -import { FlaskConical, Settings2, TestTube, GitBranch } from "lucide-react"; +import { FlaskConical, Settings2, TestTube, GitBranch, AlertCircle } from "lucide-react"; import { cn } from "@/lib/utils"; interface FeatureDefaultsSectionProps { showProfilesOnly: boolean; defaultSkipTests: boolean; + enableDependencyBlocking: boolean; useWorktrees: boolean; onShowProfilesOnlyChange: (value: boolean) => void; onDefaultSkipTestsChange: (value: boolean) => void; + onEnableDependencyBlockingChange: (value: boolean) => void; onUseWorktreesChange: (value: boolean) => void; } export function FeatureDefaultsSection({ showProfilesOnly, defaultSkipTests, + enableDependencyBlocking, useWorktrees, onShowProfilesOnlyChange, onDefaultSkipTestsChange, + onEnableDependencyBlockingChange, onUseWorktreesChange, }: FeatureDefaultsSectionProps) { return ( @@ -102,6 +106,36 @@ export function FeatureDefaultsSection({ {/* Separator */}
+ {/* Dependency Blocking Setting */} +
+ + onEnableDependencyBlockingChange(checked === true) + } + className="mt-1" + data-testid="enable-dependency-blocking-checkbox" + /> +
+ +

+ When enabled, features with incomplete dependencies will show blocked badges + and warnings. Auto mode and backlog ordering always respect dependencies + regardless of this setting. +

+
+
+ + {/* Separator */} +
+ {/* Worktree Isolation Setting */}
; // featureId -> missing dep IDs + blockedFeatures: Map; // featureId -> blocking dep IDs (incomplete dependencies) +} + +/** + * Resolves feature dependencies using topological sort with priority-aware ordering. + * + * Algorithm: + * 1. Build dependency graph and detect missing/blocked dependencies + * 2. Apply Kahn's algorithm for topological sort + * 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low) + * 4. Detect circular dependencies for features that can't be ordered + * + * @param features - Array of features to order + * @returns Resolution result with ordered features and dependency metadata + */ +export function resolveDependencies(features: Feature[]): DependencyResolutionResult { + const featureMap = new Map(features.map(f => [f.id, f])); + const inDegree = new Map(); + const adjacencyList = new Map(); // dependencyId -> [dependentIds] + const missingDependencies = new Map(); + const blockedFeatures = new Map(); + + // Initialize graph structures + for (const feature of features) { + inDegree.set(feature.id, 0); + adjacencyList.set(feature.id, []); + } + + // Build dependency graph and detect missing/blocked dependencies + for (const feature of features) { + const deps = feature.dependencies || []; + for (const depId of deps) { + if (!featureMap.has(depId)) { + // Missing dependency - track it + if (!missingDependencies.has(feature.id)) { + missingDependencies.set(feature.id, []); + } + missingDependencies.get(feature.id)!.push(depId); + } else { + // Valid dependency - add edge to graph + adjacencyList.get(depId)!.push(feature.id); + inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1); + + // Check if dependency is incomplete (blocking) + const depFeature = featureMap.get(depId)!; + if (depFeature.status !== 'completed' && depFeature.status !== 'verified') { + if (!blockedFeatures.has(feature.id)) { + blockedFeatures.set(feature.id, []); + } + blockedFeatures.get(feature.id)!.push(depId); + } + } + } + } + + // Kahn's algorithm with priority-aware selection + const queue: Feature[] = []; + const orderedFeatures: Feature[] = []; + + // Helper to sort features by priority (lower number = higher priority) + const sortByPriority = (a: Feature, b: Feature) => + (a.priority ?? 2) - (b.priority ?? 2); + + // Start with features that have no dependencies (in-degree 0) + for (const [id, degree] of inDegree) { + if (degree === 0) { + queue.push(featureMap.get(id)!); + } + } + + // Sort initial queue by priority + queue.sort(sortByPriority); + + // Process features in topological order + while (queue.length > 0) { + // Take highest priority feature from queue + const current = queue.shift()!; + orderedFeatures.push(current); + + // Process features that depend on this one + for (const dependentId of adjacencyList.get(current.id) || []) { + const currentDegree = inDegree.get(dependentId); + if (currentDegree === undefined) { + throw new Error(`In-degree not initialized for feature ${dependentId}`); + } + const newDegree = currentDegree - 1; + inDegree.set(dependentId, newDegree); + + if (newDegree === 0) { + queue.push(featureMap.get(dependentId)!); + // Re-sort queue to maintain priority order + queue.sort(sortByPriority); + } + } + } + + // Detect circular dependencies (features not in output = part of cycle) + const circularDependencies: string[][] = []; + const processedIds = new Set(orderedFeatures.map(f => f.id)); + + if (orderedFeatures.length < features.length) { + // Find cycles using DFS + const remaining = features.filter(f => !processedIds.has(f.id)); + const cycles = detectCycles(remaining, featureMap); + circularDependencies.push(...cycles); + + // Add remaining features at end (part of cycles) + orderedFeatures.push(...remaining); + } + + return { + orderedFeatures, + circularDependencies, + missingDependencies, + blockedFeatures + }; +} + +/** + * Detects circular dependencies using depth-first search + * + * @param features - Features that couldn't be topologically sorted (potential cycles) + * @param featureMap - Map of all features by ID + * @returns Array of cycles, where each cycle is an array of feature IDs + */ +function detectCycles( + features: Feature[], + featureMap: Map +): string[][] { + const cycles: string[][] = []; + const visited = new Set(); + const recursionStack = new Set(); + const currentPath: string[] = []; + + function dfs(featureId: string): boolean { + visited.add(featureId); + recursionStack.add(featureId); + currentPath.push(featureId); + + const feature = featureMap.get(featureId); + if (feature) { + for (const depId of feature.dependencies || []) { + if (!visited.has(depId)) { + if (dfs(depId)) return true; + } else if (recursionStack.has(depId)) { + // Found cycle - extract it + const cycleStart = currentPath.indexOf(depId); + cycles.push(currentPath.slice(cycleStart)); + return true; + } + } + } + + currentPath.pop(); + recursionStack.delete(featureId); + return false; + } + + for (const feature of features) { + if (!visited.has(feature.id)) { + dfs(feature.id); + } + } + + return cycles; +} + +/** + * Checks if a feature's dependencies are satisfied (all complete or verified) + * + * @param feature - Feature to check + * @param allFeatures - All features in the project + * @returns true if all dependencies are satisfied, false otherwise + */ +export function areDependenciesSatisfied( + feature: Feature, + allFeatures: Feature[] +): boolean { + if (!feature.dependencies || feature.dependencies.length === 0) { + return true; // No dependencies = always ready + } + + return feature.dependencies.every(depId => { + const dep = allFeatures.find(f => f.id === depId); + return dep && (dep.status === 'completed' || dep.status === 'verified'); + }); +} + +/** + * Gets the blocking dependencies for a feature (dependencies that are incomplete) + * + * @param feature - Feature to check + * @param allFeatures - All features in the project + * @returns Array of feature IDs that are blocking this feature + */ +export function getBlockingDependencies( + feature: Feature, + allFeatures: Feature[] +): string[] { + if (!feature.dependencies || feature.dependencies.length === 0) { + return []; + } + + return feature.dependencies.filter(depId => { + const dep = allFeatures.find(f => f.id === depId); + return dep && dep.status !== 'completed' && dep.status !== 'verified'; + }); +} diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index faf95ad1..6b1af019 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -394,6 +394,7 @@ export interface AppState { // Feature Default Settings defaultSkipTests: boolean; // Default value for skip tests when creating new features + enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true) // Worktree Settings useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false) @@ -580,6 +581,7 @@ export interface AppActions { // Feature Default Settings actions setDefaultSkipTests: (skip: boolean) => void; + setEnableDependencyBlocking: (enabled: boolean) => void; // Worktree Settings actions setUseWorktrees: (enabled: boolean) => void; @@ -750,6 +752,7 @@ const initialState: AppState = { maxConcurrency: 3, // Default to 3 concurrent agents kanbanCardDetailLevel: "standard", // Default to standard detail level defaultSkipTests: true, // Default to manual verification (tests disabled) + enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI) useWorktrees: false, // Default to disabled (worktree feature is experimental) currentWorktreeByProject: {}, worktreesByProject: {}, @@ -1341,6 +1344,7 @@ export const useAppStore = create()( // Feature Default Settings actions setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }), + setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }), // Worktree Settings actions setUseWorktrees: (enabled) => set({ useWorktrees: enabled }), @@ -2232,6 +2236,7 @@ export const useAppStore = create()( maxConcurrency: state.maxConcurrency, autoModeByProject: state.autoModeByProject, defaultSkipTests: state.defaultSkipTests, + enableDependencyBlocking: state.enableDependencyBlocking, useWorktrees: state.useWorktrees, currentWorktreeByProject: state.currentWorktreeByProject, worktreesByProject: state.worktreesByProject, diff --git a/apps/server/src/lib/dependency-resolver.ts b/apps/server/src/lib/dependency-resolver.ts new file mode 100644 index 00000000..784c621d --- /dev/null +++ b/apps/server/src/lib/dependency-resolver.ts @@ -0,0 +1,221 @@ +/** + * Dependency Resolution Utility (Server-side) + * + * Provides topological sorting and dependency analysis for features. + * Uses a modified Kahn's algorithm that respects both dependencies and priorities. + */ + +import type { Feature } from "../services/feature-loader.js"; + +export interface DependencyResolutionResult { + orderedFeatures: Feature[]; // Features in dependency-aware order + circularDependencies: string[][]; // Groups of IDs forming cycles + missingDependencies: Map; // featureId -> missing dep IDs + blockedFeatures: Map; // featureId -> blocking dep IDs (incomplete dependencies) +} + +/** + * Resolves feature dependencies using topological sort with priority-aware ordering. + * + * Algorithm: + * 1. Build dependency graph and detect missing/blocked dependencies + * 2. Apply Kahn's algorithm for topological sort + * 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low) + * 4. Detect circular dependencies for features that can't be ordered + * + * @param features - Array of features to order + * @returns Resolution result with ordered features and dependency metadata + */ +export function resolveDependencies(features: Feature[]): DependencyResolutionResult { + const featureMap = new Map(features.map(f => [f.id, f])); + const inDegree = new Map(); + const adjacencyList = new Map(); // dependencyId -> [dependentIds] + const missingDependencies = new Map(); + const blockedFeatures = new Map(); + + // Initialize graph structures + for (const feature of features) { + inDegree.set(feature.id, 0); + adjacencyList.set(feature.id, []); + } + + // Build dependency graph and detect missing/blocked dependencies + for (const feature of features) { + const deps = feature.dependencies || []; + for (const depId of deps) { + if (!featureMap.has(depId)) { + // Missing dependency - track it + if (!missingDependencies.has(feature.id)) { + missingDependencies.set(feature.id, []); + } + missingDependencies.get(feature.id)!.push(depId); + } else { + // Valid dependency - add edge to graph + adjacencyList.get(depId)!.push(feature.id); + inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1); + + // Check if dependency is incomplete (blocking) + const depFeature = featureMap.get(depId)!; + if (depFeature.status !== 'completed' && depFeature.status !== 'verified') { + if (!blockedFeatures.has(feature.id)) { + blockedFeatures.set(feature.id, []); + } + blockedFeatures.get(feature.id)!.push(depId); + } + } + } + } + + // Kahn's algorithm with priority-aware selection + const queue: Feature[] = []; + const orderedFeatures: Feature[] = []; + + // Helper to sort features by priority (lower number = higher priority) + const sortByPriority = (a: Feature, b: Feature) => + (a.priority ?? 2) - (b.priority ?? 2); + + // Start with features that have no dependencies (in-degree 0) + for (const [id, degree] of inDegree) { + if (degree === 0) { + queue.push(featureMap.get(id)!); + } + } + + // Sort initial queue by priority + queue.sort(sortByPriority); + + // Process features in topological order + while (queue.length > 0) { + // Take highest priority feature from queue + const current = queue.shift()!; + orderedFeatures.push(current); + + // Process features that depend on this one + for (const dependentId of adjacencyList.get(current.id) || []) { + const currentDegree = inDegree.get(dependentId); + if (currentDegree === undefined) { + throw new Error(`In-degree not initialized for feature ${dependentId}`); + } + const newDegree = currentDegree - 1; + inDegree.set(dependentId, newDegree); + + if (newDegree === 0) { + queue.push(featureMap.get(dependentId)!); + // Re-sort queue to maintain priority order + queue.sort(sortByPriority); + } + } + } + + // Detect circular dependencies (features not in output = part of cycle) + const circularDependencies: string[][] = []; + const processedIds = new Set(orderedFeatures.map(f => f.id)); + + if (orderedFeatures.length < features.length) { + // Find cycles using DFS + const remaining = features.filter(f => !processedIds.has(f.id)); + const cycles = detectCycles(remaining, featureMap); + circularDependencies.push(...cycles); + + // Add remaining features at end (part of cycles) + orderedFeatures.push(...remaining); + } + + return { + orderedFeatures, + circularDependencies, + missingDependencies, + blockedFeatures + }; +} + +/** + * Detects circular dependencies using depth-first search + * + * @param features - Features that couldn't be topologically sorted (potential cycles) + * @param featureMap - Map of all features by ID + * @returns Array of cycles, where each cycle is an array of feature IDs + */ +function detectCycles( + features: Feature[], + featureMap: Map +): string[][] { + const cycles: string[][] = []; + const visited = new Set(); + const recursionStack = new Set(); + const currentPath: string[] = []; + + function dfs(featureId: string): boolean { + visited.add(featureId); + recursionStack.add(featureId); + currentPath.push(featureId); + + const feature = featureMap.get(featureId); + if (feature) { + for (const depId of feature.dependencies || []) { + if (!visited.has(depId)) { + if (dfs(depId)) return true; + } else if (recursionStack.has(depId)) { + // Found cycle - extract it + const cycleStart = currentPath.indexOf(depId); + cycles.push(currentPath.slice(cycleStart)); + return true; + } + } + } + + currentPath.pop(); + recursionStack.delete(featureId); + return false; + } + + for (const feature of features) { + if (!visited.has(feature.id)) { + dfs(feature.id); + } + } + + return cycles; +} + +/** + * Checks if a feature's dependencies are satisfied (all complete or verified) + * + * @param feature - Feature to check + * @param allFeatures - All features in the project + * @returns true if all dependencies are satisfied, false otherwise + */ +export function areDependenciesSatisfied( + feature: Feature, + allFeatures: Feature[] +): boolean { + if (!feature.dependencies || feature.dependencies.length === 0) { + return true; // No dependencies = always ready + } + + return feature.dependencies.every((depId: string) => { + const dep = allFeatures.find(f => f.id === depId); + return dep && (dep.status === 'completed' || dep.status === 'verified'); + }); +} + +/** + * Gets the blocking dependencies for a feature (dependencies that are incomplete) + * + * @param feature - Feature to check + * @param allFeatures - All features in the project + * @returns Array of feature IDs that are blocking this feature + */ +export function getBlockingDependencies( + feature: Feature, + allFeatures: Feature[] +): string[] { + if (!feature.dependencies || feature.dependencies.length === 0) { + return []; + } + + return feature.dependencies.filter((depId: string) => { + const dep = allFeatures.find(f => f.id === depId); + return dep && dep.status !== 'completed' && dep.status !== 'verified'; + }); +} diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 7d779da8..cc7c4bd8 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -20,6 +20,8 @@ import { buildPromptWithImages } from "../lib/prompt-builder.js"; import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js"; import { createAutoModeOptions } from "../lib/sdk-options.js"; import { isAbortError, classifyError } from "../lib/error-handler.js"; +import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js"; +import type { Feature } from "./feature-loader.js"; import { getFeatureDir, getFeaturesDir, @@ -29,26 +31,6 @@ import { const execAsync = promisify(exec); -interface Feature { - id: string; - category: string; - description: string; - steps?: string[]; - status: string; - priority?: number; - spec?: string; - model?: string; // Model to use for this feature - imagePaths?: Array< - | string - | { - path: string; - filename?: string; - mimeType?: string; - [key: string]: unknown; - } - >; -} - interface RunningFeature { featureId: string; projectPath: string; @@ -1014,8 +996,10 @@ Format your response as a structured markdown document.`; try { const entries = await fs.readdir(featuresDir, { withFileTypes: true }); - const features: Feature[] = []; + const allFeatures: Feature[] = []; + const pendingFeatures: Feature[] = []; + // Load all features (for dependency checking) for (const entry of entries) { if (entry.isDirectory()) { const featurePath = path.join( @@ -1026,12 +1010,15 @@ Format your response as a structured markdown document.`; try { const data = await fs.readFile(featurePath, "utf-8"); const feature = JSON.parse(data); + allFeatures.push(feature); + + // Track pending features separately if ( feature.status === "pending" || feature.status === "ready" || feature.status === "backlog" ) { - features.push(feature); + pendingFeatures.push(feature); } } catch { // Skip invalid features @@ -1039,8 +1026,15 @@ Format your response as a structured markdown document.`; } } - // Sort by priority - return features.sort((a, b) => (a.priority || 999) - (b.priority || 999)); + // Apply dependency-aware ordering + const { orderedFeatures } = resolveDependencies(pendingFeatures); + + // Filter to only features with satisfied dependencies + const readyFeatures = orderedFeatures.filter(feature => + areDependenciesSatisfied(feature, allFeatures) + ); + + return readyFeatures; } catch { return []; } diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index fdba7b1e..67850f99 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -19,6 +19,10 @@ export interface Feature { steps?: string[]; passes?: boolean; priority?: number; + status?: string; + dependencies?: string[]; + spec?: string; + model?: string; imagePaths?: Array; [key: string]: unknown; } diff --git a/apps/server/tests/unit/lib/dependency-resolver.test.ts b/apps/server/tests/unit/lib/dependency-resolver.test.ts new file mode 100644 index 00000000..772f1fbe --- /dev/null +++ b/apps/server/tests/unit/lib/dependency-resolver.test.ts @@ -0,0 +1,438 @@ +import { describe, it, expect } from "vitest"; +import { + resolveDependencies, + areDependenciesSatisfied, + getBlockingDependencies, + type DependencyResolutionResult, +} from "@/lib/dependency-resolver.js"; +import type { Feature } from "@/services/feature-loader.js"; + +// Helper to create test features +function createFeature( + id: string, + options: { + status?: string; + priority?: number; + dependencies?: string[]; + category?: string; + description?: string; + } = {} +): Feature { + return { + id, + category: options.category || "test", + description: options.description || `Feature ${id}`, + status: options.status || "backlog", + priority: options.priority, + dependencies: options.dependencies, + }; +} + +describe("dependency-resolver.ts", () => { + describe("resolveDependencies", () => { + it("should handle empty feature list", () => { + const result = resolveDependencies([]); + + expect(result.orderedFeatures).toEqual([]); + expect(result.circularDependencies).toEqual([]); + expect(result.missingDependencies.size).toBe(0); + expect(result.blockedFeatures.size).toBe(0); + }); + + it("should handle features with no dependencies", () => { + const features = [ + createFeature("f1", { priority: 1 }), + createFeature("f2", { priority: 2 }), + createFeature("f3", { priority: 3 }), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures).toHaveLength(3); + expect(result.orderedFeatures[0].id).toBe("f1"); // Highest priority first + expect(result.orderedFeatures[1].id).toBe("f2"); + expect(result.orderedFeatures[2].id).toBe("f3"); + expect(result.circularDependencies).toEqual([]); + expect(result.missingDependencies.size).toBe(0); + expect(result.blockedFeatures.size).toBe(0); + }); + + it("should order features by dependencies (simple chain)", () => { + const features = [ + createFeature("f3", { dependencies: ["f2"] }), + createFeature("f1"), + createFeature("f2", { dependencies: ["f1"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures).toHaveLength(3); + expect(result.orderedFeatures[0].id).toBe("f1"); + expect(result.orderedFeatures[1].id).toBe("f2"); + expect(result.orderedFeatures[2].id).toBe("f3"); + expect(result.circularDependencies).toEqual([]); + }); + + it("should respect priority within same dependency level", () => { + const features = [ + createFeature("f1", { priority: 3, dependencies: ["base"] }), + createFeature("f2", { priority: 1, dependencies: ["base"] }), + createFeature("f3", { priority: 2, dependencies: ["base"] }), + createFeature("base"), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures[0].id).toBe("base"); + expect(result.orderedFeatures[1].id).toBe("f2"); // Priority 1 + expect(result.orderedFeatures[2].id).toBe("f3"); // Priority 2 + expect(result.orderedFeatures[3].id).toBe("f1"); // Priority 3 + }); + + it("should use default priority of 2 when not specified", () => { + const features = [ + createFeature("f1", { priority: 1 }), + createFeature("f2"), // No priority = default 2 + createFeature("f3", { priority: 3 }), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures[0].id).toBe("f1"); + expect(result.orderedFeatures[1].id).toBe("f2"); + expect(result.orderedFeatures[2].id).toBe("f3"); + }); + + it("should detect missing dependencies", () => { + const features = [ + createFeature("f1", { dependencies: ["missing1", "missing2"] }), + createFeature("f2", { dependencies: ["f1", "missing3"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.missingDependencies.size).toBe(2); + expect(result.missingDependencies.get("f1")).toEqual(["missing1", "missing2"]); + expect(result.missingDependencies.get("f2")).toEqual(["missing3"]); + expect(result.orderedFeatures).toHaveLength(2); + }); + + it("should detect blocked features (incomplete dependencies)", () => { + const features = [ + createFeature("f1", { status: "in_progress" }), + createFeature("f2", { status: "backlog", dependencies: ["f1"] }), + createFeature("f3", { status: "completed" }), + createFeature("f4", { status: "backlog", dependencies: ["f3"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.blockedFeatures.size).toBe(1); + expect(result.blockedFeatures.get("f2")).toEqual(["f1"]); + expect(result.blockedFeatures.has("f4")).toBe(false); // f3 is completed + }); + + it("should not block features whose dependencies are verified", () => { + const features = [ + createFeature("f1", { status: "verified" }), + createFeature("f2", { status: "backlog", dependencies: ["f1"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.blockedFeatures.size).toBe(0); + }); + + it("should detect circular dependencies (simple cycle)", () => { + const features = [ + createFeature("f1", { dependencies: ["f2"] }), + createFeature("f2", { dependencies: ["f1"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies).toHaveLength(1); + expect(result.circularDependencies[0]).toContain("f1"); + expect(result.circularDependencies[0]).toContain("f2"); + expect(result.orderedFeatures).toHaveLength(2); // Features still included + }); + + it("should detect circular dependencies (multi-node cycle)", () => { + const features = [ + createFeature("f1", { dependencies: ["f3"] }), + createFeature("f2", { dependencies: ["f1"] }), + createFeature("f3", { dependencies: ["f2"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies.length).toBeGreaterThan(0); + expect(result.orderedFeatures).toHaveLength(3); + }); + + it("should handle mixed valid and circular dependencies", () => { + const features = [ + createFeature("base"), + createFeature("f1", { dependencies: ["base", "f2"] }), + createFeature("f2", { dependencies: ["f1"] }), // Circular with f1 + createFeature("f3", { dependencies: ["base"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies.length).toBeGreaterThan(0); + expect(result.orderedFeatures[0].id).toBe("base"); + expect(result.orderedFeatures).toHaveLength(4); + }); + + it("should handle complex dependency graph", () => { + const features = [ + createFeature("ui", { dependencies: ["api", "auth"], priority: 1 }), + createFeature("api", { dependencies: ["db"], priority: 2 }), + createFeature("auth", { dependencies: ["db"], priority: 1 }), + createFeature("db", { priority: 1 }), + createFeature("tests", { dependencies: ["ui"], priority: 3 }), + ]; + + const result = resolveDependencies(features); + + const order = result.orderedFeatures.map(f => f.id); + + expect(order[0]).toBe("db"); + expect(order.indexOf("db")).toBeLessThan(order.indexOf("api")); + expect(order.indexOf("db")).toBeLessThan(order.indexOf("auth")); + expect(order.indexOf("api")).toBeLessThan(order.indexOf("ui")); + expect(order.indexOf("auth")).toBeLessThan(order.indexOf("ui")); + expect(order.indexOf("ui")).toBeLessThan(order.indexOf("tests")); + expect(result.circularDependencies).toEqual([]); + }); + + it("should handle features with empty dependencies array", () => { + const features = [ + createFeature("f1", { dependencies: [] }), + createFeature("f2", { dependencies: [] }), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures).toHaveLength(2); + expect(result.circularDependencies).toEqual([]); + expect(result.blockedFeatures.size).toBe(0); + }); + + it("should track multiple blocking dependencies", () => { + const features = [ + createFeature("f1", { status: "in_progress" }), + createFeature("f2", { status: "backlog" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.blockedFeatures.get("f3")).toEqual(["f1", "f2"]); + }); + + it("should handle self-referencing dependency", () => { + const features = [ + createFeature("f1", { dependencies: ["f1"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies.length).toBeGreaterThan(0); + expect(result.orderedFeatures).toHaveLength(1); + }); + }); + + describe("areDependenciesSatisfied", () => { + it("should return true for feature with no dependencies", () => { + const feature = createFeature("f1"); + const allFeatures = [feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true); + }); + + it("should return true for feature with empty dependencies array", () => { + const feature = createFeature("f1", { dependencies: [] }); + const allFeatures = [feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true); + }); + + it("should return true when all dependencies are completed", () => { + const allFeatures = [ + createFeature("f1", { status: "completed" }), + createFeature("f2", { status: "completed" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true); + }); + + it("should return true when all dependencies are verified", () => { + const allFeatures = [ + createFeature("f1", { status: "verified" }), + createFeature("f2", { status: "verified" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true); + }); + + it("should return true when dependencies are mix of completed and verified", () => { + const allFeatures = [ + createFeature("f1", { status: "completed" }), + createFeature("f2", { status: "verified" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true); + }); + + it("should return false when any dependency is in_progress", () => { + const allFeatures = [ + createFeature("f1", { status: "completed" }), + createFeature("f2", { status: "in_progress" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false); + }); + + it("should return false when any dependency is in backlog", () => { + const allFeatures = [ + createFeature("f1", { status: "completed" }), + createFeature("f2", { status: "backlog" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false); + }); + + it("should return false when dependency is missing", () => { + const allFeatures = [ + createFeature("f1", { status: "backlog", dependencies: ["missing"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[0], allFeatures)).toBe(false); + }); + + it("should return false when multiple dependencies are incomplete", () => { + const allFeatures = [ + createFeature("f1", { status: "backlog" }), + createFeature("f2", { status: "in_progress" }), + createFeature("f3", { status: "waiting_approval" }), + createFeature("f4", { status: "backlog", dependencies: ["f1", "f2", "f3"] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[3], allFeatures)).toBe(false); + }); + }); + + describe("getBlockingDependencies", () => { + it("should return empty array for feature with no dependencies", () => { + const feature = createFeature("f1"); + const allFeatures = [feature]; + + expect(getBlockingDependencies(feature, allFeatures)).toEqual([]); + }); + + it("should return empty array for feature with empty dependencies array", () => { + const feature = createFeature("f1", { dependencies: [] }); + const allFeatures = [feature]; + + expect(getBlockingDependencies(feature, allFeatures)).toEqual([]); + }); + + it("should return empty array when all dependencies are completed", () => { + const allFeatures = [ + createFeature("f1", { status: "completed" }), + createFeature("f2", { status: "completed" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]); + }); + + it("should return empty array when all dependencies are verified", () => { + const allFeatures = [ + createFeature("f1", { status: "verified" }), + createFeature("f2", { status: "verified" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]); + }); + + it("should return blocking dependencies in backlog status", () => { + const allFeatures = [ + createFeature("f1", { status: "backlog" }), + createFeature("f2", { status: "completed" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]); + }); + + it("should return blocking dependencies in in_progress status", () => { + const allFeatures = [ + createFeature("f1", { status: "in_progress" }), + createFeature("f2", { status: "verified" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]); + }); + + it("should return blocking dependencies in waiting_approval status", () => { + const allFeatures = [ + createFeature("f1", { status: "waiting_approval" }), + createFeature("f2", { status: "completed" }), + createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]); + }); + + it("should return all blocking dependencies", () => { + const allFeatures = [ + createFeature("f1", { status: "backlog" }), + createFeature("f2", { status: "in_progress" }), + createFeature("f3", { status: "waiting_approval" }), + createFeature("f4", { status: "completed" }), + createFeature("f5", { status: "backlog", dependencies: ["f1", "f2", "f3", "f4"] }), + ]; + + const blocking = getBlockingDependencies(allFeatures[4], allFeatures); + expect(blocking).toHaveLength(3); + expect(blocking).toContain("f1"); + expect(blocking).toContain("f2"); + expect(blocking).toContain("f3"); + expect(blocking).not.toContain("f4"); + }); + + it("should handle missing dependencies", () => { + const allFeatures = [ + createFeature("f1", { status: "backlog", dependencies: ["missing"] }), + ]; + + // Missing dependencies won't be in the blocking list since they don't exist + expect(getBlockingDependencies(allFeatures[0], allFeatures)).toEqual([]); + }); + + it("should handle mix of completed, verified, and incomplete dependencies", () => { + const allFeatures = [ + createFeature("f1", { status: "completed" }), + createFeature("f2", { status: "verified" }), + createFeature("f3", { status: "in_progress" }), + createFeature("f4", { status: "backlog" }), + createFeature("f5", { status: "backlog", dependencies: ["f1", "f2", "f3", "f4"] }), + ]; + + const blocking = getBlockingDependencies(allFeatures[4], allFeatures); + expect(blocking).toHaveLength(2); + expect(blocking).toContain("f3"); + expect(blocking).toContain("f4"); + }); + }); +});