Visual Workflow Studio is a visual DAG editor to help business automate their business operation. It was built with Vue 3 and TypeScript. You drag nodes onto a canvas, connect them into a workflow, configure behavior per node, and run the flow to inspect execution logs step by step.
Today the built-in node types are START, TRANSFORM, IFELSE, SWITCH, and END. The execution engine moves through the graph by following the output port selected by each node executor. Workflows can be exported/imported as JSON, and canvas state is autosaved locally.
Setup Video
https://www.loom.com/share/7d1827e242b14904b1f4dcfeb0f828f0
Dashboard Demo
- PART 1 https://www.loom.com/share/2cf98d59da244de5b78aed8bfb6f20df
- PART 2 https://www.loom.com/share/e50192f0a13d4fb5b0aa6ba413dde320
- PART 3 https://www.loom.com/share/c8e8a234f05644438610b043a157a918
Architecture and Codebase design
- [High Level Design] https://www.loom.com/share/9791f1479b354f3e92a4abb151eada38
- [State Management]
https://www.loom.com/share/a50b8a95eb604fd2b77153ee2298fe32 https://www.loom.com/share/87a7581c30af498cad2ee18498646895
-
[Low Level Design] https://www.loom.com/share/16ac53e5b1944bb49c1e7bcefddef49b https://www.loom.com/share/4356708a92fa41fbab4ee09e8361d5de https://www.loom.com/share/7ca6281be1e94073a80fd9470fc084eb
-
[How to Create new node and Making Complex Nodes] https://www.loom.com/share/653fc9327b2c4841a8ffedf551424c2d
- Node.js 20+
- npm 10+
npm install
npm run devnpm run build
npm run preview
npm run test- Vite Dev Server and Bundler drives the fast HMR loop, asset hashing, and production build output that powers the development studio interface.
- Vitest provides lightweight unit testing for stores, helpers, and the engine so rapid feedback covers core graph behaviors.
- Linting + Formatting (ESLint + Prettier) enforce shared rules across
.ts/.vuefiles and keep IDE snapshots in sync with the team style guide.
- Vue 3 is the UI framework for declarative rendering, composition API helpers, and concurrent-friendly reactivity on the canvas layer.
- Pinia manages store boundaries so graph state, history, persistence, execution, and UI concerns remain isolated yet easy to compose.
- Vue Flow helpers (used through helpers like
applyNodeChanges) keep canvas interactions performant by reusing identity-preserving change streams. - Centralized Policy Flags are grouped under
WORKFLOW_CONSTANTSinsrc/config/workflowConstants.ts, so every timeout interval, retry count, or autosave debounce is controlled through a single central flag set.
At a high level, the system works like a closed loop between UI intent, graph state, and runtime execution.
When a user drags, connects, edits, or deletes on the canvas, WorkFlowCanvas emits change events and the graph store applies only the required mutation. That updated graph state then becomes the single source used by two downstream paths: persistence (autosave/import-export) and execution (engine run + node status).
Node behavior is type-driven through the registry and factory pipeline, so rendering, graph mutation, and runtime execution stay decoupled while sharing one graph state model.
In short, the architecture follows this event flow:
- User action on canvas -> event stream (
add,move,connect,config change). - Graph mutation boundary ->
workflowGraphStoreupdates nodes/edges/indexes. - System side effects -> history tracking + autosave scheduling.
- Execution path -> engine reads graph snapshot and runs node executors.
- Feedback path -> execution log and per-node state feed UI highlights and diagnostics.
flowchart LR
UserActions[UserActions] --> CanvasUI[WorkFlowCanvas]
CanvasUI --> NodeRegistry[nodeRegistry]
NodeRegistry --> WorkNodeFactory[workNodeFactory]
WorkNodeFactory --> RenderNode[RenderWorkNode]
CanvasUI --> GraphStore[workflowGraphStore]
GraphStore --> HistoryStore[workflowHistoryStore]
GraphStore --> PersistenceStore[workflowPersistenceStore]
GraphStore --> ExecutionStore[workflowExecutionStore]
ExecutionStore --> WorkflowEngine[workflowEngine]
WorkflowEngine --> NodeExecutors[NodeExecutors]
Project structure and key components:
src/components/WorkFlowCanvas.vue: drag/drop, connect, node/edge change streams.nodeRenderers/*: node visual contracts per type.edgeRenderers/*: edge-level controls (for example delete edge interaction).
src/stores/workflowGraphStore.ts: graph domain state + normalized indexes.workflowHistoryStore.ts: command history, undo/redo lifecycle.workflowPersistenceStore.ts: autosave, import/export, restore.workflowExecutionStore.ts: runtime execution log + node execution states.globalUIStore.ts: global modal/dialog state with consumer-defined action callbacks.
src/engine/workflowEngine.ts: validation rules, DAG build, execution loop.executors/*: specialized execution strategies.
src/registry/nodeRegistry.ts- node definitions, config schema,
executorResolver, optionalportResolver.
- node definitions, config schema,
src/factory/workNodeFactory.ts- creates work nodes from registry definitions.
src/config/workflowConstants.ts- centralized limits and policy flags.
The UI follows layered composition so behavior and presentation can evolve without rewriting the complete workflow surface.
flowchart TB
AppShell[AppShell] --> WorkflowPage[WorkflowPage]
WorkflowPage --> WorkFlowCanvas[WorkFlowCanvas]
WorkflowPage --> NodeConfigPanel[NodeConfigPanel]
WorkflowPage --> ExecutionLogPanel[ExecutionLogPanel]
WorkFlowCanvas --> NodeRendererWrapper[NodeRendererWrapper]
NodeRendererWrapper --> StartNodeRenderer[StartNodeRenderer]
NodeRendererWrapper --> TransformNodeRenderer[TransformNodeRenderer]
NodeRendererWrapper --> IfElseNodeRenderer[IfElseNodeRenderer]
NodeRendererWrapper --> SwitchNodeRenderer[SwitchNodeRenderer]
NodeRendererWrapper --> EndNodeRenderer[EndNodeRenderer]
WorkFlowCanvas --> DeletableEdgeRenderer[DeletableEdgeRenderer]
Component boundaries:
WorkFlowCanvashandles canvas-level interactions (drag/drop, connect, selection, and change streams).- Node renderers handle node-specific visuals and interactions.
DeletableEdgeRendererhandles edge-local actions.- Configuration UI handles schema-driven editing of node config.
- Execution log UI handles runtime visibility and error feedback.
Minimal design system principles:
- Use a compact token set for spacing, radius, typography, and semantic colors.
- Keep interaction states explicit: normal, hover, selected, executing, success, and error.
- Reuse semantic tokens first, then add new values only when a clear new role appears.
- Keep visual rules consistent across node and edge surfaces.
Accessibility baseline and implemented coverage:
- Form controls now support programmatic label wiring (
label for->input/select/textarea id) through shared primitives. - Validation states expose screen-reader context with
aria-invalidandaria-describedby(including JSON field error messaging). - Icon and emoji action controls now expose explicit accessible names (
aria-label) for header, node actions, and log controls. - Modal behavior supports keyboard lifecycle: initial focus on open,
Escapeto close (when allowed), focus trap withTab/Shift+Tab, and focus restore on close. - Interactive controls use visible
:focus-visiblestyling to keep keyboard focus discoverable across themes. - Execution log container uses log semantics (
role="log",aria-live="polite") for incremental runtime updates.
Pros and cons of this minimal system:
- ✅ Pros: faster UI extension, visual consistency, easier theme evolution, and better built-in keyboard/screen-reader support.
⚠️ Cons: token governance is required to avoid style drift; accessibility validation is currently manual (no automated a11y tooling yet).
State is split across Pinia stores by responsibility:
workflowGraphStore: graph nodes/edges, adjacency indexes, selection, and graph mutation primitives.workflowHistoryStore: command history lifecycle (run,undo,redo) with bounded depth.workflowPersistenceStore: autosave scheduling, import/export, and restore from local storage.workflowExecutionStore: execution lifecycle, execution logs, and per-node execution status.globalUIStore: Main use case is to keep global ui state like confirmation modal, toast , banner etc. Right now it has confirmation modal with state (title,message,actionButtonMap) and does some action orchestration.
Interaction shape:
UI event -> graph command -> history update -> autosave (optional) -> execution state update (when run is triggered)
This separation keeps each store focused while still allowing them to compose cleanly.
The toolbar, canvas, config panel, execution log, and root application shell all rendezvous through the stores whenever a user drags, configures, or runs a workflow.
flowchart LR
WorkFlowToolBar[WorkFlowToolBar] -->|"drags a node type"| WorkFlowCanvas[WorkFlowCanvas]
WorkFlowCanvas -->|"mutates nodes and edges"| workflowGraphStore[workflowGraphStore]
WorkFlowConfigPanel -->|"edits selected node config"| workflowGraphStore
WorkFlowExecutionLog -->|"runs/clears workflows"| workflowExecutionStore[workflowExecutionStore]
App -->|"undo/redo shortcuts"| workflowHistoryStore[workflowHistoryStore]
App -->|"schedules autosave"| workflowPersistenceStore[workflowPersistenceStore]
workflowHistoryStore -.->|"requests autosave callback"| workflowPersistenceStore
workflowPersistenceStore -.->|"restores serialized graph"| workflowGraphStore
- ✅ Pros: keeps each component aligned with the store that owns its domain while the graph, history, persistence, and execution concerns remain isolated.
⚠️ Cons: the coordination surface grows when multiple stores need to react to the same UI event, so the diagram above documents the expectations for bordering flows before they become hard to follow.
For larger workflows, the graph layer is optimized for targeted updates rather than full collection replacement.
The graph store uses:
nodeById: Map<string, RenderWorkNode>edgeById: Map<string, Edge>adjacencyByNodeId: Map<string, Set<string>>
Why this matters:
- Node/edge lookup and incident-edge operations stay fast (O(1) style lookups).
- Dynamic port changes can remove only invalid affected edges instead of filtering every edge.
spliceupdates preserve top-level array identity, which aligns better with Vue Flow change streams (applyNodeChanges,applyEdgeChanges).
Pros and cons of this approach:
- ✅ Pros: better scaling behavior for dense workflows and lower reactive fan-out.
⚠️ Cons: more index consistency rules to maintain (handled by graph consistency assertions in development).
Hard limits and policy flags are centralized in src/config/workflowConstants.ts (for example MAX_EXECUTION_STEPS, autosave debounce, undo/redo depth, zoom bounds, and edge validation toggle).
In low level design I am covering only key flows.
workflowEngine runs as a deterministic traversal loop over the current graph snapshot.
- Build adjacency and validate DAG invariants.
- Locate the single
STARTnode and initialize execution context. - On each step, resolve an executor from registry using current
workNodetype + config. - Execute the resolved node executor and capture selected output port.
- Append execution log and node status updates.
- Move to the next node from selected port; stop when no next node exists or an execution error is captured.
sequenceDiagram
participant Palette as NodePalette
participant Canvas as WorkFlowCanvas
participant Registry as nodeRegistry
participant Factory as workNodeFactory
participant Graph as workflowGraphStore
participant Engine as workflowEngine
participant Resolver as nodeExecutorResolver
participant SelectedExecutor as nodeExecutor
Palette->>Canvas: DragNodeType
Canvas->>Registry: getNodeDefinition(type)
Canvas->>Factory: createWorkNode(id, definition)
Canvas->>Graph: addNode(renderWorkNode)
Graph->>Engine: executeWorkflow(nodes, edges)
loop executionLoop
Engine->>Resolver: resolveNodeExecutor(workNode, nodeId)
Resolver->>Registry: getNodeDefinition(workNode.type)
Resolver->>Registry: executorResolver(workNode.config)
Resolver-->>Engine: selectedNodeExecutor
Engine->>SelectedExecutor: execute(context)
SelectedExecutor-->>Engine: selectedPortAndContext
end
Engine->>Graph: executionLogAndNodeStateMap
Runtime flow function links:
getNodeDefinition(type)createWorkNode(id, definition)addNode(renderWorkNode)executeWorkflow(nodes, edges)resolveNodeExecutor(workNode, nodeId)executorResolver(config)
Node generation follows a small pipeline:
- Canvas receives a node type from drag/drop.
- Registry returns a
NodeDefinition. - Factory creates a
workNodeinstance with default config. - UI wraps it as
RenderWorkNode(with live port definition) and inserts it into graph state.
NodeDefinition is the key extension contract. It contains default config, schema for config UI, port metadata, and executorResolver(config). For dynamic branching nodes (like SWITCH), portResolver(config) can recalculate output ports at runtime.
To add a new node type, you usually only touch four places:
- Register the node definition in
src/registry/nodeRegistry.ts. - Implement executor behavior in
src/engine/executors/. - Add a node renderer in
src/components/nodeRenderers/. - Wire the renderer slot in
src/components/WorkFlowCanvas.vue.
Pros and cons:
- ✅ Pros: strong separation of concerns, better extensibility, node-specific behavior stays local.
⚠️ Cons: a bit more moving pieces, and new contributors need to learn the registry/factory/executor contract.
Code quality is driven by architecture boundaries first, then tests and runtime checks:
- Stores follow clear single-responsibility boundaries.
- Hard-coded runtime values are centralized in
WORKFLOW_CONSTANTS. - Workflow import/export is isolated behind serialization/validation boundaries.
- The engine captures execution errors into structured runtime state for renderer feedback.
- Vitest covers important graph, history, and engine behaviors.