diff --git a/src/electron/main.ts b/src/electron/main.ts index 014732b..9e12354 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -73,6 +73,10 @@ import { getWorkflowItemActivityArchiveItemId, MAX_LIVE_WORKFLOW_ITEM_ACTIVITY_EVENTS, } from '@/shared/workflow/activity'; +import { + isDependencyResolved, + isItemBlocked, +} from '@/shared/workflow/dependency-utils'; import { shouldScheduleItemAssignmentTask } from '@/shared/workflow/item-assignment'; if (started) { @@ -647,6 +651,57 @@ void app.whenReady().then(async () => { } } + /** + * Detects dependency-resolution transitions and nudges newly unblocked item + * assignees through the runtime. + */ + async function reconcileDependencies(previous: unknown, next: unknown): Promise { + if (!runtimeController || !isWorkflowSnapshotLike(previous) || !isWorkflowSnapshotLike(next)) { + return; + } + + const previousItemsById = new Map(previous.items.map((item) => [item.id, item] as const)); + const notifiedItemIds = new Set(); + + for (const item of next.items) { + const previousItem = previousItemsById.get(item.id); + + if (!previousItem || isDependencyResolved(previousItem) || !isDependencyResolved(item)) { + continue; + } + + for (const dependentItem of next.items) { + if ( + !dependentItem.dependsOn?.includes(item.id) || + !dependentItem.primaryAgentId || + notifiedItemIds.has(dependentItem.id) + ) { + continue; + } + + const previousDependentItem = previousItemsById.get(dependentItem.id); + + if ( + !previousDependentItem || + !isItemBlocked(previousDependentItem, previous.items) || + isItemBlocked(dependentItem, next.items) + ) { + continue; + } + + notifiedItemIds.add(dependentItem.id); + await runtimeController.postSystemMessage( + dependentItem.primaryAgentId, + [ + `Dependency resolved for work item "${dependentItem.title}" (id: ${dependentItem.id}).`, + `"${item.title}" reached ${item.status}, so all dependencies are now satisfied.`, + 'You can resume work when ready.', + ].join(' '), + ).catch(() => {}); + } + } + } + /** * Periodic sweep: for every item assigned to an agent and still in a * lane that should keep an assignment task, @@ -719,6 +774,7 @@ void app.whenReady().then(async () => { const previous = await stores.workflow.get('snapshot'); await reconcileAssignments(previous, value); + await reconcileDependencies(previous, value); if (isWorkflowSnapshotLike(value)) { await compactWorkflowActivity(value); } diff --git a/src/electron/main/agent-actions/handlers/items.test.ts b/src/electron/main/agent-actions/handlers/items.test.ts new file mode 100644 index 0000000..6ce7a14 --- /dev/null +++ b/src/electron/main/agent-actions/handlers/items.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; + +import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity'; + +import { itemTools } from './items'; +import type { WorkflowSnapshot } from './snapshot'; +import type { ToolServices } from './types'; + +function createSnapshot(): WorkflowSnapshot { + return { + items: [ + { + activity: createWorkflowItemActivitySummary({ totalEventCount: 0 }), + artifactFolderName: 'item-1', + brief: 'Blocked item', + createdAt: 1, + id: 'item-1', + primaryAgentId: 'agent-1', + projectId: 'project-1', + scheduledTaskId: null, + sortOrder: 0, + status: 'ready', + tasks: [], + title: 'Blocked item', + updatedAt: 1, + workProducts: [], + workflowEvents: [], + }, + { + activity: createWorkflowItemActivitySummary({ totalEventCount: 0 }), + artifactFolderName: 'item-2', + brief: 'Dependency item', + createdAt: 2, + id: 'item-2', + primaryAgentId: null, + projectId: 'project-1', + scheduledTaskId: null, + sortOrder: 1, + status: 'active', + tasks: [], + title: 'Dependency item', + updatedAt: 2, + workProducts: [], + workflowEvents: [], + }, + { + activity: createWorkflowItemActivitySummary({ totalEventCount: 0 }), + artifactFolderName: 'item-3', + brief: 'Third item', + createdAt: 3, + dependsOn: ['item-1'], + id: 'item-3', + primaryAgentId: null, + projectId: 'project-1', + scheduledTaskId: null, + sortOrder: 2, + status: 'inbox', + tasks: [], + title: 'Third item', + updatedAt: 3, + workProducts: [], + workflowEvents: [], + }, + ], + projects: [ + { + color: '#000000', + createdAt: 1, + description: 'Project', + id: 'project-1', + name: 'Project', + rootPath: null, + updatedAt: 1, + }, + ], + selectedItemId: null, + selectedProjectFilter: 'all', + selectedProjectId: 'project-1', + selectedProjectView: 'board', + }; +} + +function createServices(snapshot: WorkflowSnapshot): ToolServices & { getSnapshot: () => WorkflowSnapshot } { + let currentSnapshot = structuredClone(snapshot); + + return { + agentContext: { + agentId: 'agent-1', + agentName: 'Navigator', + ipcContainerDir: '/workspace/extra/dune', + ipcHostDir: '/tmp/dune', + projectId: 'project-1', + }, + getRuntimeController: () => ({ + getSnapshot: () => ({ + agents: [ + { + definition: { archetype: 'worker', responsibilities: [] }, + id: 'agent-1', + name: 'Navigator', + projectId: 'project-1', + status: 'ready', + updatedAt: 1, + }, + ], + }), + }) as never, + getSnapshot: () => currentSnapshot, + onWorkflowChanged: () => undefined, + workflowStore: { + delete: async () => undefined, + get: async (key: string) => (key === 'snapshot' ? structuredClone(currentSnapshot) as T : null), + keys: async () => ['snapshot'], + set: async (key: string, value: T) => { + if (key === 'snapshot') { + currentSnapshot = structuredClone(value as WorkflowSnapshot); + } + }, + }, + }; +} + +describe('item dependency handlers', () => { + it('adds and removes dependencies on work items', async () => { + const services = createServices(createSnapshot()); + const addDependency = itemTools.find((tool) => tool.definition.name === 'workflow.items.add_dependency')!.handler; + const removeDependency = itemTools.find((tool) => tool.definition.name === 'workflow.items.remove_dependency')!.handler; + + await addDependency(services, { + dependsOnId: 'item-2', + itemId: 'item-1', + }); + + let item = services.getSnapshot().items.find((candidate) => candidate.id === 'item-1'); + expect(item?.dependsOn).toEqual(['item-2']); + expect(item?.workflowEvents[0]?.description).toBe('Added dependency on "Dependency item".'); + + await removeDependency(services, { + dependsOnId: 'item-2', + itemId: 'item-1', + }); + + item = services.getSnapshot().items.find((candidate) => candidate.id === 'item-1'); + expect(item?.dependsOn).toBeUndefined(); + expect(item?.workflowEvents[0]?.description).toBe('Removed dependency on "Dependency item".'); + }); + + it('rejects circular dependency updates', async () => { + const services = createServices(createSnapshot()); + const addDependency = itemTools.find((tool) => tool.definition.name === 'workflow.items.add_dependency')!.handler; + + await expect(addDependency(services, { + dependsOnId: 'item-3', + itemId: 'item-1', + })).rejects.toThrow('Cannot create a circular dependency.'); + }); + + it('rejects moves to active while dependencies are unresolved', async () => { + const services = createServices(createSnapshot()); + const addDependency = itemTools.find((tool) => tool.definition.name === 'workflow.items.add_dependency')!.handler; + const moveItem = itemTools.find((tool) => tool.definition.name === 'workflow.items.move')!.handler; + + await addDependency(services, { + dependsOnId: 'item-2', + itemId: 'item-1', + }); + + await expect(moveItem(services, { + itemId: 'item-1', + status: 'active', + })).rejects.toThrow( + 'Cannot move item to active: it has unresolved dependencies. All dependencies must reach done or acceptance first.', + ); + }); +}); diff --git a/src/electron/main/agent-actions/handlers/items.ts b/src/electron/main/agent-actions/handlers/items.ts index 9418bec..e94517f 100644 --- a/src/electron/main/agent-actions/handlers/items.ts +++ b/src/electron/main/agent-actions/handlers/items.ts @@ -4,6 +4,10 @@ import { createId } from '@/shared/id'; import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity'; import { createDefaultTasks } from '@/shared/workflow/default-tasks'; import { createArtifactFolderName } from '@/shared/workflow/project-artifacts'; +import { + hasCircularDependency, + normalizeDependencyIds, +} from '@/shared/workflow/dependency-utils'; import { ensureProjectArtifactFolder } from '@/electron/main/workflow/project-artifacts'; import { @@ -212,6 +216,113 @@ export const itemTools: RegisteredTool[] = [ return { item: presentItem(snapshot, item) }; }, }, + { + definition: { + description: 'Add a dependency to a Dune work item.', + inputSchema: objectSchema( + { + dependsOnId: stringSchema, + itemId: stringSchema, + }, + ['itemId', 'dependsOnId'], + ), + name: 'workflow.items.add_dependency', + }, + handler: async ({ agentContext, onWorkflowChanged, workflowStore }, args) => { + const snapshot = await readWorkflowSnapshot(workflowStore); + const item = findItem(snapshot, requireString(args.itemId, 'itemId')); + const dependency = findItem(snapshot, requireString(args.dependsOnId, 'dependsOnId')); + + if (item.projectId !== dependency.projectId) { + throw new ToolHandlerError( + 'validation-error', + 'Dependencies must point to items in the same project.', + ); + } + + if (item.id === dependency.id) { + throw new ToolHandlerError( + 'validation-error', + 'A work item cannot depend on itself.', + ); + } + + const currentDependencyIds = normalizeDependencyIds(item.dependsOn) ?? []; + + if (currentDependencyIds.includes(dependency.id)) { + return { item: presentItem(snapshot, item) }; + } + + const nextDependencyIds = [...currentDependencyIds, dependency.id]; + + if (hasCircularDependency(item.id, nextDependencyIds, snapshot.items)) { + throw new ToolHandlerError( + 'validation-error', + 'Cannot create a circular dependency.', + ); + } + + const now = Date.now(); + item.dependsOn = nextDependencyIds; + item.updatedAt = now; + prependWorkflowEvents(item, [ + createWorkflowEvent('item', `Added dependency on "${dependency.title}".`, now, agentContext.agentName), + ]); + touchProject(snapshot, item.projectId, now); + + await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged); + return { item: presentItem(snapshot, item) }; + }, + }, + { + definition: { + description: 'Remove a dependency from a Dune work item.', + inputSchema: objectSchema( + { + dependsOnId: stringSchema, + itemId: stringSchema, + }, + ['itemId', 'dependsOnId'], + ), + name: 'workflow.items.remove_dependency', + }, + handler: async ({ agentContext, onWorkflowChanged, workflowStore }, args) => { + const snapshot = await readWorkflowSnapshot(workflowStore); + const item = findItem(snapshot, requireString(args.itemId, 'itemId')); + const dependencyId = requireString(args.dependsOnId, 'dependsOnId'); + const currentDependencyIds = normalizeDependencyIds(item.dependsOn) ?? []; + + if (!currentDependencyIds.includes(dependencyId)) { + return { item: presentItem(snapshot, item) }; + } + + const dependency = snapshot.items.find((candidate) => candidate.id === dependencyId) ?? null; + const now = Date.now(); + const nextDependencyIds = currentDependencyIds.filter((currentId) => currentId !== dependencyId); + + if (nextDependencyIds.length > 0) { + item.dependsOn = nextDependencyIds; + } else { + delete item.dependsOn; + } + + item.updatedAt = now; + prependWorkflowEvents(item, [ + createWorkflowEvent( + 'item', + dependency + ? `Removed dependency on "${dependency.title}".` + : `Removed dependency on "${dependencyId}".`, + now, + agentContext.agentName, + ), + ]); + touchProject(snapshot, item.projectId, now); + + await writeWorkflowSnapshot(workflowStore, snapshot, onWorkflowChanged); + return { item: presentItem(snapshot, item) }; + }, + }, { definition: { description: 'Move a Dune work item to a new status.', @@ -253,7 +364,7 @@ export const itemTools: RegisteredTool[] = [ const index = Math.max(0, Math.min(rawIndex, destinationItems.length)); const previousStatus = item.status; - assertAgentCanMoveItem(agentContext.agentId, item, status); + assertAgentCanMoveItem(agentContext.agentId, item, status, snapshot.items); item.status = status; item.updatedAt = now; diff --git a/src/electron/main/agent-actions/handlers/snapshot.ts b/src/electron/main/agent-actions/handlers/snapshot.ts index b94d937..786d8c9 100644 --- a/src/electron/main/agent-actions/handlers/snapshot.ts +++ b/src/electron/main/agent-actions/handlers/snapshot.ts @@ -6,6 +6,7 @@ import { normalizeProjectRootPath, } from '@/shared/workflow/project-artifacts'; import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity'; +import { normalizeDependencyIds } from '@/shared/workflow/dependency-utils'; import type { AppStorage } from '@/electron/main/storage/app-storage'; import { ToolHandlerError } from './types'; @@ -36,6 +37,7 @@ export interface WorkflowItem { artifactFolderName: string; brief: string; createdAt: number; + dependsOn?: string[] | undefined; id: string; primaryAgentId: string | null; projectId: string; @@ -151,6 +153,9 @@ export function cloneWorkflowSnapshot(snapshot: WorkflowSnapshot): WorkflowSnaps typeof item.artifactFolderName === 'string' && item.artifactFolderName.trim() ? item.artifactFolderName.trim() : createArtifactFolderName(item.title, item.id), + ...(normalizeDependencyIds(item.dependsOn) + ? { dependsOn: normalizeDependencyIds(item.dependsOn) } + : {}), scheduledTaskId: item.scheduledTaskId ?? null, tasks: item.tasks.map((task) => ({ ...task })), workProducts: item.workProducts.map((workProduct) => ({ ...workProduct })), @@ -180,6 +185,13 @@ export function normalizeWorkflowSnapshot(snapshot: WorkflowSnapshot): void { typeof item.artifactFolderName === 'string' && item.artifactFolderName.trim() ? item.artifactFolderName.trim() : createArtifactFolderName(item.title, item.id); + const normalizedDependencyIds = normalizeDependencyIds(item.dependsOn); + + if (normalizedDependencyIds) { + item.dependsOn = normalizedDependencyIds; + } else { + delete item.dependsOn; + } } for (const project of snapshot.projects) { diff --git a/src/electron/main/agent-actions/handlers/validators.test.ts b/src/electron/main/agent-actions/handlers/validators.test.ts index 36cceac..cf7b3f9 100644 --- a/src/electron/main/agent-actions/handlers/validators.test.ts +++ b/src/electron/main/agent-actions/handlers/validators.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from 'vitest'; import type { WorkflowItem } from './snapshot'; import { assertAgentCanMoveItem } from './validators'; -function createItem(status: WorkflowItem['status']): WorkflowItem { +function createItem( + status: WorkflowItem['status'], + overrides: Partial = {}, +): WorkflowItem { return { activity: { archivedEventCount: 0, @@ -25,6 +28,7 @@ function createItem(status: WorkflowItem['status']): WorkflowItem { updatedAt: 1, workProducts: [], workflowEvents: [], + ...overrides, }; } @@ -59,6 +63,23 @@ describe('assertAgentCanMoveItem', () => { ).not.toThrow(); }); + it('rejects moves to active when dependencies are still unresolved', () => { + const dependency = createItem('active', { + id: 'item-2', + primaryAgentId: null, + title: 'Dependency item', + }); + const blockedItem = createItem('ready', { + dependsOn: ['item-2'], + }); + + expect(() => + assertAgentCanMoveItem('agent-1', blockedItem, 'active', [blockedItem, dependency]), + ).toThrow( + 'Cannot move item to active: it has unresolved dependencies. All dependencies must reach done or acceptance first.', + ); + }); + it('rejects agent moves out of done', () => { expect(() => assertAgentCanMoveItem('agent-1', createItem('done'), 'active'), diff --git a/src/electron/main/agent-actions/handlers/validators.ts b/src/electron/main/agent-actions/handlers/validators.ts index 8bba677..1c954c0 100644 --- a/src/electron/main/agent-actions/handlers/validators.ts +++ b/src/electron/main/agent-actions/handlers/validators.ts @@ -1,5 +1,7 @@ // validation helpers IPC tool handlers. +import { isItemBlocked } from '@/shared/workflow/dependency-utils'; + import { ToolHandlerError, type RuntimeAgent } from './types'; import { createWorkflowEvent, @@ -51,6 +53,7 @@ export function assertAgentCanMoveItem( agentId: string, item: WorkflowItem, nextStatus: WorkflowItemStatus, + allItems: readonly WorkflowItem[] = [item], ) { if (item.status === 'done') { throw new ToolHandlerError( @@ -82,6 +85,13 @@ export function assertAgentCanMoveItem( ); } + if (nextStatus === 'active' && item.status !== 'active' && isItemBlocked(item, allItems)) { + throw new ToolHandlerError( + 'validation-error', + 'Cannot move item to active: it has unresolved dependencies. All dependencies must reach done or acceptance first.', + ); + } + if (item.status === 'ready') { if (nextStatus !== 'active') { throw new ToolHandlerError( diff --git a/src/electron/main/agent-actions/register-actions.test.ts b/src/electron/main/agent-actions/register-actions.test.ts index 3ac86be..1130a0f 100644 --- a/src/electron/main/agent-actions/register-actions.test.ts +++ b/src/electron/main/agent-actions/register-actions.test.ts @@ -33,6 +33,8 @@ describe('registerDuneActions', () => { const actionNames = action.mock.calls.map(([name]) => name as string); expect(actionNames).toContain('workflow_projects_list'); + expect(actionNames).toContain('workflow_items_add_dependency'); + expect(actionNames).toContain('workflow_items_remove_dependency'); expect(actionNames).not.toContain('coding_engine_claude_code'); expect(actionNames).not.toContain('coding_engine_codex'); expect(actionNames).not.toContain('coding_engine_poll'); diff --git a/src/electron/main/agent-actions/register-actions.ts b/src/electron/main/agent-actions/register-actions.ts index c87bb7e..602e56e 100644 --- a/src/electron/main/agent-actions/register-actions.ts +++ b/src/electron/main/agent-actions/register-actions.ts @@ -146,6 +146,14 @@ export function registerDuneActions( primaryAgentId: z.string().nullable().optional() .describe('Agent ID to assign, or null to unassign. Cannot change while item is done.'), }); + reg('workflow.items.add_dependency', { + itemId: z.string().describe('Work item ID'), + dependsOnId: z.string().describe('Dependency work item ID'), + }); + reg('workflow.items.remove_dependency', { + itemId: z.string().describe('Work item ID'), + dependsOnId: z.string().describe('Dependency work item ID'), + }); reg('workflow.items.move', { itemId: z.string().describe('Work item ID'), note: z.string().optional().describe('Optional note to add to workflow history with this move.'), diff --git a/src/electron/main/runtime/agent-runtime.test.ts b/src/electron/main/runtime/agent-runtime.test.ts index 9afabbb..3bf68a5 100644 --- a/src/electron/main/runtime/agent-runtime.test.ts +++ b/src/electron/main/runtime/agent-runtime.test.ts @@ -101,7 +101,7 @@ interface MockAgent { addChannel: ReturnType; channelDrivers: Map; getGroup: ReturnType; - getTask: ReturnType; + getTask: (taskId: string) => { status: string } | undefined; name: string; off: ReturnType; on: ReturnType; diff --git a/src/renderer/app/AppShell.tsx b/src/renderer/app/AppShell.tsx index 13802a6..f383ae5 100644 --- a/src/renderer/app/AppShell.tsx +++ b/src/renderer/app/AppShell.tsx @@ -263,7 +263,7 @@ export default function AppShell() { route === 'workflow' && selectedProjectScreen === 'main' && !!selectedProject && - (selectedProjectView === 'board' || selectedProjectView === 'agents'); + (selectedProjectView === 'board' || selectedProjectView === 'graph' || selectedProjectView === 'agents'); const isProjectAgentsInitializing = selectedProjectView === 'agents' && runtimeInfo.status === 'starting' && diff --git a/src/renderer/app/hooks/use-workflow-persistence.ts b/src/renderer/app/hooks/use-workflow-persistence.ts index 82097de..e608a6e 100644 --- a/src/renderer/app/hooks/use-workflow-persistence.ts +++ b/src/renderer/app/hooks/use-workflow-persistence.ts @@ -20,6 +20,7 @@ import { normalizeProjectRootPath, } from '@/shared/workflow/project-artifacts'; import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity'; +import { normalizeDependencyIds } from '@/shared/workflow/dependency-utils'; import { isPlainObject } from '@/shared/is-record'; const STORE_NAME = 'workflow'; @@ -180,6 +181,13 @@ function normalizeCurrentSnapshot(value: unknown): WorkflowSnapshot | null { const normalized = normalizeEvent(event); return normalized ? [normalized] : []; }); + const dependencyIds = Array.isArray(item.dependsOn) + ? normalizeDependencyIds( + item.dependsOn.flatMap((dependencyId) => + typeof dependencyId === 'string' ? [dependencyId] : [], + ), + ) + : undefined; const activity = isPlainObject(item.activity) ? createWorkflowItemActivitySummary({ ...( @@ -214,6 +222,7 @@ function normalizeCurrentSnapshot(value: unknown): WorkflowSnapshot | null { : createArtifactFolderName(item.title, item.id), brief: item.brief, createdAt: item.createdAt, + ...(dependencyIds ? { dependsOn: dependencyIds } : {}), id: item.id, primaryAgentId: typeof item.primaryAgentId === 'string' || item.primaryAgentId === null diff --git a/src/renderer/app/store/app-commands.ts b/src/renderer/app/store/app-commands.ts index 94abd48..8ed3ab4 100644 --- a/src/renderer/app/store/app-commands.ts +++ b/src/renderer/app/store/app-commands.ts @@ -245,6 +245,11 @@ export function openWorkflow(projectId?: string | null) { openProjectView('board', projectId); } +/** Opens the dependency graph. */ +export function openDependencyGraph(projectId?: string | null) { + openProjectView('graph', projectId); +} + /** Opens item. */ export function openItem(itemId: string) { withNavigationChange(() => { @@ -367,6 +372,7 @@ export function useAppCommands() { goForward, openAgent, openAgents, + openDependencyGraph, openItem, openPlugins, openProjectActivity, diff --git a/src/renderer/app/store/selectors.ts b/src/renderer/app/store/selectors.ts index dbe061f..d0e16dd 100644 --- a/src/renderer/app/store/selectors.ts +++ b/src/renderer/app/store/selectors.ts @@ -173,18 +173,25 @@ export function useWorkflowSession() { selectedProject?.id ?? selectedProjectId, ); const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const)); - const filteredItems = projectItems.filter((item) => { + const projectItemsWithSummaries = projectItems.map((item) => ({ + item, + summary: presentWorkflowItemSummary(item, projectItems, agentsById, itemActivity), + })); + const filteredItemSummaries = projectItemsWithSummaries.filter(({ item, summary }) => { switch (selectedProjectFilter) { case 'assigned': return Boolean(item.primaryAgentId); case 'blocked': - return item.tasks.some((task) => task.status === 'blocked'); + return summary.hasBlockedTasks || summary.isBlockedByDependencies; case 'review': return item.status === 'review'; default: return true; } - }); + }).map(({ summary }) => summary); + const summariesById = new Map( + projectItemsWithSummaries.map(({ summary }) => [summary.id, summary] as const), + ); const projectItemsByAgent = new Map( projectItems .flatMap((item) => @@ -207,16 +214,14 @@ export function useWorkflowSession() { createdAtLabel: presentWorkflowEventTimestamp(entry.createdAt), })), activitySummary, - filteredItemSummaries: filteredItems.map((item) => - presentWorkflowItemSummary(item, agentsById, itemActivity), - ), + filteredItemSummaries, isWorkflowHydrated, items: projectItems, metrics: { activeCount: projectItems.filter((item) => item.status === 'active').length, agentCount: projectAgents.length, - blockedCount: projectItems.filter((item) => - item.tasks.some((task) => task.status === 'blocked'), + blockedCount: projectItemsWithSummaries.filter(({ summary }) => + summary.hasBlockedTasks || summary.isBlockedByDependencies, ).length, reviewCount: projectItems.filter((item) => item.status === 'review').length, }, @@ -236,9 +241,9 @@ export function useWorkflowSession() { projects, recentItems: sortedProjectItemsByUpdated.slice(0, 4).map((item) => ({ id: item.id, - specialStateLabel: presentWorkflowItemSummary(item, agentsById, itemActivity).specialStateLabel, + specialStateLabel: summariesById.get(item.id)?.specialStateLabel ?? null, title: item.title, - updatedLabel: presentWorkflowItemSummary(item, agentsById, itemActivity).updatedLabel, + updatedLabel: summariesById.get(item.id)?.updatedLabel ?? '', })), selectedItem: selectedItem ? { diff --git a/src/renderer/app/store/types.ts b/src/renderer/app/store/types.ts index ea9f659..1bd1557 100644 --- a/src/renderer/app/store/types.ts +++ b/src/renderer/app/store/types.ts @@ -98,6 +98,12 @@ export interface WorkflowItemActivity { isWorking: boolean; } +/** Workflow mutation result shape. */ +export interface WorkflowMutationResult { + error?: string; + ok: boolean; +} + /** Workflow state. */ export interface WorkflowState extends WorkflowSnapshot { isWorkflowHydrated: boolean; @@ -107,6 +113,7 @@ export interface WorkflowState extends WorkflowSnapshot { /** Workflow actions shape. */ export interface WorkflowActions { + addDependency: (itemId: string, dependsOnId: string, note?: string) => WorkflowMutationResult; addTask: (itemId: string, title: string, note?: string) => string | null; addWorkProduct: (itemId: string, input: { body: string; note?: string; title: string }) => string | null; assignPrimaryAgent: ( @@ -134,6 +141,7 @@ export interface WorkflowActions { hydrateWorkflow: (snapshot: WorkflowSnapshot) => void; moveItem: (itemId: string, status: WorkflowItemStatus, index: number, note?: string) => void; openProjectSettings: () => void; + removeDependency: (itemId: string, dependsOnId: string, note?: string) => WorkflowMutationResult; closeProjectSettings: () => void; selectItem: (itemId: string | null) => void; selectProjectFilter: (filter: WorkflowProjectFilter) => void; @@ -219,8 +227,10 @@ export interface WorkflowSessionState { filteredItemSummaries: Array<{ brief: string; completedTaskCount: number; + dependsOnCount: number; hasBlockedTasks: boolean; id: string; + isBlockedByDependencies: boolean; primaryAgentId: string | null; primaryAgentName: string | null; specialStateLabel: string | null; @@ -228,6 +238,7 @@ export interface WorkflowSessionState { statusLabel: string; title: string; totalTaskCount: number; + unresolvedDependencyCount: number; updatedLabel: string; }>; isWorkflowHydrated: boolean; diff --git a/src/renderer/app/store/workflow-slice.test.ts b/src/renderer/app/store/workflow-slice.test.ts index 24df31b..c3e6a98 100644 --- a/src/renderer/app/store/workflow-slice.test.ts +++ b/src/renderer/app/store/workflow-slice.test.ts @@ -153,6 +153,45 @@ describe('workflow slice', () => { expect(rejectionItemAfter?.tasks.length).toBeGreaterThan(previousTaskCount); }); + it('prevents blocked items from moving into active and rejects circular dependencies locally', () => { + const state = useAppStore.getState(); + const projectId = state.selectedProjectId; + + if (!projectId) { + throw new Error('Expected a seeded project.'); + } + + const dependencyId = state.createItem({ + brief: 'Finish this prerequisite first.', + projectId, + status: 'ready', + title: 'Dependency item', + }); + const blockedItemId = state.createItem({ + brief: 'This work must wait.', + projectId, + status: 'ready', + title: 'Blocked item', + }); + + if (!dependencyId || !blockedItemId) { + throw new Error('Expected ready items to be created.'); + } + + expect(state.addDependency(blockedItemId, dependencyId).ok).toBe(true); + + useAppStore.getState().moveItem(blockedItemId, 'active', 0); + + const blockedItem = useAppStore.getState().items.find((item) => item.id === blockedItemId); + expect(blockedItem?.status).toBe('ready'); + + const cycleResult = useAppStore.getState().addDependency(dependencyId, blockedItemId); + expect(cycleResult).toEqual({ + error: 'Cannot create a circular dependency.', + ok: false, + }); + }); + it('keeps primary-agent ownership exclusive across work items', async () => { const state = useAppStore.getState(); const projectId = state.selectedProjectId; diff --git a/src/renderer/app/store/workflow-slice.ts b/src/renderer/app/store/workflow-slice.ts index b9d2e63..9c56b1b 100644 --- a/src/renderer/app/store/workflow-slice.ts +++ b/src/renderer/app/store/workflow-slice.ts @@ -3,6 +3,7 @@ import type { AppStoreSlice } from '@/renderer/app/store/types'; import type { WorkflowActions, + WorkflowMutationResult, WorkflowSlice, WorkflowState, } from '@/renderer/app/store/types'; @@ -27,6 +28,11 @@ import { normalizeProjectRootPath, } from '@/shared/workflow/project-artifacts'; import { createWorkflowItemActivitySummary } from '@/shared/workflow/activity'; +import { + hasCircularDependency, + isItemBlocked, + normalizeDependencyIds, +} from '@/shared/workflow/dependency-utils'; const defaultProjectColors = ['#A86D46', '#7A8B5D', '#4F7A78', '#9D6A71', '#6C69A6'] as const; @@ -309,6 +315,81 @@ export function createWorkflowSlice( return taskId; }, + addDependency: (itemId, dependsOnId, note) => { + const normalizedNote = normalizeNote(note); + let result: WorkflowMutationResult = { ok: false, error: 'Work item not found.' }; + + set((state) => { + const targetItem = state.items.find((item) => item.id === itemId) ?? null; + const dependencyItem = state.items.find((item) => item.id === dependsOnId) ?? null; + + if (!targetItem) { + result = { ok: false, error: 'Work item not found.' }; + return state; + } + + if (!dependencyItem) { + result = { ok: false, error: 'Dependency item not found.' }; + return state; + } + + if (targetItem.projectId !== dependencyItem.projectId) { + result = { ok: false, error: 'Dependencies must stay within the same project.' }; + return state; + } + + if (targetItem.id === dependencyItem.id) { + result = { ok: false, error: 'A work item cannot depend on itself.' }; + return state; + } + + const currentDependencyIds = normalizeDependencyIds(targetItem.dependsOn) ?? []; + + if (currentDependencyIds.includes(dependsOnId)) { + result = { ok: true }; + return state; + } + + const nextDependencyIds = [...currentDependencyIds, dependsOnId]; + + if (hasCircularDependency(itemId, nextDependencyIds, state.items)) { + result = { ok: false, error: 'Cannot create a circular dependency.' }; + return state; + } + + const updatedAt = Date.now(); + result = { ok: true }; + + return { + ...withSelection({ + items: state.items.map((item) => { + if (item.id !== itemId) { + return item; + } + + return appendItemEvents( + { + ...item, + dependsOn: nextDependencyIds, + }, + [ + ...(normalizedNote ? [{ description: normalizedNote, kind: 'note' as const }] : []), + { description: `Added dependency on “${dependencyItem.title}”.`, kind: 'item' as const }, + ], + updatedAt, + ); + }), + projects: touchProject(state.projects, targetItem.projectId, updatedAt), + selectedItemId: itemId, + selectedProjectFilter: state.selectedProjectFilter, + selectedProjectId: targetItem.projectId, + selectedProjectView: state.selectedProjectView, + }), + }; + }); + + return result; + }, addWorkProduct: (itemId, input) => { const title = input.title.trim(); const body = input.body.trim(); @@ -631,6 +712,10 @@ export function createWorkflowSlice( return state; } + if (status === 'active' && item.status !== 'active' && isItemBlocked(item, state.items)) { + return state; + } + const normalizedNote = normalizeNote(note); const updatedAt = Date.now(); const destination = getProjectItems( @@ -685,6 +770,65 @@ export function createWorkflowSlice( openProjectSettings: () => { set({ selectedProjectScreen: 'settings' }); }, + removeDependency: (itemId, dependsOnId, note) => { + const normalizedNote = normalizeNote(note); + let result: WorkflowMutationResult = { ok: false, error: 'Work item not found.' }; + + set((state) => { + const targetItem = state.items.find((item) => item.id === itemId) ?? null; + const dependencyItem = state.items.find((item) => item.id === dependsOnId) ?? null; + + if (!targetItem) { + result = { ok: false, error: 'Work item not found.' }; + return state; + } + + const currentDependencyIds = normalizeDependencyIds(targetItem.dependsOn) ?? []; + + if (!currentDependencyIds.includes(dependsOnId)) { + result = { ok: true }; + return state; + } + + const updatedAt = Date.now(); + const nextDependencyIds = currentDependencyIds.filter((dependencyId) => dependencyId !== dependsOnId); + result = { ok: true }; + + return { + ...withSelection({ + items: state.items.map((item) => { + if (item.id !== itemId) { + return item; + } + + return appendItemEvents( + { + ...item, + ...(nextDependencyIds.length > 0 ? { dependsOn: nextDependencyIds } : {}), + }, + [ + ...(normalizedNote ? [{ description: normalizedNote, kind: 'note' as const }] : []), + { + description: dependencyItem + ? `Removed dependency on “${dependencyItem.title}”.` + : `Removed dependency on “${dependsOnId}”.`, + kind: 'item' as const, + }, + ], + updatedAt, + ); + }), + projects: touchProject(state.projects, targetItem.projectId, updatedAt), + selectedItemId: itemId, + selectedProjectFilter: state.selectedProjectFilter, + selectedProjectId: targetItem.projectId, + selectedProjectView: state.selectedProjectView, + }), + }; + }); + + return result; + }, closeProjectSettings: () => { set({ selectedProjectScreen: 'main' }); }, @@ -699,7 +843,7 @@ export function createWorkflowSlice( selectedItemId: itemId, selectedProjectId: item?.projectId ?? state.selectedProjectId, selectedProjectScreen: 'main', - selectedProjectView: 'board' as WorkflowProjectView, + selectedProjectView: state.selectedProjectView as WorkflowProjectView, }; }); }, diff --git a/src/renderer/app/workspaces/WorkflowWorkspace.tsx b/src/renderer/app/workspaces/WorkflowWorkspace.tsx index 16a9b75..5c95ef3 100644 --- a/src/renderer/app/workspaces/WorkflowWorkspace.tsx +++ b/src/renderer/app/workspaces/WorkflowWorkspace.tsx @@ -4,7 +4,7 @@ import { Plus, X, } from 'lucide-react'; -import { useState } from 'react'; +import { type ComponentProps, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { CompactShellToolbar } from '@/renderer/app/shell/CompactShellToolbar'; @@ -13,6 +13,7 @@ import { useWorkflowSession } from '@/renderer/app/store/selectors'; import { useAppStore } from '@/renderer/app/store/use-app-store'; import { CreateProjectDialog } from '@/renderer/features/workflow/components/CreateProjectDialog'; import { CreateWorkItemDialog } from '@/renderer/features/workflow/components/CreateWorkItemDialog'; +import { DependencyGraphView } from '@/renderer/features/workflow/components/DependencyGraphView'; import { WorkflowBoard } from '@/renderer/features/workflow/components/WorkflowBoard'; import { WorkflowItemInspector } from '@/renderer/features/workflow/components/WorkflowItemInspector'; import { WorkflowProjectActivity } from '@/renderer/features/workflow/components/WorkflowProjectActivity'; @@ -35,6 +36,7 @@ import type { WorkflowProjectActivityEntry } from '@/renderer/features/workflow/ const projectHeaderTabs = [ { label: 'Activity', value: 'activity' }, { label: 'Board', value: 'board' }, + { label: 'Graph', value: 'graph' }, { label: 'Agents', value: 'agents' }, ] as const; @@ -76,6 +78,7 @@ export function WorkflowWorkspace({ >>>({}); const [loadingActivityProjectId, setLoadingActivityProjectId] = useState(null); const { + addDependency, addTask, assignPrimaryAgent, clearAgentAssignments, @@ -84,12 +87,14 @@ export function WorkflowWorkspace({ createProject, deleteProject, moveItem, + removeDependency, selectItem, updateProject, updateItem, updateTask, } = useAppStore( useShallow((state) => ({ + addDependency: state.addDependency, addTask: state.addTask, assignPrimaryAgent: state.assignPrimaryAgent, clearAgentAssignments: state.clearAgentAssignments, @@ -98,6 +103,7 @@ export function WorkflowWorkspace({ createProject: state.createProject, deleteProject: state.deleteProject, moveItem: state.moveItem, + removeDependency: state.removeDependency, selectItem: state.selectItem, updateProject: state.updateProject, updateItem: state.updateItem, @@ -109,6 +115,7 @@ export function WorkflowWorkspace({ activitySummary, filteredItemSummaries, isWorkflowHydrated, + items, projectAgents, projects, selectedItem, @@ -118,6 +125,7 @@ export function WorkflowWorkspace({ selectedProjectView, } = useWorkflowSession(); const isBoardView = selectedProjectView === 'board'; + const isGraphView = selectedProjectView === 'graph'; const isAgentsView = selectedProjectView === 'agents'; const isProjectAgentsInitializing = isAgentsView && @@ -276,6 +284,32 @@ export function WorkflowWorkspace({ /> ) : null; + const inspectorProps = { + allItems: items, + item: selectedItem, + onAddDependency: addDependency, + onAddTask: (itemId: string, title: string) => { + addTask(itemId, title); + }, + onAssignPrimaryAgent: (itemId: string, input: { agentId: string | null; agentName?: string | null }) => { + void handleAssignPrimaryAgent(itemId, input); + }, + onCreateAgent: (itemId: string) => { + void handleCreateAgentForItem(itemId); + }, + onOpenAgent: (agentId: string) => commands.openAgent(agentId), + onRemoveDependency: removeDependency, + onUpdateItem: updateItem, + onUpdateItemStatus: (itemId: string, status: Parameters[1]) => + moveItem(itemId, status, Number.MAX_SAFE_INTEGER), + onUpdateTask: updateTask, + project: selectedProject, + projectAgents: projectAgents.map((agent) => ({ + id: agent.id, + name: agent.name, + })), + } satisfies ComponentProps; + const boardView = ( <> {isCompactShell ? ( @@ -306,29 +340,7 @@ export function WorkflowWorkspace({ {isSettingsScreen ? ( projectSettingsInspector ) : ( - { - addTask(itemId, title); - }} - onAssignPrimaryAgent={(itemId, input) => { - void handleAssignPrimaryAgent(itemId, input); - }} - onCreateAgent={(itemId) => { - void handleCreateAgentForItem(itemId); - }} - onOpenAgent={(agentId) => commands.setPopoverAgentId(agentId)} - onUpdateItem={updateItem} - onUpdateItemStatus={(itemId, status) => - moveItem(itemId, status, Number.MAX_SAFE_INTEGER) - } - onUpdateTask={updateTask} - project={selectedProject} - projectAgents={projectAgents.map((agent) => ({ - id: agent.id, - name: agent.name, - }))} - /> + )} @@ -336,30 +348,36 @@ export function WorkflowWorkspace({ ); + const graphView = ( + <> + {isCompactShell ? ( +
+ +
+ ) : ( +
+
+ +
+ +
+ {isSettingsScreen ? projectSettingsInspector : } +
+
+ )} + + ); + const compactInspector = ( - { - addTask(itemId, title); - }} - onAssignPrimaryAgent={(itemId, input) => { - void handleAssignPrimaryAgent(itemId, input); - }} - onCreateAgent={(itemId) => { - void handleCreateAgentForItem(itemId); - }} - onOpenAgent={(agentId) => commands.setPopoverAgentId(agentId)} - onUpdateItem={updateItem} - onUpdateItemStatus={(itemId, status) => - moveItem(itemId, status, Number.MAX_SAFE_INTEGER) - } - onUpdateTask={updateTask} - project={selectedProject} - projectAgents={projectAgents.map((agent) => ({ - id: agent.id, - name: agent.name, - }))} - /> + ); const emptyState = ( @@ -421,6 +439,11 @@ export function WorkflowWorkspace({ return; } + if (tab.value === 'graph') { + commands.openDependencyGraph(); + return; + } + if (tab.value === 'agents') { commands.openAgents(); return; @@ -439,7 +462,7 @@ export function WorkflowWorkspace({
- {isBoardView && !showTitlebarProjectCreateAction ? ( + {(isBoardView || isGraphView) && !showTitlebarProjectCreateAction ? (