Browse Source

feat: add executeSelectedNodes with topo sort, abort, and downstream propagation

Implements the "Run selected nodes" feature (PR #61) with fixes for all 8
review issues: topological ordering via groupNodesByLevel, AbortController
cancellation, glbViewer case coverage, AbortError filtering, downstream
consumer propagation with dedup guard, and dynamic button label. Also
extracts saveLogSession helper to replace 4 inline duplicates across
executeWorkflow and regenerateNode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
f26a667b9c
  1. 5
      .gitignore
  2. 2
      CLAUDE.md
  3. 36
      src/components/FloatingActionBar.tsx
  4. 272
      src/store/workflowStore.ts

5
.gitignore

@ -44,4 +44,7 @@ logs/*.json
.claude/
# planning docs
.planning/
.planning/
# agents
.agents/

2
CLAUDE.md

@ -219,6 +219,6 @@ All routes in `src/app/api/`:
- Never push directly to `main`, `master`, or `develop`
## Commits
- Commit frequently and atomically, if a task is complete, commit.
- The .planning directory is untracked, do not attempt to commit any changes to the files in this directory.

36
src/components/FloatingActionBar.tsx

@ -161,6 +161,7 @@ export function FloatingActionBar() {
currentNodeIds,
executeWorkflow,
regenerateNode,
executeSelectedNodes,
stopWorkflow,
validateWorkflow,
edgeStyle,
@ -185,11 +186,15 @@ export function FloatingActionBar() {
const runMenuRef = useRef<HTMLDivElement>(null);
const { valid, errors } = validateWorkflow();
// Get the selected nodes
const selectedNodes = useMemo(() => {
return nodes.filter((n) => n.selected);
}, [nodes]);
// Get the selected node (if exactly one is selected)
const selectedNode = useMemo(() => {
const selected = nodes.filter((n) => n.selected);
return selected.length === 1 ? selected[0] : null;
}, [nodes]);
return selectedNodes.length === 1 ? selectedNodes[0] : null;
}, [selectedNodes]);
// Close run menu when clicking outside
useEffect(() => {
@ -234,6 +239,13 @@ export function FloatingActionBar() {
}
};
const handleRunSelectedNodes = () => {
if (selectedNodes.length > 0) {
executeSelectedNodes(selectedNodes.map((n) => n.id));
setRunMenuOpen(false);
}
};
return (
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 z-50">
<div className="flex items-center gap-0.5 bg-neutral-800/95 backdrop-blur-sm rounded-lg shadow-lg border border-neutral-700/80 px-1.5 py-1">
@ -392,6 +404,24 @@ export function FloatingActionBar() {
</svg>
Run selected node only
</button>
<button
onClick={handleRunSelectedNodes}
disabled={selectedNodes.length === 0}
className={`w-full px-3 py-2 text-left text-[11px] font-medium transition-colors flex items-center gap-2 ${
selectedNodes.length > 0
? "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100"
: "text-neutral-500 cursor-not-allowed"
}`}
title={selectedNodes.length === 0 ? "Select one or more nodes first" : `Run ${selectedNodes.length} selected node${selectedNodes.length > 1 ? 's' : ''}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V9.653z" />
</svg>
{selectedNodes.length > 0
? `Run ${selectedNodes.length} selected node${selectedNodes.length !== 1 ? 's' : ''}`
: 'Run selected nodes'}
</button>
</div>
)}
</div>

272
src/store/workflowStore.ts

@ -77,6 +77,20 @@ import type { NodeExecutionContext } from "./execution";
export type { LevelGroup } from "./utils/executionUtils";
export { CONCURRENCY_SETTINGS_KEY } from "./utils/executionUtils";
function saveLogSession(): void {
const session = logger.getCurrentSession();
if (session) {
session.endTime = new Date().toISOString();
fetch('/api/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session }),
}).catch((err) => {
console.error('Failed to save log session:', err);
});
}
}
export type EdgeStyle = "angular" | "curved";
// Workflow file format
@ -151,6 +165,7 @@ interface WorkflowStore {
_buildExecutionContext: (node: WorkflowNode, signal?: AbortSignal) => NodeExecutionContext;
executeWorkflow: (startFromNodeId?: string) => Promise<void>;
regenerateNode: (nodeId: string) => Promise<void>;
executeSelectedNodes: (nodeIds: string[]) => Promise<void>;
stopWorkflow: () => void;
setMaxConcurrentCalls: (value: number) => void;
@ -958,19 +973,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
set({ isRunning: false, currentNodeIds: [], _abortController: null });
// Save logs to server
const session = logger.getCurrentSession();
if (session) {
session.endTime = new Date().toISOString();
fetch('/api/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session }),
}).catch((err) => {
console.error('Failed to save log session:', err);
});
}
saveLogSession();
await logger.endSession();
} catch (error) {
// Handle AbortError gracefully (user cancelled)
@ -986,19 +989,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
}
set({ isRunning: false, currentNodeIds: [], _abortController: null });
// Save logs to server (even on error)
const session = logger.getCurrentSession();
if (session) {
session.endTime = new Date().toISOString();
fetch('/api/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session }),
}).catch((err) => {
console.error('Failed to save log session:', err);
});
}
saveLogSession();
await logger.endSession();
}
},
@ -1092,19 +1083,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
logger.info('node.execution', 'Node regeneration completed successfully', { nodeId });
set({ isRunning: false, currentNodeIds: [] });
// Save logs to server
const session = logger.getCurrentSession();
if (session) {
session.endTime = new Date().toISOString();
fetch('/api/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session }),
}).catch((err) => {
console.error('Failed to save log session:', err);
});
}
saveLogSession();
await logger.endSession();
} catch (error) {
logger.error('node.error', 'Node regeneration failed', {
@ -1116,19 +1095,212 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
});
set({ isRunning: false, currentNodeIds: [] });
// Save logs to server (even on error)
const session = logger.getCurrentSession();
if (session) {
session.endTime = new Date().toISOString();
fetch('/api/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session }),
}).catch((err) => {
console.error('Failed to save log session:', err);
});
saveLogSession();
await logger.endSession();
}
},
executeSelectedNodes: async (nodeIds: string[]) => {
const { nodes, edges, isRunning, maxConcurrentCalls } = get();
if (isRunning) {
logger.warn('node.execution', 'Cannot execute nodes, workflow already running');
return;
}
if (nodeIds.length === 0) {
logger.warn('node.execution', 'No nodes provided for execution');
return;
}
// Filter to valid nodes
const selectedSet = new Set(nodeIds);
const nodesToExecute = nodeIds
.map((id) => nodes.find((n) => n.id === id))
.filter((n): n is WorkflowNode => n !== undefined);
if (nodesToExecute.length === 0) {
logger.warn('node.execution', 'No valid nodes found for execution');
return;
}
// Create AbortController for this execution run
const abortController = new AbortController();
set({ isRunning: true, currentNodeIds: nodeIds, _abortController: abortController });
await logger.startSession();
logger.info('node.execution', 'Executing selected nodes', {
nodeCount: nodesToExecute.length,
nodeIds,
});
// Helper to execute a single node
const executeNode = async (node: WorkflowNode, signal: AbortSignal) => {
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
logger.info('node.execution', `Executing ${node.type} node`, {
nodeId: node.id,
nodeType: node.type,
});
const executionCtx = get()._buildExecutionContext(node, signal);
const regenOptions = { useStoredFallback: true };
switch (node.type) {
case "imageInput":
case "audioInput":
// Data source nodes - no execution needed
break;
case "glbViewer":
await executeGlbViewer(executionCtx);
break;
case "annotation":
await executeAnnotation(executionCtx);
break;
case "prompt":
await executePrompt(executionCtx);
break;
case "promptConstructor":
await executePromptConstructor(executionCtx);
break;
case "nanoBanana":
await executeNanoBanana(executionCtx, regenOptions);
break;
case "generateVideo":
await executeGenerateVideo(executionCtx, regenOptions);
break;
case "llmGenerate":
await executeLlmGenerate(executionCtx, regenOptions);
break;
case "splitGrid":
await executeSplitGrid(executionCtx);
break;
case "output":
await executeOutput(executionCtx);
break;
case "outputGallery":
await executeOutputGallery(executionCtx);
break;
case "imageCompare":
await executeImageCompare(executionCtx);
break;
case "videoStitch":
await executeVideoStitch(executionCtx);
break;
case "easeCurve":
await executeEaseCurve(executionCtx);
break;
}
};
try {
// Filter edges to only those within the selected set for topological sort
const selectedEdges = edges.filter(
(e) => selectedSet.has(e.source) && selectedSet.has(e.target)
);
// Group selected nodes by dependency level for ordered execution
const levels = groupNodesByLevel(nodesToExecute, selectedEdges);
// Execute levels sequentially, nodes within each level in parallel batches
for (const level of levels) {
if (abortController.signal.aborted || !get().isRunning) break;
const levelNodes = level.nodeIds
.map((id) => nodesToExecute.find((n) => n.id === id))
.filter((n): n is WorkflowNode => n !== undefined);
if (levelNodes.length === 0) continue;
const batches = chunk(levelNodes, maxConcurrentCalls);
for (const batch of batches) {
if (abortController.signal.aborted || !get().isRunning) break;
const batchIds = batch.map((n) => n.id);
set({ currentNodeIds: batchIds });
logger.info('node.execution', `Executing batch of selected nodes`, {
level: level.level,
nodeCount: batch.length,
nodeIds: batchIds,
});
const results = await Promise.allSettled(
batch.map((node) => executeNode(node, abortController.signal))
);
// Check for failures, filtering out AbortErrors
const failed = results.find(
(r): r is PromiseRejectedResult =>
r.status === 'rejected' &&
!(r.reason instanceof DOMException && r.reason.name === 'AbortError')
);
if (failed) {
logger.error('node.error', 'Node execution failed in batch', {
level: level.level,
error: failed.reason instanceof Error ? failed.reason.message : String(failed.reason),
});
abortController.abort();
throw failed.reason;
}
}
}
// Propagate to downstream consumer nodes not in the selected set
if (!abortController.signal.aborted && get().isRunning) {
const { edges: currentEdges } = get();
const propagated = new Set<string>();
for (const nodeId of nodeIds) {
const downstreamEdges = currentEdges.filter(e => e.source === nodeId);
for (const edge of downstreamEdges) {
if (selectedSet.has(edge.target) || propagated.has(edge.target)) continue;
const targetNode = get().nodes.find(n => n.id === edge.target);
if (!targetNode) continue;
const targetCtx = get()._buildExecutionContext(targetNode);
switch (targetNode.type) {
case "glbViewer":
await executeGlbViewer(targetCtx);
propagated.add(edge.target);
break;
case "output":
await executeOutput(targetCtx);
propagated.add(edge.target);
break;
case "outputGallery":
await executeOutputGallery(targetCtx);
propagated.add(edge.target);
break;
case "imageCompare":
await executeImageCompare(targetCtx);
propagated.add(edge.target);
break;
}
}
}
}
logger.info('node.execution', 'Selected nodes execution completed successfully');
set({ isRunning: false, currentNodeIds: [], _abortController: null });
saveLogSession();
await logger.endSession();
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
logger.info('node.execution', 'Selected nodes execution cancelled by user');
} else {
logger.error('node.error', 'Selected nodes execution failed', {}, error instanceof Error ? error : undefined);
useToast.getState().show(
`Execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
"error"
);
}
set({ isRunning: false, currentNodeIds: [], _abortController: null });
saveLogSession();
await logger.endSession();
}
},

Loading…
Cancel
Save