diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd7aa1..ac83996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,12 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ### Added - Release automation workflow (`.github/workflows/release.yml`) for tag-driven publishing. - Semver tag helper script (`scripts/release-version.sh`) with `patch`, `minor`, `major` bump modes. +- Workflow file export schema metadata (`schema`, `version`) with backward-compatible import checks. +- Workflow builder controls for undo/redo and explicit edge disconnect. ### Changed - CI now includes browser smoke validation (`Web E2E Smoke`). +- Web editor keyboard shortcuts now include undo/redo and selection-aware delete behavior. ## [1.0.7] - 2026-02-13 diff --git a/README.md b/README.md index 62ba205..527beb5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ It combines a drag-and-drop workflow studio, resilient execution, AI-assisted au ## What You Get - Visual workflow builder (React Flow) +- Core editor UX: undo/redo, duplicate, edge disconnect, auto-layout, and JSON import/export - Web automation (Playwright) and desktop automation (agent service) - Recorder flows for web and desktop action capture - Autopilot workflow generation from natural-language prompts @@ -38,16 +39,20 @@ It combines a drag-and-drop workflow studio, resilient execution, AI-assisted au Power shortcuts: - `Ctrl/Cmd+K` quick-add node search +- `Ctrl/Cmd+Z` undo +- `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` redo - `Ctrl+S` save draft - `Ctrl+T` test run - `Ctrl+R` run - `Ctrl+D` duplicate selected node +- `Delete` remove selected node or selected edge ## Demo Flows Use these guided demos to evaluate the platform quickly: - `docs/DEMOS.md#demo-1-autopilot-invoice-triage` - `docs/DEMOS.md#demo-2-orchestrator-unattended-queue` - `docs/DEMOS.md#demo-3-document-understanding-and-clipboard-ai` +- `docs/DEMOS.md#demo-4-workflow-builder-mvp-controls` ## Contributor Onboarding New contributors should start here: diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 16da069..f0c093e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -237,6 +237,52 @@ const defaultDefinition: WorkflowDefinition = { } }; +const WORKFLOW_FILE_SCHEMA = "forgeflow.workflow"; +const WORKFLOW_FILE_VERSION = 1; +const MAX_EDITOR_HISTORY = 80; + +type EditorSnapshot = { + nodes: Node[]; + edges: Edge[]; + hash: string; +}; + +function cloneNodes(source: Node[]): Node[] { + const normalized = source.map((node) => { + const next = { ...node } as Record; + const data = + next.data && typeof next.data === "object" ? { ...(next.data as Record) } : undefined; + if (data) { + delete data.__runStatus; + delete data.__runDurationMs; + delete data.__runStartedAt; + delete data.__runAttempts; + delete data.__runError; + next.data = data; + } + delete next.selected; + delete next.dragging; + delete next.positionAbsolute; + delete next.width; + delete next.height; + return next; + }); + return JSON.parse(JSON.stringify(normalized)) as Node[]; +} + +function cloneEdges(source: Edge[]): Edge[] { + const normalized = source.map((edge) => { + const next = { ...edge } as Record; + delete next.selected; + return next; + }); + return JSON.parse(JSON.stringify(normalized)) as Edge[]; +} + +function buildSnapshotHash(nodes: Node[], edges: Edge[]) { + return JSON.stringify({ nodes, edges }); +} + function artifactPathToUrl(apiUrl: string, artifactPath?: string) { if (!artifactPath) return null; const normalized = artifactPath.replace(/\\/g, "/"); @@ -271,6 +317,7 @@ export default function App() { const [nodes, setNodes, onNodesChange] = useNodesState(defaultDefinition.nodes as Node[]); const [edges, setEdges, onEdgesChange] = useEdgesState(defaultDefinition.edges as Edge[]); const [selectedNode, setSelectedNode] = useState(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [status, setStatus] = useState(""); const [desktopRecording, setDesktopRecording] = useState(false); const [runs, setRuns] = useState([]); @@ -344,6 +391,7 @@ export default function App() { const [snapToGrid, setSnapToGrid] = useState(true); const [isDirty, setIsDirty] = useState(false); const [lastAutoSaveAt, setLastAutoSaveAt] = useState(null); + const [historyRevision, setHistoryRevision] = useState(0); const [collabPresence, setCollabPresence] = useState([]); const [workflowComments, setWorkflowComments] = useState([]); const [workflowHistory, setWorkflowHistory] = useState(null); @@ -363,6 +411,9 @@ export default function App() { const collabHeartbeatRef = useRef(null); const lastSavedHashRef = useRef(""); const autosaveInFlightRef = useRef(false); + const editorHistoryRef = useRef([]); + const editorHistoryIndexRef = useRef(-1); + const historyHydratingRef = useRef(false); useEffect(() => { nodesRef.current = nodes; @@ -372,6 +423,101 @@ export default function App() { edgesRef.current = edges; }, [edges]); + const resetEditorHistory = (snapshotNodes: Node[], snapshotEdges: Edge[]) => { + const nodesClone = cloneNodes(snapshotNodes); + const edgesClone = cloneEdges(snapshotEdges); + editorHistoryRef.current = [ + { + nodes: nodesClone, + edges: edgesClone, + hash: buildSnapshotHash(nodesClone, edgesClone) + } + ]; + editorHistoryIndexRef.current = 0; + historyHydratingRef.current = false; + setHistoryRevision((value) => value + 1); + }; + + const captureEditorSnapshot = (snapshotNodes: Node[], snapshotEdges: Edge[]) => { + if (historyHydratingRef.current) { + historyHydratingRef.current = false; + return; + } + + const nodesClone = cloneNodes(snapshotNodes); + const edgesClone = cloneEdges(snapshotEdges); + const hash = buildSnapshotHash(nodesClone, edgesClone); + const currentIndex = editorHistoryIndexRef.current; + const currentSnapshot = currentIndex >= 0 ? editorHistoryRef.current[currentIndex] : null; + if (currentSnapshot?.hash === hash) return; + + const trimmed = editorHistoryRef.current.slice(0, Math.max(0, currentIndex + 1)); + trimmed.push({ nodes: nodesClone, edges: edgesClone, hash }); + const overflow = Math.max(0, trimmed.length - MAX_EDITOR_HISTORY); + editorHistoryRef.current = overflow > 0 ? trimmed.slice(overflow) : trimmed; + editorHistoryIndexRef.current = editorHistoryRef.current.length - 1; + setHistoryRevision((value) => value + 1); + }; + + const hydrateEditorSnapshot = (snapshot: EditorSnapshot) => { + historyHydratingRef.current = true; + const nodesClone = cloneNodes(snapshot.nodes); + const edgesClone = cloneEdges(snapshot.edges); + setNodes(nodesClone); + setEdges(edgesClone); + setSelectedNode((current) => { + if (!current) return null; + return nodesClone.find((node) => node.id === current.id) || null; + }); + setSelectedEdgeId((current) => { + if (!current) return null; + return edgesClone.some((edge) => edge.id === current) ? current : null; + }); + }; + + const handleUndo = () => { + const currentIndex = editorHistoryIndexRef.current; + if (currentIndex <= 0) return; + const nextIndex = currentIndex - 1; + editorHistoryIndexRef.current = nextIndex; + setHistoryRevision((value) => value + 1); + const snapshot = editorHistoryRef.current[nextIndex]; + if (snapshot) { + hydrateEditorSnapshot(snapshot); + setStatus("Undo"); + } + }; + + const handleRedo = () => { + const currentIndex = editorHistoryIndexRef.current; + if (currentIndex < 0 || currentIndex >= editorHistoryRef.current.length - 1) return; + const nextIndex = currentIndex + 1; + editorHistoryIndexRef.current = nextIndex; + setHistoryRevision((value) => value + 1); + const snapshot = editorHistoryRef.current[nextIndex]; + if (snapshot) { + hydrateEditorSnapshot(snapshot); + setStatus("Redo"); + } + }; + + const canUndo = useMemo(() => editorHistoryIndexRef.current > 0, [historyRevision]); + const canRedo = useMemo( + () => editorHistoryIndexRef.current >= 0 && editorHistoryIndexRef.current < editorHistoryRef.current.length - 1, + [historyRevision] + ); + + useEffect(() => { + captureEditorSnapshot(nodes, edges); + }, [nodes, edges]); + + useEffect(() => { + if (!selectedEdgeId) return; + if (!edges.some((edge) => edge.id === selectedEdgeId)) { + setSelectedEdgeId(null); + } + }, [edges, selectedEdgeId]); + const nodeOptions = NODE_OPTIONS; const filteredNodeOptions = useMemo(() => { @@ -982,8 +1128,13 @@ export default function App() { "definition" in workflow || "draftDefinition" in workflow ? (workflow as WorkflowRecord) : await getWorkflow(workflow.id); setActiveWorkflow(fullWorkflow); const def = fullWorkflow.draftDefinition || fullWorkflow.definition || defaultDefinition; - setNodes(def.nodes || defaultDefinition.nodes); - setEdges(def.edges || []); + const nextNodes = (def.nodes || defaultDefinition.nodes) as Node[]; + const nextEdges = (def.edges || []) as Edge[]; + setNodes(nextNodes); + setEdges(nextEdges); + setSelectedNode(null); + setSelectedEdgeId(null); + resetEditorHistory(nextNodes, nextEdges); lastSavedHashRef.current = hashDefinition( buildPersistedDefinition(defaultDefinition, def, (def.nodes || defaultDefinition.nodes) as Node[], (def.edges || []) as Edge[]) ); @@ -994,6 +1145,7 @@ export default function App() { const onConnect = (connection: Connection) => { setEdges((eds) => addEdge(connection, eds)); + setSelectedEdgeId(null); }; const handleLogin = async (username: string, password: string, totpCode?: string) => { @@ -1123,8 +1275,11 @@ export default function App() { setTwoFactorSetup(null); setTwoFactorToken(""); setLoginTotpCode(""); + setSelectedNode(null); + setSelectedEdgeId(null); setNodes(defaultDefinition.nodes as Node[]); setEdges(defaultDefinition.edges as Edge[]); + resetEditorHistory(defaultDefinition.nodes as Node[], defaultDefinition.edges as Edge[]); lastSavedHashRef.current = ""; setIsDirty(false); setLastAutoSaveAt(null); @@ -1294,6 +1449,7 @@ export default function App() { nodesRef.current = [...nodesRef.current, newNode]; setNodes((nds) => [...nds, newNode]); setSelectedNode(newNode); + setSelectedEdgeId(null); if (sourceNode) { setEdges((eds) => [...eds, { id: `e-${sourceNode.id}-${id}`, source: sourceNode.id, target: id }]); @@ -1353,6 +1509,7 @@ export default function App() { nodesRef.current = [...nodesRef.current, newNode]; setNodes((current) => [...current, newNode]); setSelectedNode(newNode); + setSelectedEdgeId(null); setFeedback(`Duplicated node ${selectedNode.id}`, "success"); }; @@ -1366,9 +1523,25 @@ export default function App() { setNodes((current) => current.filter((node) => node.id !== selectedNode.id)); setEdges((current) => current.filter((edge) => edge.source !== selectedNode.id && edge.target !== selectedNode.id)); setSelectedNode(null); + setSelectedEdgeId(null); setFeedback(`Deleted node ${selectedNode.id}`, "info"); }; + const handleDisconnectSelectedEdge = () => { + if (!selectedEdgeId) return; + setEdges((current) => current.filter((edge) => edge.id !== selectedEdgeId)); + setSelectedEdgeId(null); + setFeedback(`Disconnected edge ${selectedEdgeId}`, "info"); + }; + + const handleDeleteSelection = () => { + if (selectedEdgeId) { + handleDisconnectSelectedEdge(); + return; + } + handleDeleteSelectedNode(); + }; + const validateWorkflowDefinition = (definition: WorkflowDefinition) => { const validationErrors: string[] = []; const allNodes = Array.isArray(definition?.nodes) ? definition.nodes : []; @@ -1925,8 +2098,11 @@ export default function App() { if (!ok) return; await deleteWorkflow(activeWorkflow.id); setActiveWorkflow(null); + setSelectedNode(null); + setSelectedEdgeId(null); setNodes(defaultDefinition.nodes as Node[]); setEdges(defaultDefinition.edges as Edge[]); + resetEditorHistory(defaultDefinition.nodes as Node[], defaultDefinition.edges as Edge[]); setRuns([]); setActiveRun(null); setRunDiff(null); @@ -2038,6 +2214,8 @@ export default function App() { return; } const payload = { + schema: WORKFLOW_FILE_SCHEMA, + version: WORKFLOW_FILE_VERSION, name: workflowName.trim() || activeWorkflow.name, definition: buildCurrentDefinition(), exportedAt: new Date().toISOString() @@ -2064,6 +2242,16 @@ export default function App() { if (!file) return; const raw = await file.text(); const parsed = JSON.parse(raw); + const schema = typeof parsed?.schema === "string" ? parsed.schema : ""; + const version = Number(parsed?.version); + if (schema && schema !== WORKFLOW_FILE_SCHEMA) { + throw new Error(`Unsupported workflow file schema: ${schema}`); + } + if (schema && Number.isFinite(version) && version > WORKFLOW_FILE_VERSION) { + throw new Error( + `Workflow file version ${version} is newer than this app supports (max ${WORKFLOW_FILE_VERSION}).` + ); + } const definition = parsed?.definition ?? parsed; const validDefinition = definition && typeof definition === "object" && Array.isArray(definition.nodes) && Array.isArray(definition.edges); @@ -2075,7 +2263,9 @@ export default function App() { const created = await createWorkflow({ name: importedName, definition }); setWorkflowList((list) => [created, ...list]); await selectWorkflow(created); - setFeedback(`Workflow loaded from ${file.name}`, "success"); + const versionSuffix = + schema && Number.isFinite(version) ? ` (schema v${Math.trunc(version)})` : " (legacy format)"; + setFeedback(`Workflow loaded from ${file.name}${versionSuffix}`, "success"); }; const isBusyForAutosave = @@ -2127,8 +2317,10 @@ export default function App() { quickAddInputRef.current?.focus(); quickAddInputRef.current?.select(); }, + onUndo: handleUndo, + onRedo: handleRedo, onDuplicate: handleDuplicateSelectedNode, - onDelete: handleDeleteSelectedNode, + onDelete: handleDeleteSelection, onAutoLayout: () => { void withActionLoading("auto-layout", autoLayoutNodes); } @@ -2887,8 +3079,14 @@ export default function App() { } desktopRecording={desktopRecording} onAutoLayout={() => withActionLoading("auto-layout", autoLayoutNodes)} + onUndo={handleUndo} + onRedo={handleRedo} + canUndo={canUndo} + canRedo={canRedo} onDuplicateSelectedNode={handleDuplicateSelectedNode} canDuplicateSelectedNode={Boolean(selectedNode) && String(selectedNode?.data?.type || "") !== "start"} + onDisconnectSelectedEdge={handleDisconnectSelectedEdge} + canDisconnectSelectedEdge={Boolean(selectedEdgeId)} snapToGrid={snapToGrid} onToggleSnap={() => setSnapToGrid((value) => !value)} onSaveDraft={() => withActionLoading("save-draft", () => handleSave())} @@ -3015,6 +3213,10 @@ export default function App() { onEdgesChange={onEdgesChange} onConnect={onConnect} onNodeClick={(_, node) => setSelectedNode(node)} + onSelectionChange={({ nodes: selectedNodes, edges: selectedEdges }) => { + setSelectedNode(selectedNodes[0] || null); + setSelectedEdgeId(selectedEdges[0]?.id || null); + }} onInit={(instance) => { reactFlowRef.current = instance; }} diff --git a/apps/web/src/components/CanvasToolbar.test.tsx b/apps/web/src/components/CanvasToolbar.test.tsx index b8c897b..f787a55 100644 --- a/apps/web/src/components/CanvasToolbar.test.tsx +++ b/apps/web/src/components/CanvasToolbar.test.tsx @@ -17,8 +17,14 @@ test("quick-add input Enter triggers add-first action", () => { onRecordDesktop={() => undefined} desktopRecording={false} onAutoLayout={() => undefined} + onUndo={() => undefined} + onRedo={() => undefined} + canUndo={false} + canRedo={false} onDuplicateSelectedNode={() => undefined} canDuplicateSelectedNode={false} + onDisconnectSelectedEdge={() => undefined} + canDisconnectSelectedEdge={false} snapToGrid={true} onToggleSnap={() => undefined} onSaveDraft={() => undefined} @@ -47,8 +53,14 @@ test("toolbar shows dirty draft status", () => { onRecordDesktop={() => undefined} desktopRecording={false} onAutoLayout={() => undefined} + onUndo={() => undefined} + onRedo={() => undefined} + canUndo={false} + canRedo={false} onDuplicateSelectedNode={() => undefined} canDuplicateSelectedNode={false} + onDisconnectSelectedEdge={() => undefined} + canDisconnectSelectedEdge={false} snapToGrid={true} onToggleSnap={() => undefined} onSaveDraft={() => undefined} @@ -61,3 +73,43 @@ test("toolbar shows dirty draft status", () => { ); expect(screen.getByText("Unsaved changes")).toBeInTheDocument(); }); + +test("toolbar triggers undo and redo actions", () => { + const onUndo = vi.fn(); + const onRedo = vi.fn(); + render( + undefined} + quickAddInputRef={createRef()} + filteredNodeOptions={[{ label: "HTTP Request", type: "http_request", category: "Core" }]} + onQuickAddFirstNode={() => undefined} + onQuickAddNode={() => undefined} + isActionLoading={() => false} + onRecordWeb={() => undefined} + onRecordDesktop={() => undefined} + desktopRecording={false} + onAutoLayout={() => undefined} + onUndo={onUndo} + onRedo={onRedo} + canUndo={true} + canRedo={true} + onDuplicateSelectedNode={() => undefined} + canDuplicateSelectedNode={false} + onDisconnectSelectedEdge={() => undefined} + canDisconnectSelectedEdge={false} + snapToGrid={true} + onToggleSnap={() => undefined} + onSaveDraft={() => undefined} + onPublish={() => undefined} + onTestRun={() => undefined} + onRun={() => undefined} + isDirty={false} + lastAutoSaveAt={null} + /> + ); + fireEvent.click(screen.getByRole("button", { name: "Undo" })); + fireEvent.click(screen.getByRole("button", { name: "Redo" })); + expect(onUndo).toHaveBeenCalledTimes(1); + expect(onRedo).toHaveBeenCalledTimes(1); +}); diff --git a/apps/web/src/components/CanvasToolbar.tsx b/apps/web/src/components/CanvasToolbar.tsx index 1575371..78b9101 100644 --- a/apps/web/src/components/CanvasToolbar.tsx +++ b/apps/web/src/components/CanvasToolbar.tsx @@ -13,8 +13,14 @@ type CanvasToolbarProps = { onRecordDesktop: () => void; desktopRecording: boolean; onAutoLayout: () => void; + onUndo: () => void; + onRedo: () => void; + canUndo: boolean; + canRedo: boolean; onDuplicateSelectedNode: () => void; canDuplicateSelectedNode: boolean; + onDisconnectSelectedEdge: () => void; + canDisconnectSelectedEdge: boolean; snapToGrid: boolean; onToggleSnap: () => void; onSaveDraft: () => void; @@ -37,8 +43,14 @@ export function CanvasToolbar({ onRecordDesktop, desktopRecording, onAutoLayout, + onUndo, + onRedo, + canUndo, + canRedo, onDuplicateSelectedNode, canDuplicateSelectedNode, + onDisconnectSelectedEdge, + canDisconnectSelectedEdge, snapToGrid, onToggleSnap, onSaveDraft, @@ -88,6 +100,12 @@ export function CanvasToolbar({
+ + {isDirty ? "Unsaved changes" : "Saved"} @@ -118,6 +136,9 @@ export function CanvasToolbar({ +
diff --git a/apps/web/src/hooks/useGlobalHotkeys.test.tsx b/apps/web/src/hooks/useGlobalHotkeys.test.tsx index 06ee202..fcc7d6a 100644 --- a/apps/web/src/hooks/useGlobalHotkeys.test.tsx +++ b/apps/web/src/hooks/useGlobalHotkeys.test.tsx @@ -7,6 +7,8 @@ type HarnessProps = { onRun?: () => void; onTestRun?: () => void; onFocusQuickAdd?: () => void; + onUndo?: () => void; + onRedo?: () => void; onDuplicate?: () => void; onDelete?: () => void; onAutoLayout?: () => void; @@ -17,6 +19,8 @@ function Harness({ onRun = () => undefined, onTestRun = () => undefined, onFocusQuickAdd = () => undefined, + onUndo = () => undefined, + onRedo = () => undefined, onDuplicate = () => undefined, onDelete = () => undefined, onAutoLayout = () => undefined @@ -32,6 +36,8 @@ function Harness({ quickRef.current?.focus(); onFocusQuickAdd(); }, + onUndo, + onRedo, onDuplicate, onDelete, onAutoLayout @@ -67,6 +73,20 @@ test("Delete triggers delete action", () => { expect(onDelete).toHaveBeenCalledTimes(1); }); +test("Ctrl/Cmd+Z triggers undo action", () => { + const onUndo = vi.fn(); + render(); + fireEvent.keyDown(window, { key: "z", ctrlKey: true }); + expect(onUndo).toHaveBeenCalledTimes(1); +}); + +test("Ctrl/Cmd+Shift+Z triggers redo action", () => { + const onRedo = vi.fn(); + render(); + fireEvent.keyDown(window, { key: "z", ctrlKey: true, shiftKey: true }); + expect(onRedo).toHaveBeenCalledTimes(1); +}); + test("shortcuts are ignored when focused inside editable elements", () => { const onSave = vi.fn(); render(); diff --git a/apps/web/src/hooks/useGlobalHotkeys.ts b/apps/web/src/hooks/useGlobalHotkeys.ts index d53341d..969ab74 100644 --- a/apps/web/src/hooks/useGlobalHotkeys.ts +++ b/apps/web/src/hooks/useGlobalHotkeys.ts @@ -6,6 +6,8 @@ type UseGlobalHotkeysParams = { onRun: () => void; onTestRun: () => void; onFocusQuickAdd: () => void; + onUndo: () => void; + onRedo: () => void; onDuplicate: () => void; onDelete: () => void; onAutoLayout: () => void; @@ -29,6 +31,8 @@ export function useGlobalHotkeys({ onRun, onTestRun, onFocusQuickAdd, + onUndo, + onRedo, onDuplicate, onDelete, onAutoLayout @@ -61,12 +65,27 @@ export function useGlobalHotkeys({ onFocusQuickAdd(); return; } + if (hasCmd && key === "z" && event.shiftKey) { + event.preventDefault(); + onRedo(); + return; + } + if (hasCmd && key === "z") { + event.preventDefault(); + onUndo(); + return; + } + if (hasCmd && key === "y") { + event.preventDefault(); + onRedo(); + return; + } if (hasCmd && key === "d") { event.preventDefault(); onDuplicate(); return; } - if (key === "delete" || key === "backspace") { + if (!hasCmd && (key === "delete" || key === "backspace")) { event.preventDefault(); onDelete(); return; @@ -81,5 +100,5 @@ export function useGlobalHotkeys({ return () => { window.removeEventListener("keydown", onKeyDown); }; - }, [enabled, onSave, onRun, onTestRun, onFocusQuickAdd, onDuplicate, onDelete, onAutoLayout]); + }, [enabled, onSave, onRun, onTestRun, onFocusQuickAdd, onUndo, onRedo, onDuplicate, onDelete, onAutoLayout]); } diff --git a/docs/DEMOS.md b/docs/DEMOS.md index 41a9aa6..f7c2f38 100644 --- a/docs/DEMOS.md +++ b/docs/DEMOS.md @@ -47,6 +47,20 @@ Expected outcome: - Normalized data is copied to target key. - Context inspector shows extracted + transferred values. +## Demo 4: Workflow Builder MVP Controls +Goal: Validate the core drag-and-drop builder flow and persistence. + +1. Create/select a workflow and add 3+ nodes from quick-add. +2. Connect nodes on canvas using edge handles. +3. Select an edge and remove it (`Delete` or `More > Disconnect Edge`). +4. Move or delete a node, then run `Undo` and `Redo`. +5. Export with `Save Workflow File`, then import with `Load Workflow File`. + +Expected outcome: +- Editor supports incremental design changes with reversible history. +- Edge disconnect works without deleting nodes. +- Exported files include workflow schema metadata and can be re-imported. + ## Demo Assets - Template source: `apps/server/src/lib/templates.ts` - Node catalog: `apps/web/src/lib/nodeCatalog.ts` @@ -56,5 +70,6 @@ Expected outcome: - Demo 1: 5-7 minutes - Demo 2: 4-6 minutes - Demo 3: 4-5 minutes +- Demo 4: 4-6 minutes -Total: 15-18 minutes for a full product walkthrough. +Total: 19-24 minutes for a full product walkthrough.