diff --git a/scripts/002_create_rls_policies.sql b/scripts/002_create_rls_policies.sql index 18986fe..f895928 100644 --- a/scripts/002_create_rls_policies.sql +++ b/scripts/002_create_rls_policies.sql @@ -207,6 +207,32 @@ CREATE POLICY "Editors can create workflow versions" ) ); +DROP POLICY IF EXISTS "Editors can update workflow versions" ON public.workflow_versions; +CREATE POLICY "Editors can update workflow versions" + ON public.workflow_versions FOR UPDATE + USING ( + workflow_id IN ( + SELECT id FROM public.workflows + WHERE workspace_id IN ( + SELECT workspace_id FROM public.get_user_workspace_roles() + WHERE role IN ('owner', 'editor') + ) + ) + ); + +DROP POLICY IF EXISTS "Editors can delete workflow versions" ON public.workflow_versions; +CREATE POLICY "Editors can delete workflow versions" + ON public.workflow_versions FOR DELETE + USING ( + workflow_id IN ( + SELECT id FROM public.workflows + WHERE workspace_id IN ( + SELECT workspace_id FROM public.get_user_workspace_roles() + WHERE role IN ('owner', 'editor') + ) + ) + ); + -- Workflow executions policies DROP POLICY IF EXISTS "Users can view executions in their workspaces" ON public.workflow_executions; CREATE POLICY "Users can view executions in their workspaces" diff --git a/src/app/api/workflows/[id]/auto-layout/route.ts b/src/app/api/workflows/[id]/auto-layout/route.ts index 552dd1c..fbc4892 100644 --- a/src/app/api/workflows/[id]/auto-layout/route.ts +++ b/src/app/api/workflows/[id]/auto-layout/route.ts @@ -8,14 +8,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ if (result.error) return result.error const { id: workflowId } = await params - const workflow = await getWorkflow(workflowId) + const workflow = await getWorkflow(workflowId, result.workspace.id) if (!workflow) { return NextResponse.json({ error: "Workflow not found" }, { status: 404 }) } const layoutedNodes = autoLayout.applyLayout(workflow.nodes, workflow.connections) - const updated = await updateWorkflow(workflowId, { nodes: layoutedNodes }) + const updated = await updateWorkflow(workflowId, { nodes: layoutedNodes }, result.workspace.id) return NextResponse.json(updated) } diff --git a/src/app/api/workflows/[id]/connections/route.ts b/src/app/api/workflows/[id]/connections/route.ts index 8153415..51d4d32 100644 --- a/src/app/api/workflows/[id]/connections/route.ts +++ b/src/app/api/workflows/[id]/connections/route.ts @@ -10,7 +10,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const connectionData = await request.json() try { - const workflow = await addWorkflowConnection(id, connectionData) + const workflow = await addWorkflowConnection(id, connectionData, result.workspace.id) const newConnection = workflow.connections[workflow.connections.length - 1] return NextResponse.json(newConnection) } catch (err) { diff --git a/src/app/api/workflows/[id]/copy/route.ts b/src/app/api/workflows/[id]/copy/route.ts index ff7669a..bb6a38c 100644 --- a/src/app/api/workflows/[id]/copy/route.ts +++ b/src/app/api/workflows/[id]/copy/route.ts @@ -13,7 +13,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const { id: workflowId } = await params const { nodeIds } = await request.json() - const workflow = await getWorkflow(workflowId) + const workflow = await getWorkflow(workflowId, result.workspace.id) if (!workflow) { return NextResponse.json({ error: "Workflow not found" }, { status: 404 }) } diff --git a/src/app/api/workflows/[id]/execute/route.ts b/src/app/api/workflows/[id]/execute/route.ts index f14f105..d5c4ad8 100644 --- a/src/app/api/workflows/[id]/execute/route.ts +++ b/src/app/api/workflows/[id]/execute/route.ts @@ -12,7 +12,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const { id } = await params const { input } = await request.json() - const workflow = await getWorkflow(id) + const workflow = await getWorkflow(id, result.workspace.id) if (!workflow) { return NextResponse.json({ error: "Workflow not found" }, { status: 404 }) diff --git a/src/app/api/workflows/[id]/export/route.ts b/src/app/api/workflows/[id]/export/route.ts index 000bb75..9b6c01e 100644 --- a/src/app/api/workflows/[id]/export/route.ts +++ b/src/app/api/workflows/[id]/export/route.ts @@ -7,7 +7,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (result.error) return result.error const { id: workflowId } = await params - const workflow = await getWorkflow(workflowId) + const workflow = await getWorkflow(workflowId, result.workspace.id) if (!workflow) { return NextResponse.json({ error: "Workflow not found" }, { status: 404 }) diff --git a/src/app/api/workflows/[id]/history/status/route.ts b/src/app/api/workflows/[id]/history/status/route.ts index 921b8ce..4e34673 100644 --- a/src/app/api/workflows/[id]/history/status/route.ts +++ b/src/app/api/workflows/[id]/history/status/route.ts @@ -1,16 +1,16 @@ +// Deprecated; use client HistoryManager for history status (Builder Hardening Sprint 0). import { type NextRequest, NextResponse } from "next/server" import { withWorkspace } from "@/lib/api/with-workspace" -import { getHistoryManager } from "@/lib/history-manager" -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { +export async function GET(_request: NextRequest, _ctx: { params: Promise<{ id: string }> }) { const result = await withWorkspace() if (result.error) return result.error - const { id: workflowId } = await params - const historyManager = getHistoryManager(workflowId) - - return NextResponse.json({ - canUndo: historyManager.canUndo(), - canRedo: historyManager.canRedo(), - }) + return NextResponse.json( + { + error: + "This history status endpoint is gone (410). Undo/redo/history status are client-only via HistoryManager (Builder Hardening Sprint 0).", + }, + { status: 410 }, + ) } diff --git a/src/app/api/workflows/[id]/nodes/[nodeId]/route.ts b/src/app/api/workflows/[id]/nodes/[nodeId]/route.ts index 2d15d29..0843302 100644 --- a/src/app/api/workflows/[id]/nodes/[nodeId]/route.ts +++ b/src/app/api/workflows/[id]/nodes/[nodeId]/route.ts @@ -13,7 +13,7 @@ export async function PATCH( const updates = await request.json() try { - const workflow = await updateWorkflowNode(id, nodeId, updates) + const workflow = await updateWorkflowNode(id, nodeId, updates, result.workspace.id) const node = workflow.nodes.find((n) => n.id === nodeId) if (!node) return NextResponse.json({ error: "Node not found" }, { status: 404 }) return NextResponse.json(node) @@ -32,7 +32,7 @@ export async function DELETE( const { id, nodeId } = await params try { - await deleteWorkflowNode(id, nodeId) + await deleteWorkflowNode(id, nodeId, result.workspace.id) return NextResponse.json({ success: true }) } catch { return NextResponse.json({ error: "Node not found" }, { status: 404 }) diff --git a/src/app/api/workflows/[id]/nodes/route.ts b/src/app/api/workflows/[id]/nodes/route.ts index f432103..6fb96c3 100644 --- a/src/app/api/workflows/[id]/nodes/route.ts +++ b/src/app/api/workflows/[id]/nodes/route.ts @@ -10,7 +10,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const nodeData = await request.json() try { - const workflow = await addWorkflowNode(id, nodeData) + const workflow = await addWorkflowNode(id, nodeData, result.workspace.id) const newNode = workflow.nodes[workflow.nodes.length - 1] return NextResponse.json(newNode) } catch (err: unknown) { diff --git a/src/app/api/workflows/[id]/paste/route.ts b/src/app/api/workflows/[id]/paste/route.ts index 5a74803..74f6ab1 100644 --- a/src/app/api/workflows/[id]/paste/route.ts +++ b/src/app/api/workflows/[id]/paste/route.ts @@ -23,7 +23,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: "Nothing to paste" }, { status: 400 }) } - const workflow = await getWorkflow(workflowId) + const workflow = await getWorkflow(workflowId, result.workspace.id) if (!workflow) { return NextResponse.json({ error: "Workflow not found" }, { status: 404 }) } @@ -59,10 +59,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ...(pastedConnections as typeof workflow.connections), ] - const updated = await updateWorkflow(workflowId, { - nodes: updatedNodes, - connections: updatedConnections, - }) + const updated = await updateWorkflow( + workflowId, + { + nodes: updatedNodes, + connections: updatedConnections, + }, + result.workspace.id, + ) const newNodeIds = pastedNodes.map((n) => n.id) return NextResponse.json({ success: true, nodeIds: newNodeIds, workflow: updated }) diff --git a/src/app/api/workflows/[id]/redo/route.ts b/src/app/api/workflows/[id]/redo/route.ts index 1f95616..be3c42e 100644 --- a/src/app/api/workflows/[id]/redo/route.ts +++ b/src/app/api/workflows/[id]/redo/route.ts @@ -1,30 +1,16 @@ +// Deprecated; use client HistoryManager for undo/redo (Builder Hardening Sprint 0). import { type NextRequest, NextResponse } from "next/server" import { withWorkspace } from "@/lib/api/with-workspace" -import { getWorkflow, updateWorkflow } from "@/lib/db/workflows" -import { getHistoryManager } from "@/lib/history-manager" -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { +export async function POST(_request: NextRequest, _ctx: { params: Promise<{ id: string }> }) { const result = await withWorkspace() if (result.error) return result.error - const { id: workflowId } = await params - const workflow = await getWorkflow(workflowId) - - if (!workflow) { - return NextResponse.json({ error: "Workflow not found" }, { status: 404 }) - } - - const historyManager = getHistoryManager(workflowId) - const nextWorkflow = historyManager.redo(workflow) - - if (!nextWorkflow) { - return NextResponse.json({ error: "Nothing to redo" }, { status: 400 }) - } - - const updated = await updateWorkflow(workflowId, { - nodes: nextWorkflow.nodes, - connections: nextWorkflow.connections, - }) - - return NextResponse.json(updated) + return NextResponse.json( + { + error: + "This redo endpoint is gone (410). Undo/redo are client-only via HistoryManager (Builder Hardening Sprint 0).", + }, + { status: 410 }, + ) } diff --git a/src/app/api/workflows/[id]/route.ts b/src/app/api/workflows/[id]/route.ts index 66bd6b0..fab60f1 100644 --- a/src/app/api/workflows/[id]/route.ts +++ b/src/app/api/workflows/[id]/route.ts @@ -7,7 +7,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: if (result.error) return result.error const { id } = await params - const workflow = await getWorkflow(id) + const workflow = await getWorkflow(id, result.workspace.id) if (!workflow) { return NextResponse.json({ error: "Workflow not found" }, { status: 404 }) @@ -22,7 +22,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id const { id } = await params const updates = await request.json() - const workflow = await updateWorkflow(id, updates) + const workflow = await updateWorkflow(id, updates, result.workspace.id) if (!workflow) { return NextResponse.json({ error: "Workflow not found" }, { status: 404 }) @@ -36,7 +36,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i if (result.error) return result.error const { id } = await params - const deleted = await deleteWorkflow(id) + const deleted = await deleteWorkflow(id, result.workspace.id) if (!deleted) { return NextResponse.json({ error: "Workflow not found" }, { status: 404 }) diff --git a/src/app/api/workflows/[id]/undo/route.ts b/src/app/api/workflows/[id]/undo/route.ts index 06f4b5b..7d8d580 100644 --- a/src/app/api/workflows/[id]/undo/route.ts +++ b/src/app/api/workflows/[id]/undo/route.ts @@ -1,30 +1,16 @@ +// Deprecated; use client HistoryManager for undo/redo (Builder Hardening Sprint 0). import { type NextRequest, NextResponse } from "next/server" import { withWorkspace } from "@/lib/api/with-workspace" -import { getWorkflow, updateWorkflow } from "@/lib/db/workflows" -import { getHistoryManager } from "@/lib/history-manager" -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { +export async function POST(_request: NextRequest, _ctx: { params: Promise<{ id: string }> }) { const result = await withWorkspace() if (result.error) return result.error - const { id: workflowId } = await params - const workflow = await getWorkflow(workflowId) - - if (!workflow) { - return NextResponse.json({ error: "Workflow not found" }, { status: 404 }) - } - - const historyManager = getHistoryManager(workflowId) - const previousWorkflow = historyManager.undo(workflow) - - if (!previousWorkflow) { - return NextResponse.json({ error: "Nothing to undo" }, { status: 400 }) - } - - const updated = await updateWorkflow(workflowId, { - nodes: previousWorkflow.nodes, - connections: previousWorkflow.connections, - }) - - return NextResponse.json(updated) + return NextResponse.json( + { + error: + "This undo endpoint is gone (410). Undo/redo are client-only via HistoryManager (Builder Hardening Sprint 0).", + }, + { status: 410 }, + ) } diff --git a/src/app/api/workflows/[id]/versions/[version]/route.ts b/src/app/api/workflows/[id]/versions/[version]/route.ts index c5f51be..0f11f94 100644 --- a/src/app/api/workflows/[id]/versions/[version]/route.ts +++ b/src/app/api/workflows/[id]/versions/[version]/route.ts @@ -1,6 +1,27 @@ import { type NextRequest, NextResponse } from "next/server" +import { z } from "zod" +import { parseVersionParam } from "@/lib/api/version-param" import { withWorkspace } from "@/lib/api/with-workspace" -import { versionStore } from "@/lib/version-store" +import { versionStore, type VersionWriteResult } from "@/lib/version-store" + +const tagVersionBodySchema = z.object({ + tag: z.string().min(1, "tag must be a non-empty string"), +}) + +function writeResultResponse(result: VersionWriteResult): NextResponse { + switch (result) { + case "ok": + return NextResponse.json({ success: true }) + case "not_found": + return NextResponse.json({ error: "Version not found" }, { status: 404 }) + case "forbidden": + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + default: { + const _exhaustive: never = result + return _exhaustive + } + } +} export async function GET( request: NextRequest, @@ -10,14 +31,24 @@ export async function GET( if (result.error) return result.error const { id, version } = await params - const versionNumber = Number.parseInt(version) - const versionData = versionStore.getVersion(id, versionNumber) - - if (!versionData) { - return NextResponse.json({ error: "Version not found" }, { status: 404 }) + const parsed = parseVersionParam(version) + if (!parsed.ok) { + return NextResponse.json( + { error: `Invalid version number: expected integer, got ${JSON.stringify(version)}` }, + { status: 400 }, + ) } - return NextResponse.json(versionData) + try { + const versionData = await versionStore.getVersion(id, parsed.value) + if (!versionData) { + return NextResponse.json({ error: "Version not found" }, { status: 404 }) + } + return NextResponse.json(versionData) + } catch (error) { + console.error("Version get error:", error instanceof Error ? error.message : String(error)) + return NextResponse.json({ error: "Failed to load version" }, { status: 500 }) + } } export async function DELETE( @@ -28,14 +59,21 @@ export async function DELETE( if (result.error) return result.error const { id, version } = await params - const versionNumber = Number.parseInt(version) - const success = versionStore.deleteVersion(id, versionNumber) - - if (!success) { - return NextResponse.json({ error: "Version not found" }, { status: 404 }) + const parsed = parseVersionParam(version) + if (!parsed.ok) { + return NextResponse.json( + { error: `Invalid version number: expected integer, got ${JSON.stringify(version)}` }, + { status: 400 }, + ) } - return NextResponse.json({ success: true }) + try { + const writeResult = await versionStore.deleteVersion(id, parsed.value) + return writeResultResponse(writeResult) + } catch (error) { + console.error("Version delete error:", error instanceof Error ? error.message : String(error)) + return NextResponse.json({ error: "Failed to delete version" }, { status: 500 }) + } } export async function PATCH( @@ -47,16 +85,24 @@ export async function PATCH( try { const { id, version } = await params - const { tag } = await request.json() - const versionNumber = Number.parseInt(version) - const success = versionStore.tagVersion(id, versionNumber, tag) - - if (!success) { - return NextResponse.json({ error: "Version not found" }, { status: 404 }) + const parsed = parseVersionParam(version) + if (!parsed.ok) { + return NextResponse.json( + { error: `Invalid version number: expected integer, got ${JSON.stringify(version)}` }, + { status: 400 }, + ) } - return NextResponse.json({ success: true }) + const body = tagVersionBodySchema.parse(await request.json()) + const writeResult = await versionStore.tagVersion(id, parsed.value, body.tag) + return writeResultResponse(writeResult) } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request body", details: error.flatten() }, + { status: 400 }, + ) + } console.error("Version tag error:", error instanceof Error ? error.message : String(error)) return NextResponse.json({ error: "Failed to tag version" }, { status: 500 }) } diff --git a/src/app/api/workflows/[id]/versions/compare/route.ts b/src/app/api/workflows/[id]/versions/compare/route.ts index c09347d..e5594ac 100644 --- a/src/app/api/workflows/[id]/versions/compare/route.ts +++ b/src/app/api/workflows/[id]/versions/compare/route.ts @@ -1,4 +1,5 @@ import { type NextRequest, NextResponse } from "next/server" +import { parseVersionParam } from "@/lib/api/version-param" import { withWorkspace } from "@/lib/api/with-workspace" import { versionStore } from "@/lib/version-store" @@ -8,18 +9,23 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const { id } = await params const searchParams = request.nextUrl.searchParams - const v1 = Number.parseInt(searchParams.get("v1") || "") - const v2 = Number.parseInt(searchParams.get("v2") || "") + const v1Raw = searchParams.get("v1") || "" + const v2Raw = searchParams.get("v2") || "" + const v1 = parseVersionParam(v1Raw) + const v2 = parseVersionParam(v2Raw) - if (isNaN(v1) || isNaN(v2)) { + if (!v1.ok || !v2.ok) { return NextResponse.json({ error: "Invalid version numbers" }, { status: 400 }) } - const comparison = versionStore.compareVersions(id, v1, v2) - - if (!comparison) { - return NextResponse.json({ error: "Versions not found" }, { status: 404 }) + try { + const comparison = await versionStore.compareVersions(id, v1.value, v2.value) + if (!comparison) { + return NextResponse.json({ error: "Versions not found" }, { status: 404 }) + } + return NextResponse.json(comparison) + } catch (error) { + console.error("Version compare error:", error instanceof Error ? error.message : String(error)) + return NextResponse.json({ error: "Failed to compare versions" }, { status: 500 }) } - - return NextResponse.json(comparison) } diff --git a/src/app/api/workflows/[id]/versions/route.ts b/src/app/api/workflows/[id]/versions/route.ts index 902f5e3..76b0439 100644 --- a/src/app/api/workflows/[id]/versions/route.ts +++ b/src/app/api/workflows/[id]/versions/route.ts @@ -1,14 +1,24 @@ import { type NextRequest, NextResponse } from "next/server" +import { z } from "zod" import { withWorkspace } from "@/lib/api/with-workspace" import { getWorkflow } from "@/lib/db/workflows" import { versionStore } from "@/lib/version-store" +const createVersionBodySchema = z.object({ + description: z.string().optional(), +}) + export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const result = await withWorkspace() if (result.error) return result.error const { id } = await params - const versions = versionStore.getVersions(id) + const workflow = await getWorkflow(id, result.workspace.id) + if (!workflow) { + return NextResponse.json({ error: "Workflow not found" }, { status: 404 }) + } + + const versions = await versionStore.getVersions(id) return NextResponse.json(versions) } @@ -18,16 +28,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ try { const { id } = await params - const { description } = await request.json() - const workflow = await getWorkflow(id) + const body = createVersionBodySchema.parse(await request.json().catch(() => ({}))) + const workflow = await getWorkflow(id, result.workspace.id) if (!workflow) { return NextResponse.json({ error: "Workflow not found" }, { status: 404 }) } - const version = versionStore.createVersion(workflow, description) + const version = await versionStore.createVersion(workflow, body.description) return NextResponse.json(version) } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request body", details: error.flatten() }, + { status: 400 }, + ) + } console.error("Version creation error:", error instanceof Error ? error.message : String(error)) return NextResponse.json({ error: "Failed to create version" }, { status: 500 }) } diff --git a/src/components/builder/builder-canvas.tsx b/src/components/builder/builder-canvas.tsx index 15571d5..9b4e46e 100644 --- a/src/components/builder/builder-canvas.tsx +++ b/src/components/builder/builder-canvas.tsx @@ -2,17 +2,15 @@ import type React from "react" -import { useState, useCallback, useRef, useEffect, useMemo } from "react" +import { useState, useCallback, useEffect, useMemo } from "react" import { ReactFlow, ReactFlowProvider, Background, useReactFlow, + useViewport, useNodesState, useEdgesState, - type NodeChange, - type EdgeChange, - type Connection as ReactFlowConnection, type Node, type NodeProps, } from "@xyflow/react" @@ -31,7 +29,7 @@ import { } from "lucide-react" import { Button } from "@/components/ui/button" import Link from "next/link" -import type { WorkflowNode, Position, NodeType, Workflow, Connection } from "@/lib/workflow-types" +import type { NodeType, Workflow } from "@/lib/workflow-types" import { NodeSidebar } from "./node-sidebar" import { CanvasNode } from "./canvas-node" import { FrameNode } from "./frame-node" @@ -43,16 +41,19 @@ import { ExportImportDialog } from "./export-import-dialog" import { BuilderCommandPalette } from "./builder-command-palette" import { NodeContextMenu, PaneContextMenu } from "./builder-context-menu" import { GlassContainer } from "@/components/ui/glass-container" -import { getHistoryManager } from "@/lib/history-manager" import { useToast } from "@/hooks/use-toast" import { workflowNodesToReactFlow, workflowConnectionsToEdges, - reactFlowEdgesToConnections, } from "@/lib/builder/workflow-to-reactflow" import { edgeTypes } from "./edges" import type { WorkflowNodeData, WorkflowNodeProps } from "./canvas-node" import type { FrameNodeData } from "./frame-node" +import { GRID_SIZE, NODE_TYPES } from "./builder-constants" +import { useBuilderHistory } from "./hooks/use-builder-history" +import { useBuilderExecutionHighlight } from "./hooks/use-builder-execution-highlight" +import { useBuilderClipboard } from "./hooks/use-builder-clipboard" +import { useBuilderGraphMutations } from "./hooks/use-builder-graph-mutations" const fetcher = async (url: string) => { const res = await fetch(url) @@ -61,24 +62,10 @@ const fetcher = async (url: string) => { return res.json() } -const GRID_SIZE = 20 -const NODE_TYPES = [ - "agent", - "start", - "end", - "guardrail", - "condition", - "mcp", - "user-approval", - "file-search", - "frame", -] as const - function BuilderCanvasInner() { - const clipboardRef = useRef<{ nodes: WorkflowNode[]; connections: Connection[] } | null>(null) - const layoutTransitionTimeoutRef = useRef | null>(null) const { toast } = useToast() - const { screenToFlowPosition, getViewport } = useReactFlow() + const { screenToFlowPosition, zoomIn, zoomOut, fitView } = useReactFlow() + const viewport = useViewport() const safeFetch = useCallback( async (url: string, options?: RequestInit) => { @@ -99,6 +86,13 @@ function BuilderCanvasInner() { const [workflowId, setWorkflowId] = useState(null) const [creationFailed, setCreationFailed] = useState(false) + const [showSidebar, setShowSidebar] = useState(true) + const [showProperties, setShowProperties] = useState(true) + const [showVersionHistory, setShowVersionHistory] = useState(false) + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false) + const [menuType, setMenuType] = useState<"node" | "pane" | null>(null) + const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null) + const [menuNodeId, setMenuNodeId] = useState(null) const { data: workflows, @@ -151,126 +145,29 @@ function BuilderCanvasInner() { revalidateOnReconnect: false, }, ) - const { data: historyStatus } = useSWR<{ canUndo: boolean; canRedo: boolean } | null>( - workflowId ? `/api/workflows/${workflowId}/history/status` : null, - fetcher, - { revalidateOnFocus: false }, - ) - - const [showSidebar, setShowSidebar] = useState(true) - const [showProperties, setShowProperties] = useState(true) - const [showVersionHistory, setShowVersionHistory] = useState(false) - const [showExecutionMonitor, setShowExecutionMonitor] = useState(false) - const [commandPaletteOpen, setCommandPaletteOpen] = useState(false) - const [highlightedNodeId, setHighlightedNodeId] = useState(null) - const [highlightedEdgeIds, _setHighlightedEdgeIds] = useState([]) - const [isLayoutTransitioning, setIsLayoutTransitioning] = useState(false) - - const saveToHistory = useCallback(() => { - if (workflow && workflowId) { - const historyManager = getHistoryManager(workflowId) - historyManager.saveState(workflow) - } - }, [workflow, workflowId]) - - const mutateWorkflowAndHistory = useCallback((id: string) => { - mutate(`/api/workflows/${id}`) - mutate(`/api/workflows/${id}/history/status`) - }, []) - - const [menuType, setMenuType] = useState<"node" | "pane" | null>(null) - const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null) - const [menuNodeId, setMenuNodeId] = useState(null) - - const handlePaneContextMenu = useCallback((event: React.MouseEvent | MouseEvent) => { - event.preventDefault() - setMenuType("pane") - if ("clientX" in event) { - setMenuPosition({ x: event.clientX, y: event.clientY }) - } - setMenuNodeId(null) - }, []) - - const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => { - event.preventDefault() - event.stopPropagation() - setMenuType("node") - setMenuPosition({ x: event.clientX, y: event.clientY }) - setMenuNodeId(node.id) - }, []) - - const handleCloseMenu = useCallback(() => { - setMenuType(null) - setMenuPosition(null) - setMenuNodeId(null) - }, []) - - const handleNodeDeleteById = useCallback( - async (nodeId: string) => { - if (!workflowId) return - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { method: "DELETE" }) - mutateWorkflowAndHistory(workflowId) - }, - [workflowId, saveToHistory, mutateWorkflowAndHistory, safeFetch], - ) - - const handleAssignToFrame = useCallback( - async (nodeId: string, frameId: string) => { - if (!workflowId) return - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ parentId: frameId }), - }) - mutateWorkflowAndHistory(workflowId) - toast({ title: "Node added to frame" }) - }, - [workflowId, saveToHistory, mutateWorkflowAndHistory, toast, safeFetch], - ) - const handleRemoveFromFrame = useCallback( - async (nodeId: string) => { - if (!workflowId) return - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ parentId: null }), - }) - mutateWorkflowAndHistory(workflowId) - toast({ title: "Node removed from frame" }) - }, - [workflowId, saveToHistory, mutateWorkflowAndHistory, toast, safeFetch], - ) + const { + canUndo, + canRedo, + isHistoryTransitioning, + saveToHistory, + mutateWorkflow, + handleUndo, + handleRedo, + handleRestoreVersion, + } = useBuilderHistory({ workflowId, workflow, safeFetch, toast }) - const handleFrameLabelChange = useCallback( - async (nodeId: string, newLabel: string) => { - if (!workflowId) return - const node = workflow?.nodes.find((n) => n.id === nodeId) - if (!node) return - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data: { ...node.data, label: newLabel } }), - }) - mutateWorkflowAndHistory(workflowId) - }, - [workflowId, workflow?.nodes, saveToHistory, mutateWorkflowAndHistory, safeFetch], - ) + const { + showExecutionMonitor, + highlightedNodeId, + highlightedEdgeIds, + handleNodeHighlight, + handleExecutionPath, + handleCloseExecutionMonitor, + handleToggleExecutionMonitor, + } = useBuilderExecutionHighlight(workflow?.connections) - const initialNodes = workflow - ? workflowNodesToReactFlow(workflow.nodes).map((n) => ({ - ...n, - data: { - ...n.data, - isHighlighted: n.id === highlightedNodeId, - customOnDelete: () => handleNodeDeleteById(n.id), - } as WorkflowNodeData & { customOnDelete?: () => void }, - })) - : [] + const initialNodes = workflow ? workflowNodesToReactFlow(workflow.nodes) : [] const initialEdges = workflow ? workflowConnectionsToEdges(workflow.connections, { nodes: workflow.nodes, @@ -281,39 +178,81 @@ function BuilderCanvasInner() { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes) const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges) + const { + isLayoutTransitioning, + handleNodeDeleteById, + handleAssignToFrame, + handleRemoveFromFrame, + handleFrameLabelChange, + handleNodesChange, + handleEdgesChange, + handleConnect, + handleNodeDragStop, + handleAddNode, + handleAddFrame, + handleAutoLayout, + handleNodeDelete, + handleSaveVersion, + } = useBuilderGraphMutations({ + workflowId, + workflow, + edges, + saveToHistory, + mutateWorkflow, + safeFetch, + toast, + screenToFlowPosition, + onNodesChange, + onEdgesChange, + }) + + const selectedNodeIds = nodes.filter((n) => n.selected).map((n) => n.id) + const selectedNodeId = selectedNodeIds[0] ?? null + const selectedNode = workflow?.nodes?.find((n) => n.id === selectedNodeId) + + const { handleCopy, handlePaste, handleDuplicate, handleDuplicateById, handleCopyById } = + useBuilderClipboard({ + workflowId, + selectedNodeIds, + workflow, + saveToHistory, + mutateWorkflow, + safeFetch, + toast, + }) + useEffect(() => { - if (workflow) { - let flowNodes = workflowNodesToReactFlow(workflow.nodes).map((n) => ({ + if (!workflow) return + let flowNodes = workflowNodesToReactFlow(workflow.nodes).map((n) => ({ + ...n, + data: { + ...n.data, + isHighlighted: n.id === highlightedNodeId, + customOnDelete: () => handleNodeDeleteById(n.id), + ...(n.type === "frame" && { + customOnLabelChange: (newLabel: string) => handleFrameLabelChange(n.id, newLabel), + }), + } as WorkflowNodeData & { + customOnDelete?: () => void + customOnLabelChange?: (l: string) => void + }, + })) + if (isLayoutTransitioning) { + flowNodes = flowNodes.map((n) => ({ ...n, - data: { - ...n.data, - isHighlighted: n.id === highlightedNodeId, - customOnDelete: () => handleNodeDeleteById(n.id), - ...(n.type === "frame" && { - customOnLabelChange: (newLabel: string) => handleFrameLabelChange(n.id, newLabel), - }), - } as WorkflowNodeData & { - customOnDelete?: () => void - customOnLabelChange?: (l: string) => void + style: { + ...n.style, + transition: "transform 0.4s cubic-bezier(0.4, 0, 0.2, 1)", }, })) - if (isLayoutTransitioning) { - flowNodes = flowNodes.map((n) => ({ - ...n, - style: { - ...n.style, - transition: "transform 0.4s cubic-bezier(0.4, 0, 0.2, 1)", - }, - })) - } - setNodes(flowNodes) - setEdges( - workflowConnectionsToEdges(workflow.connections, { - nodes: workflow.nodes, - runningEdgeIds: highlightedEdgeIds, - }), - ) } + setNodes(flowNodes) + setEdges( + workflowConnectionsToEdges(workflow.connections, { + nodes: workflow.nodes, + runningEdgeIds: highlightedEdgeIds, + }), + ) }, [ workflow, highlightedNodeId, @@ -325,306 +264,37 @@ function BuilderCanvasInner() { setEdges, ]) - const handleNodesChange = useCallback( - (changes: NodeChange>[]) => { - onNodesChange(changes) - }, - [onNodesChange], - ) - - const handleEdgesChange = useCallback( - (changes: EdgeChange[]) => { - onEdgesChange(changes) - const removeChanges = changes.filter((c) => c.type === "remove") as { id: string }[] - if (removeChanges.length > 0 && workflowId) { - saveToHistory() - const updatedEdges = edges.filter((e) => !removeChanges.some((r) => r.id === e.id)) - safeFetch(`/api/workflows/${workflowId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ connections: reactFlowEdgesToConnections(updatedEdges) }), - }).then(() => mutateWorkflowAndHistory(workflowId!)) - } - }, - [onEdgesChange, edges, workflowId, saveToHistory, mutateWorkflowAndHistory, safeFetch], - ) - - const handleConnect = useCallback( - async (connection: ReactFlowConnection) => { - if (!workflowId || !connection.source || !connection.target) return - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/connections`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sourceId: connection.source, - targetId: connection.target, - sourceHandle: connection.sourceHandle ?? undefined, - targetHandle: connection.targetHandle ?? undefined, - }), - }) - mutateWorkflowAndHistory(workflowId) - }, - [workflowId, saveToHistory, mutateWorkflowAndHistory, safeFetch], - ) - - const handleNodeDragStop = useCallback( - async (_: React.MouseEvent, node: Node) => { - if (!workflowId) return - const snappedPosition = { - x: Math.round(node.position.x / GRID_SIZE) * GRID_SIZE, - y: Math.round(node.position.y / GRID_SIZE) * GRID_SIZE, - } - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/nodes/${node.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ position: snappedPosition }), - }) - mutateWorkflowAndHistory(workflowId) - }, - [workflowId, saveToHistory, mutateWorkflowAndHistory, safeFetch], - ) - - const handleAddNode = useCallback( - async (type: NodeType, position?: Position) => { - if (!workflowId) return - saveToHistory() - - let posX: number - let posY: number - - if (position) { - posX = Math.round(position.x / GRID_SIZE) * GRID_SIZE - posY = Math.round(position.y / GRID_SIZE) * GRID_SIZE - } else { - const center = screenToFlowPosition({ - x: typeof window !== "undefined" ? window.innerWidth / 2 : 400, - y: typeof window !== "undefined" ? window.innerHeight / 2 : 300, - }) - posX = Math.round(center.x / GRID_SIZE) * GRID_SIZE - posY = Math.round(center.y / GRID_SIZE) * GRID_SIZE - } - - while ( - workflow?.nodes.some( - (n) => Math.abs(n.position.x - posX) < 10 && Math.abs(n.position.y - posY) < 10, - ) - ) { - posX += GRID_SIZE * 2 - posY += GRID_SIZE * 2 - } - - const finalPosition = { x: posX, y: posY } - const labels: Record = { - start: "Start", - end: "End", - agent: "New Agent", - guardrail: "Guardrail", - condition: "Condition", - mcp: "MCP Server", - "user-approval": "User Approval", - "file-search": "File Search", - frame: "New Frame", - } - - const nodePayload: Record = { - type, - position: finalPosition, - data: { label: labels[type] }, - } - if (type === "frame") { - nodePayload.data = { label: labels[type], width: 400, height: 300 } - nodePayload.style = { width: 400, height: 300 } - } - - await safeFetch(`/api/workflows/${workflowId}/nodes`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(nodePayload), - }) - mutateWorkflowAndHistory(workflowId) - }, - [ - workflowId, - workflow?.nodes, - saveToHistory, - mutateWorkflowAndHistory, - screenToFlowPosition, - safeFetch, - ], - ) - - const handleAddFrame = useCallback(() => { - handleAddNode("frame") - }, [handleAddNode]) - - const selectedNodeId = nodes.find((n) => n.selected)?.id ?? null - const selectedNode = workflow?.nodes?.find((n) => n.id === selectedNodeId) - - const handleUndo = useCallback(async () => { - if (!workflowId) return - await safeFetch(`/api/workflows/${workflowId}/undo`, { method: "POST" }) - mutateWorkflowAndHistory(workflowId) - }, [workflowId, mutateWorkflowAndHistory, safeFetch]) - - const handleRedo = useCallback(async () => { - if (!workflowId) return - await safeFetch(`/api/workflows/${workflowId}/redo`, { method: "POST" }) - mutateWorkflowAndHistory(workflowId) - }, [workflowId, mutateWorkflowAndHistory, safeFetch]) - - const handleCopy = useCallback(async () => { - if (!selectedNodeId || !workflowId) return - const response = await safeFetch(`/api/workflows/${workflowId}/copy`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ nodeIds: [selectedNodeId] }), - }) - if (response.ok) { - const result = await response.json() - clipboardRef.current = { nodes: result.nodes ?? [], connections: result.connections ?? [] } - toast({ title: "Node copied to clipboard" }) - } - }, [selectedNodeId, workflowId, toast, safeFetch]) - - const handlePaste = useCallback(async () => { - if (!workflowId) return - const clipboard = clipboardRef.current - if (!clipboard?.nodes?.length) { - toast({ title: "Nothing to paste", variant: "destructive" }) - return - } - saveToHistory() - const response = await safeFetch(`/api/workflows/${workflowId}/paste`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ nodes: clipboard.nodes, connections: clipboard.connections }), - }) - if (response.ok) { - const result = await response.json() - mutateWorkflowAndHistory(workflowId) - toast({ title: `Pasted ${result.nodeIds?.length ?? 0} node(s)` }) - } else { - toast({ title: "Nothing to paste", variant: "destructive" }) - } - }, [workflowId, saveToHistory, mutateWorkflowAndHistory, toast, safeFetch]) - - const handleDuplicate = useCallback(async () => { - if (!selectedNodeId || !workflowId) return - saveToHistory() - const copyRes = await safeFetch(`/api/workflows/${workflowId}/copy`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ nodeIds: [selectedNodeId] }), - }) - if (!copyRes.ok) return - const copyResult = await copyRes.json() - clipboardRef.current = { - nodes: copyResult.nodes ?? [], - connections: copyResult.connections ?? [], - } - const pasteRes = await safeFetch(`/api/workflows/${workflowId}/paste`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - nodes: copyResult.nodes ?? [], - connections: copyResult.connections ?? [], - }), - }) - if (pasteRes.ok) { - mutateWorkflowAndHistory(workflowId) - toast({ title: "Node duplicated" }) - } - }, [selectedNodeId, workflowId, saveToHistory, mutateWorkflowAndHistory, toast, safeFetch]) - const handleSelectAll = useCallback(() => { - if (workflow?.nodes.length) { - setNodes((nds) => nds.map((n) => ({ ...n, selected: n.id === workflow.nodes[0].id }))) - } - }, [workflow, setNodes]) - - const handleAutoLayout = useCallback(async () => { - if (!workflowId) return - if (layoutTransitionTimeoutRef.current) { - clearTimeout(layoutTransitionTimeoutRef.current) - layoutTransitionTimeoutRef.current = null - } - saveToHistory() - setIsLayoutTransitioning(true) - const response = await safeFetch(`/api/workflows/${workflowId}/auto-layout`, { method: "POST" }) - if (response.ok) { - mutateWorkflowAndHistory(workflowId) - toast({ title: "Layout applied successfully" }) - layoutTransitionTimeoutRef.current = setTimeout(() => { - layoutTransitionTimeoutRef.current = null - setIsLayoutTransitioning(false) - }, 450) - } else { - setIsLayoutTransitioning(false) - toast({ title: "Failed to apply layout", variant: "destructive" }) - } - }, [workflowId, saveToHistory, mutateWorkflowAndHistory, toast, safeFetch]) + setNodes((nds) => nds.map((n) => ({ ...n, selected: true }))) + setEdges((eds) => eds.map((e) => ({ ...e, selected: true }))) + }, [setNodes, setEdges]) - useEffect(() => { - return () => { - if (layoutTransitionTimeoutRef.current) { - clearTimeout(layoutTransitionTimeoutRef.current) - layoutTransitionTimeoutRef.current = null - } + const handlePaneContextMenu = useCallback((event: React.MouseEvent | MouseEvent) => { + event.preventDefault() + setMenuType("pane") + if ("clientX" in event) { + setMenuPosition({ x: event.clientX, y: event.clientY }) } + setMenuNodeId(null) }, []) - const handleNodeDelete = useCallback(async () => { - if (!selectedNodeId || !workflowId) return - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/nodes/${selectedNodeId}`, { method: "DELETE" }) - mutateWorkflowAndHistory(workflowId) - }, [selectedNodeId, workflowId, saveToHistory, mutateWorkflowAndHistory, safeFetch]) + const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => { + event.preventDefault() + event.stopPropagation() + setMenuType("node") + setMenuPosition({ x: event.clientX, y: event.clientY }) + setMenuNodeId(node.id) + }, []) - const handleDuplicateById = useCallback( - async (nodeId: string) => { - if (!workflowId) return - saveToHistory() - const copyRes = await safeFetch(`/api/workflows/${workflowId}/copy`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ nodeIds: [nodeId] }), - }) - if (!copyRes.ok) return - const copyResult = await copyRes.json() - const pasteRes = await safeFetch(`/api/workflows/${workflowId}/paste`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - nodes: copyResult.nodes ?? [], - connections: copyResult.connections ?? [], - }), - }) - if (pasteRes.ok) { - mutateWorkflowAndHistory(workflowId) - toast({ title: "Node duplicated" }) - } - }, - [workflowId, saveToHistory, mutateWorkflowAndHistory, toast, safeFetch], - ) + const handleCloseMenu = useCallback(() => { + setMenuType(null) + setMenuPosition(null) + setMenuNodeId(null) + }, []) - const handleCopyById = useCallback( - async (nodeId: string) => { - if (!workflowId) return - const response = await safeFetch(`/api/workflows/${workflowId}/copy`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ nodeIds: [nodeId] }), - }) - if (response.ok) { - const result = await response.json() - clipboardRef.current = { nodes: result.nodes ?? [], connections: result.connections ?? [] } - toast({ title: "Node copied to clipboard" }) - } - }, - [workflowId, toast, safeFetch], - ) + const handleZoomIn = useCallback(() => zoomIn(), [zoomIn]) + const handleZoomOut = useCallback(() => zoomOut(), [zoomOut]) + const handleResetView = useCallback(() => fitView({ padding: 0.2 }), [fitView]) const nodeTypes = useMemo(() => { return Object.fromEntries( @@ -635,21 +305,6 @@ function BuilderCanvasInner() { > }, []) - const handleSaveVersion = useCallback(async () => { - if (!workflowId) return - await safeFetch(`/api/workflows/${workflowId}/versions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ description: "Manual save" }), - }) - }, [workflowId, safeFetch]) - - const { zoomIn, zoomOut, fitView } = useReactFlow() - - const handleZoomIn = useCallback(() => zoomIn(), [zoomIn]) - const handleZoomOut = useCallback(() => zoomOut(), [zoomOut]) - const handleResetView = useCallback(() => fitView({ padding: 0.2 }), [fitView]) - useEffect(() => { const closeMenuOnResizeOrScroll = () => handleCloseMenu() window.addEventListener("resize", closeMenuOnResizeOrScroll) @@ -708,12 +363,12 @@ function BuilderCanvasInner() { } else if ((e.ctrlKey || e.metaKey) && e.key === "a") { e.preventDefault() handleSelectAll() - } else if ((e.key === "Delete" || e.key === "Backspace") && selectedNodeId) { + } else if ((e.key === "Delete" || e.key === "Backspace") && selectedNodeIds.length > 0) { e.preventDefault() - handleNodeDelete() + void handleNodeDelete(selectedNodeIds) } else if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault() - handleSaveVersion() + void handleSaveVersion() } else if ((e.ctrlKey || e.metaKey) && e.key === "=") { e.preventDefault() handleZoomIn() @@ -725,7 +380,7 @@ function BuilderCanvasInner() { handleResetView() } else if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "L") { e.preventDefault() - handleAutoLayout() + void handleAutoLayout() } } window.addEventListener("keydown", handleKeyDown) @@ -743,12 +398,12 @@ function BuilderCanvasInner() { handleZoomOut, handleResetView, handleAutoLayout, - selectedNodeId, + selectedNodeIds, ]) if (isUnauthorized) { return ( -
+

Sign in to access the workflow builder

+ {runError ?

{runError}

: null}
{/* Execution Results */} @@ -195,9 +218,7 @@ export function ExecutionMonitor({
- {execution.completedAt - ? `${Math.round((execution.completedAt.getTime() - execution.startedAt.getTime()) / 1000)}s` - : "In progress..."} + {elapsedMs != null ? `${Math.round(elapsedMs / 1000)}s` : "In progress..."}
{execution.logs.length} logs
diff --git a/src/components/builder/hooks/use-builder-clipboard.ts b/src/components/builder/hooks/use-builder-clipboard.ts new file mode 100644 index 0000000..f5910e4 --- /dev/null +++ b/src/components/builder/hooks/use-builder-clipboard.ts @@ -0,0 +1,141 @@ +"use client" + +import { useCallback, useRef } from "react" +import type { Connection, Workflow, WorkflowNode } from "@/lib/workflow-types" +import type { SafeFetch } from "../builder-constants" + +type ToastFn = (props: { + title: string + description?: string + variant?: "default" | "destructive" +}) => void + +type ClipboardPayload = { + nodes: WorkflowNode[] + connections: Connection[] +} + +type SaveToHistory = (snapshot?: Workflow) => void + +export function useBuilderClipboard(options: { + workflowId: string | null + selectedNodeIds: string[] + workflow: Workflow | null | undefined + saveToHistory: SaveToHistory + mutateWorkflow: (id: string) => void + safeFetch: SafeFetch + toast: ToastFn +}) { + const { workflowId, selectedNodeIds, workflow, saveToHistory, mutateWorkflow, safeFetch, toast } = + options + const clipboardRef = useRef(null) + + const copyNodeIds = useCallback( + async (nodeIds: string[]) => { + if (!nodeIds.length || !workflowId) return false + const response = await safeFetch(`/api/workflows/${workflowId}/copy`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nodeIds }), + }) + if (!response.ok) return false + const result = await response.json() + clipboardRef.current = { nodes: result.nodes ?? [], connections: result.connections ?? [] } + return true + }, + [workflowId, safeFetch], + ) + + const handleCopy = useCallback(async () => { + if (!selectedNodeIds.length) return + const ok = await copyNodeIds(selectedNodeIds) + if (ok) { + toast({ + title: + selectedNodeIds.length === 1 + ? "Node copied to clipboard" + : `${selectedNodeIds.length} nodes copied to clipboard`, + }) + } + }, [selectedNodeIds, copyNodeIds, toast]) + + const handlePaste = useCallback(async () => { + if (!workflowId || !workflow) return + const clipboard = clipboardRef.current + if (!clipboard?.nodes?.length) { + toast({ title: "Nothing to paste", variant: "destructive" }) + return + } + const previous = workflow + const response = await safeFetch(`/api/workflows/${workflowId}/paste`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nodes: clipboard.nodes, connections: clipboard.connections }), + }) + if (response.ok) { + const result = await response.json() + saveToHistory(previous) + mutateWorkflow(workflowId) + toast({ title: `Pasted ${result.nodeIds?.length ?? 0} node(s)` }) + } else { + toast({ title: "Nothing to paste", variant: "destructive" }) + } + }, [workflowId, workflow, saveToHistory, mutateWorkflow, toast, safeFetch]) + + const duplicateNodeIds = useCallback( + async (nodeIds: string[], successTitle: string) => { + if (!nodeIds.length || !workflowId || !workflow) return + const previous = workflow + const copied = await copyNodeIds(nodeIds) + if (!copied || !clipboardRef.current) return + const pasteRes = await safeFetch(`/api/workflows/${workflowId}/paste`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nodes: clipboardRef.current.nodes, + connections: clipboardRef.current.connections, + }), + }) + if (pasteRes.ok) { + saveToHistory(previous) + mutateWorkflow(workflowId) + toast({ title: successTitle }) + } + }, + [workflowId, workflow, copyNodeIds, saveToHistory, mutateWorkflow, toast, safeFetch], + ) + + const handleDuplicate = useCallback(async () => { + await duplicateNodeIds( + selectedNodeIds, + selectedNodeIds.length === 1 + ? "Node duplicated" + : `${selectedNodeIds.length} nodes duplicated`, + ) + }, [selectedNodeIds, duplicateNodeIds]) + + const handleDuplicateById = useCallback( + async (nodeId: string) => { + await duplicateNodeIds([nodeId], "Node duplicated") + }, + [duplicateNodeIds], + ) + + const handleCopyById = useCallback( + async (nodeId: string) => { + const ok = await copyNodeIds([nodeId]) + if (ok) { + toast({ title: "Node copied to clipboard" }) + } + }, + [copyNodeIds, toast], + ) + + return { + handleCopy, + handlePaste, + handleDuplicate, + handleDuplicateById, + handleCopyById, + } +} diff --git a/src/components/builder/hooks/use-builder-execution-highlight.ts b/src/components/builder/hooks/use-builder-execution-highlight.ts new file mode 100644 index 0000000..f72fb9e --- /dev/null +++ b/src/components/builder/hooks/use-builder-execution-highlight.ts @@ -0,0 +1,71 @@ +"use client" + +import { useCallback, useState } from "react" +import { + connectionIdsAlongNodePath, + connectionIdsTouchingNode, +} from "@/lib/builder/workflow-to-reactflow" +import type { Connection } from "@/lib/workflow-types" + +export function useBuilderExecutionHighlight(connections: Connection[] | undefined) { + const [showExecutionMonitor, setShowExecutionMonitor] = useState(false) + const [highlightedNodeId, setHighlightedNodeId] = useState(null) + const [highlightedEdgeIds, setHighlightedEdgeIds] = useState([]) + const [executionPathNodeIds, setExecutionPathNodeIds] = useState([]) + + const clearExecutionHighlights = useCallback(() => { + setHighlightedNodeId(null) + setHighlightedEdgeIds([]) + setExecutionPathNodeIds([]) + }, []) + + const handleNodeHighlight = useCallback( + (nodeId: string | null) => { + setHighlightedNodeId(nodeId) + if (!nodeId) { + setHighlightedEdgeIds([]) + return + } + const pathEdges = connectionIdsAlongNodePath(connections ?? [], executionPathNodeIds) + if (pathEdges.length > 0) { + const touching = new Set(connectionIdsTouchingNode(connections ?? [], nodeId)) + setHighlightedEdgeIds(pathEdges.filter((id) => touching.has(id))) + return + } + setHighlightedEdgeIds(connectionIdsTouchingNode(connections ?? [], nodeId)) + }, + [connections, executionPathNodeIds], + ) + + const handleExecutionPath = useCallback( + (nodeIds: string[]) => { + const path = nodeIds.filter(Boolean) + setExecutionPathNodeIds(path) + setHighlightedEdgeIds(connectionIdsAlongNodePath(connections ?? [], path)) + setHighlightedNodeId(path.length > 0 ? path[path.length - 1] : null) + }, + [connections], + ) + + const handleCloseExecutionMonitor = useCallback(() => { + setShowExecutionMonitor(false) + clearExecutionHighlights() + }, [clearExecutionHighlights]) + + const handleToggleExecutionMonitor = useCallback(() => { + if (showExecutionMonitor) { + clearExecutionHighlights() + } + setShowExecutionMonitor(!showExecutionMonitor) + }, [showExecutionMonitor, clearExecutionHighlights]) + + return { + showExecutionMonitor, + highlightedNodeId, + highlightedEdgeIds, + handleNodeHighlight, + handleExecutionPath, + handleCloseExecutionMonitor, + handleToggleExecutionMonitor, + } +} diff --git a/src/components/builder/hooks/use-builder-graph-mutations.ts b/src/components/builder/hooks/use-builder-graph-mutations.ts new file mode 100644 index 0000000..4eda492 --- /dev/null +++ b/src/components/builder/hooks/use-builder-graph-mutations.ts @@ -0,0 +1,346 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import type { + Connection as ReactFlowConnection, + Edge, + EdgeChange, + Node, + NodeChange, + XYPosition, +} from "@xyflow/react" +import type { NodeType, Position, Workflow } from "@/lib/workflow-types" +import { reactFlowEdgesToConnections } from "@/lib/builder/workflow-to-reactflow" +import type { WorkflowNodeData } from "../canvas-node" +import { GRID_SIZE, type SafeFetch } from "../builder-constants" + +type ToastFn = (props: { + title: string + description?: string + variant?: "default" | "destructive" +}) => void + +type SaveToHistory = (snapshot?: Workflow) => void + +export function useBuilderGraphMutations(options: { + workflowId: string | null + workflow: Workflow | null | undefined + edges: Edge[] + saveToHistory: SaveToHistory + mutateWorkflow: (id: string) => void + safeFetch: SafeFetch + toast: ToastFn + screenToFlowPosition: (position: XYPosition) => XYPosition + onNodesChange: (changes: NodeChange>[]) => void + onEdgesChange: (changes: EdgeChange[]) => void +}) { + const { + workflowId, + workflow, + edges, + saveToHistory, + mutateWorkflow, + safeFetch, + toast, + screenToFlowPosition, + onNodesChange, + onEdgesChange, + } = options + + const layoutTransitionTimeoutRef = useRef | null>(null) + const [isLayoutTransitioning, setIsLayoutTransitioning] = useState(false) + + useEffect(() => { + return () => { + if (layoutTransitionTimeoutRef.current) { + clearTimeout(layoutTransitionTimeoutRef.current) + layoutTransitionTimeoutRef.current = null + } + } + }, []) + + const handleNodeDeleteById = useCallback( + async (nodeId: string) => { + if (!workflowId || !workflow) return + const previous = workflow + const response = await safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { + method: "DELETE", + }) + if (response.ok) { + saveToHistory(previous) + } + mutateWorkflow(workflowId) + }, + [workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch], + ) + + const handleAssignToFrame = useCallback( + async (nodeId: string, frameId: string) => { + if (!workflowId || !workflow) return + const previous = workflow + const response = await safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parentId: frameId }), + }) + if (!response.ok) { + mutateWorkflow(workflowId) + return + } + saveToHistory(previous) + mutateWorkflow(workflowId) + toast({ title: "Node added to frame" }) + }, + [workflowId, workflow, saveToHistory, mutateWorkflow, toast, safeFetch], + ) + + const handleRemoveFromFrame = useCallback( + async (nodeId: string) => { + if (!workflowId || !workflow) return + const previous = workflow + const response = await safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parentId: null }), + }) + if (!response.ok) { + mutateWorkflow(workflowId) + return + } + saveToHistory(previous) + mutateWorkflow(workflowId) + toast({ title: "Node removed from frame" }) + }, + [workflowId, workflow, saveToHistory, mutateWorkflow, toast, safeFetch], + ) + + const handleFrameLabelChange = useCallback( + async (nodeId: string, newLabel: string) => { + if (!workflowId || !workflow) return + const node = workflow.nodes.find((n) => n.id === nodeId) + if (!node) return + const previous = workflow + const response = await safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: { ...node.data, label: newLabel } }), + }) + if (response.ok) { + saveToHistory(previous) + } + mutateWorkflow(workflowId) + }, + [workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch], + ) + + const handleNodesChange = useCallback( + (changes: NodeChange>[]) => { + onNodesChange(changes) + }, + [onNodesChange], + ) + + const handleEdgesChange = useCallback( + (changes: EdgeChange[]) => { + onEdgesChange(changes) + const removeChanges = changes.filter((c) => c.type === "remove") as { id: string }[] + if (removeChanges.length > 0 && workflowId && workflow) { + const previous = workflow + const updatedEdges = edges.filter((e) => !removeChanges.some((r) => r.id === e.id)) + void safeFetch(`/api/workflows/${workflowId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ connections: reactFlowEdgesToConnections(updatedEdges) }), + }).then((response) => { + if (response.ok) { + saveToHistory(previous) + } + mutateWorkflow(workflowId) + }) + } + }, + [onEdgesChange, edges, workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch], + ) + + const handleConnect = useCallback( + async (connection: ReactFlowConnection) => { + if (!workflowId || !workflow || !connection.source || !connection.target) return + const previous = workflow + const response = await safeFetch(`/api/workflows/${workflowId}/connections`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sourceId: connection.source, + targetId: connection.target, + sourceHandle: connection.sourceHandle ?? undefined, + targetHandle: connection.targetHandle ?? undefined, + }), + }) + if (response.ok) { + saveToHistory(previous) + } + mutateWorkflow(workflowId) + }, + [workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch], + ) + + const handleNodeDragStop = useCallback( + async (_: React.MouseEvent, node: Node) => { + if (!workflowId || !workflow) return + const previous = workflow + const snappedPosition = { + x: Math.round(node.position.x / GRID_SIZE) * GRID_SIZE, + y: Math.round(node.position.y / GRID_SIZE) * GRID_SIZE, + } + const response = await safeFetch(`/api/workflows/${workflowId}/nodes/${node.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ position: snappedPosition }), + }) + if (response.ok) { + saveToHistory(previous) + } + mutateWorkflow(workflowId) + }, + [workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch], + ) + + const handleAddNode = useCallback( + async (type: NodeType, position?: Position) => { + if (!workflowId || !workflow) return + const previous = workflow + + let posX: number + let posY: number + + if (position) { + posX = Math.round(position.x / GRID_SIZE) * GRID_SIZE + posY = Math.round(position.y / GRID_SIZE) * GRID_SIZE + } else { + const center = screenToFlowPosition({ + x: typeof window !== "undefined" ? window.innerWidth / 2 : 400, + y: typeof window !== "undefined" ? window.innerHeight / 2 : 300, + }) + posX = Math.round(center.x / GRID_SIZE) * GRID_SIZE + posY = Math.round(center.y / GRID_SIZE) * GRID_SIZE + } + + while ( + workflow.nodes.some( + (n) => Math.abs(n.position.x - posX) < 10 && Math.abs(n.position.y - posY) < 10, + ) + ) { + posX += GRID_SIZE * 2 + posY += GRID_SIZE * 2 + } + + const finalPosition = { x: posX, y: posY } + const labels: Record = { + start: "Start", + end: "End", + agent: "New Agent", + guardrail: "Guardrail", + condition: "Condition", + mcp: "MCP Server", + "user-approval": "User Approval", + "file-search": "File Search", + frame: "New Frame", + } + + const nodePayload: Record = { + type, + position: finalPosition, + data: { label: labels[type] }, + } + if (type === "frame") { + nodePayload.data = { label: labels[type], width: 400, height: 300 } + nodePayload.style = { width: 400, height: 300 } + } + + const response = await safeFetch(`/api/workflows/${workflowId}/nodes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(nodePayload), + }) + if (response.ok) { + saveToHistory(previous) + } + mutateWorkflow(workflowId) + }, + [workflowId, workflow, saveToHistory, mutateWorkflow, screenToFlowPosition, safeFetch], + ) + + const handleAddFrame = useCallback(() => { + handleAddNode("frame") + }, [handleAddNode]) + + const handleAutoLayout = useCallback(async () => { + if (!workflowId || !workflow) return + if (layoutTransitionTimeoutRef.current) { + clearTimeout(layoutTransitionTimeoutRef.current) + layoutTransitionTimeoutRef.current = null + } + const previous = workflow + setIsLayoutTransitioning(true) + const response = await safeFetch(`/api/workflows/${workflowId}/auto-layout`, { method: "POST" }) + if (response.ok) { + saveToHistory(previous) + mutateWorkflow(workflowId) + toast({ title: "Layout applied successfully" }) + layoutTransitionTimeoutRef.current = setTimeout(() => { + layoutTransitionTimeoutRef.current = null + setIsLayoutTransitioning(false) + }, 450) + } else { + setIsLayoutTransitioning(false) + toast({ title: "Failed to apply layout", variant: "destructive" }) + } + }, [workflowId, workflow, saveToHistory, mutateWorkflow, toast, safeFetch]) + + const handleNodeDelete = useCallback( + async (nodeIds: string | string[] | null) => { + const ids = Array.isArray(nodeIds) ? nodeIds : nodeIds ? [nodeIds] : [] + if (!ids.length || !workflowId || !workflow) return + const previous = workflow + const results = await Promise.all( + ids.map((nodeId) => + safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { method: "DELETE" }), + ), + ) + if (results.every((response) => response.ok)) { + saveToHistory(previous) + } + mutateWorkflow(workflowId) + }, + [workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch], + ) + + const handleSaveVersion = useCallback(async () => { + if (!workflowId) return + const response = await safeFetch(`/api/workflows/${workflowId}/versions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ description: "Manual save" }), + }) + if (response.ok) { + toast({ title: "Version saved" }) + } + }, [workflowId, safeFetch, toast]) + + return { + isLayoutTransitioning, + handleNodeDeleteById, + handleAssignToFrame, + handleRemoveFromFrame, + handleFrameLabelChange, + handleNodesChange, + handleEdgesChange, + handleConnect, + handleNodeDragStop, + handleAddNode, + handleAddFrame, + handleAutoLayout, + handleNodeDelete, + handleSaveVersion, + } +} diff --git a/src/components/builder/hooks/use-builder-history.ts b/src/components/builder/hooks/use-builder-history.ts new file mode 100644 index 0000000..7606ba5 --- /dev/null +++ b/src/components/builder/hooks/use-builder-history.ts @@ -0,0 +1,203 @@ +"use client" + +import { useCallback, useRef, useState, useSyncExternalStore } from "react" +import { mutate } from "swr" +import { getHistoryManager } from "@/lib/history-manager" +import type { Workflow, WorkflowVersion } from "@/lib/workflow-types" +import type { SafeFetch } from "../builder-constants" + +type ToastFn = (props: { + title: string + description?: string + variant?: "default" | "destructive" +}) => void + +function subscribeHistory(workflowId: string | null, onStoreChange: () => void): () => void { + if (!workflowId) return () => {} + return getHistoryManager(workflowId).subscribe(onStoreChange) +} + +/** Primitive snapshot so useSyncExternalStore can compare by value. */ +function getHistoryFlagsSnapshot(workflowId: string | null): string { + if (!workflowId) return "0|0|0" + const manager = getHistoryManager(workflowId) + return `${manager.getRevision()}|${manager.canUndo() ? 1 : 0}|${manager.canRedo() ? 1 : 0}` +} + +function workflowUpdatedAtMs(workflow: Workflow): number { + const value = workflow.updatedAt + if (value instanceof Date) return value.getTime() + const parsed = new Date(value as unknown as string).getTime() + return Number.isNaN(parsed) ? 0 : parsed +} + +function rollbackHistoryTransition( + historyManager: ReturnType, + direction: "undo" | "redo", + snapshot: Workflow, +) { + switch (direction) { + case "undo": + historyManager.redo(snapshot) + return + case "redo": + historyManager.undo(snapshot) + return + default: { + const _exhaustive: never = direction + return _exhaustive + } + } +} + +export function useBuilderHistory(options: { + workflowId: string | null + workflow: Workflow | null | undefined + safeFetch: SafeFetch + toast: ToastFn +}) { + const { workflowId, workflow, safeFetch, toast } = options + const transitionInFlightRef = useRef(false) + const [isHistoryTransitioning, setIsHistoryTransitioning] = useState(false) + const lastPersistedRef = useRef(null) + const lastSyncedUpdatedAtRef = useRef(null) + + if (workflow) { + const updatedAtMs = workflowUpdatedAtMs(workflow) + if (lastSyncedUpdatedAtRef.current !== updatedAtMs) { + lastSyncedUpdatedAtRef.current = updatedAtMs + lastPersistedRef.current = workflow + } + } + + const flagsSnapshot = useSyncExternalStore( + (onStoreChange) => subscribeHistory(workflowId, onStoreChange), + () => getHistoryFlagsSnapshot(workflowId), + () => "0|0|0", + ) + const [, canUndoBit, canRedoBit] = flagsSnapshot.split("|") + const canUndo = canUndoBit === "1" + const canRedo = canRedoBit === "1" + + const mutateWorkflow = useCallback((id: string) => { + mutate(`/api/workflows/${id}`) + }, []) + + const saveToHistory = useCallback( + (snapshot?: Workflow) => { + const toSave = snapshot ?? workflow + if (toSave && workflowId) { + getHistoryManager(workflowId).saveState(toSave) + } + }, + [workflow, workflowId], + ) + + const applyHistoryTransition = useCallback( + async (direction: "undo" | "redo") => { + if (!workflowId || !workflow) return + // Serialize: overlapping ⌘Z must not push duplicate redo entries or race PATCHes. + if (transitionInFlightRef.current) return + transitionInFlightRef.current = true + setIsHistoryTransitioning(true) + + const historyManager = getHistoryManager(workflowId) + const current = lastPersistedRef.current ?? workflow + let snapshot: Workflow | null + switch (direction) { + case "undo": + snapshot = historyManager.undo(current) + break + case "redo": + snapshot = historyManager.redo(current) + break + default: { + const _exhaustive: never = direction + transitionInFlightRef.current = false + setIsHistoryTransitioning(false) + return _exhaustive + } + } + if (!snapshot) { + transitionInFlightRef.current = false + setIsHistoryTransitioning(false) + return + } + + try { + const response = await safeFetch(`/api/workflows/${workflowId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nodes: snapshot.nodes, + connections: snapshot.connections, + }), + }) + if (!response.ok) { + rollbackHistoryTransition(historyManager, direction, snapshot) + return + } + lastPersistedRef.current = snapshot + mutateWorkflow(workflowId) + } catch { + rollbackHistoryTransition(historyManager, direction, snapshot) + } finally { + transitionInFlightRef.current = false + setIsHistoryTransitioning(false) + } + }, + [workflowId, workflow, mutateWorkflow, safeFetch], + ) + + const handleUndo = useCallback(() => applyHistoryTransition("undo"), [applyHistoryTransition]) + const handleRedo = useCallback(() => applyHistoryTransition("redo"), [applyHistoryTransition]) + + const handleRestoreVersion = useCallback( + async (version: WorkflowVersion) => { + if (!workflowId || !workflow) return + if (transitionInFlightRef.current) return + transitionInFlightRef.current = true + setIsHistoryTransitioning(true) + const previous = workflow + try { + const response = await safeFetch(`/api/workflows/${workflowId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nodes: version.nodes, + connections: version.connections, + }), + }) + if (!response.ok) return + getHistoryManager(workflowId).saveState(previous) + lastPersistedRef.current = { + ...previous, + nodes: version.nodes, + connections: version.connections, + } + mutateWorkflow(workflowId) + toast({ + title: "Version restored", + description: `Restored ${version.name}`, + }) + } catch { + // Leave stacks untouched when network fails before a successful persist. + } finally { + transitionInFlightRef.current = false + setIsHistoryTransitioning(false) + } + }, + [workflowId, workflow, mutateWorkflow, safeFetch, toast], + ) + + return { + canUndo, + canRedo, + isHistoryTransitioning, + saveToHistory, + mutateWorkflow, + handleUndo, + handleRedo, + handleRestoreVersion, + } +} diff --git a/src/lib/api/version-param.ts b/src/lib/api/version-param.ts new file mode 100644 index 0000000..5674702 --- /dev/null +++ b/src/lib/api/version-param.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +/** + * Strict positive integer version path/query param (rejects "1junk", "1.5"). + * + * @example + * versionParamSchema.parse("3") // 3 + */ +export const versionParamSchema = z + .string() + .regex(/^\d+$/, "version must be a positive integer string") + .transform(Number) + .pipe(z.number().int().positive()) + +export function parseVersionParam(raw: string): { ok: true; value: number } | { ok: false } { + const parsed = versionParamSchema.safeParse(raw) + if (!parsed.success) return { ok: false } + return { ok: true, value: parsed.data } +} diff --git a/src/lib/builder/workflow-to-reactflow.ts b/src/lib/builder/workflow-to-reactflow.ts index dfef174..77602a4 100644 --- a/src/lib/builder/workflow-to-reactflow.ts +++ b/src/lib/builder/workflow-to-reactflow.ts @@ -47,6 +47,37 @@ export interface WorkflowConnectionsToEdgesOptions { runningEdgeIds?: string[] } +/** + * Connection ids that touch a node (as source or target). Used for run-edge highlight. + * + * @example + * connectionIdsTouchingNode(connections, "n2") // ["e1", "e2"] + */ +export function connectionIdsTouchingNode(connections: Connection[], nodeId: string): string[] { + return (connections || []) + .filter((c) => c.sourceId === nodeId || c.targetId === nodeId) + .map((c) => c.id) +} + +/** + * Connection ids along an ordered node visit path (consecutive pairs only). + * + * @example + * connectionIdsAlongNodePath(connections, ["n1", "n2", "n3"]) // ["e1", "e2"] + */ +export function connectionIdsAlongNodePath(connections: Connection[], nodeIds: string[]): string[] { + if (nodeIds.length < 2) return [] + const ids: string[] = [] + for (let i = 0; i < nodeIds.length - 1; i++) { + const sourceId = nodeIds[i] + const targetId = nodeIds[i + 1] + const match = (connections || []).find( + (c) => c.sourceId === sourceId && c.targetId === targetId, + ) + if (match) ids.push(match.id) + } + return ids +} export function workflowConnectionsToEdges( connections: Connection[], options: WorkflowConnectionsToEdgesOptions = {}, diff --git a/src/lib/db/workflows.ts b/src/lib/db/workflows.ts index 5271a9e..3db67ee 100644 --- a/src/lib/db/workflows.ts +++ b/src/lib/db/workflows.ts @@ -93,22 +93,24 @@ export async function deleteWorkflow(id: string, workspaceId?: string): Promise< export async function addWorkflowNode( workflowId: string, node: Omit, + workspaceId?: string, ): Promise { - const workflow = await getWorkflow(workflowId) + const workflow = await getWorkflow(workflowId, workspaceId) if (!workflow) throw new Error("Workflow not found") const newNode: WorkflowNode = { ...node, id: crypto.randomUUID() } const updatedNodes = [...workflow.nodes, newNode] - return await updateWorkflow(workflowId, { nodes: updatedNodes }) + return await updateWorkflow(workflowId, { nodes: updatedNodes }, workspaceId) } export async function updateWorkflowNode( workflowId: string, nodeId: string, updates: Partial, + workspaceId?: string, ): Promise { - const workflow = await getWorkflow(workflowId) + const workflow = await getWorkflow(workflowId, workspaceId) if (!workflow) throw new Error("Workflow not found") const updatedNodes = workflow.nodes.map((node) => { @@ -118,11 +120,15 @@ export async function updateWorkflowNode( return merged }) - return await updateWorkflow(workflowId, { nodes: updatedNodes }) + return await updateWorkflow(workflowId, { nodes: updatedNodes }, workspaceId) } -export async function deleteWorkflowNode(workflowId: string, nodeId: string): Promise { - const workflow = await getWorkflow(workflowId) +export async function deleteWorkflowNode( + workflowId: string, + nodeId: string, + workspaceId?: string, +): Promise { + const workflow = await getWorkflow(workflowId, workspaceId) if (!workflow) throw new Error("Workflow not found") const updatedNodes = workflow.nodes.filter((node) => node.id !== nodeId) @@ -130,14 +136,19 @@ export async function deleteWorkflowNode(workflowId: string, nodeId: string): Pr (conn) => conn.sourceId !== nodeId && conn.targetId !== nodeId, ) - return await updateWorkflow(workflowId, { nodes: updatedNodes, connections: updatedConnections }) + return await updateWorkflow( + workflowId, + { nodes: updatedNodes, connections: updatedConnections }, + workspaceId, + ) } export async function addWorkflowConnection( workflowId: string, connection: Omit, + workspaceId?: string, ): Promise { - const workflow = await getWorkflow(workflowId) + const workflow = await getWorkflow(workflowId, workspaceId) if (!workflow) throw new Error("Workflow not found") const exists = workflow.connections.some( @@ -148,17 +159,18 @@ export async function addWorkflowConnection( const newConnection: Connection = { ...connection, id: crypto.randomUUID() } const updatedConnections = [...workflow.connections, newConnection] - return await updateWorkflow(workflowId, { connections: updatedConnections }) + return await updateWorkflow(workflowId, { connections: updatedConnections }, workspaceId) } export async function deleteWorkflowConnection( workflowId: string, connectionId: string, + workspaceId?: string, ): Promise { - const workflow = await getWorkflow(workflowId) + const workflow = await getWorkflow(workflowId, workspaceId) if (!workflow) throw new Error("Workflow not found") const updatedConnections = workflow.connections.filter((conn) => conn.id !== connectionId) - return await updateWorkflow(workflowId, { connections: updatedConnections }) + return await updateWorkflow(workflowId, { connections: updatedConnections }, workspaceId) } diff --git a/src/lib/execution-duration.ts b/src/lib/execution-duration.ts new file mode 100644 index 0000000..a9dd77c --- /dev/null +++ b/src/lib/execution-duration.ts @@ -0,0 +1,51 @@ +/** + * Coerce unknown date-like values to a valid Date, or null if invalid. + * Useful after JSON deserialization where Date fields arrive as ISO strings. + * + * @example + * toValidDate("2026-01-01T00:00:00.000Z")?.getTime() + */ +export function toValidDate(value: unknown): Date | null { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value + } + if (typeof value === "string") { + if (!hasValidCalendarDate(value)) return null + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed + } + if (typeof value === "number") { + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed + } + return null +} + +/** Reject JS Date rollover of impossible calendars like 2026-02-30. */ +function hasValidCalendarDate(value: string): boolean { + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/) + if (!match) return true + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const utc = new Date(Date.UTC(year, month - 1, day)) + return ( + utc.getUTCFullYear() === year && utc.getUTCMonth() + 1 === month && utc.getUTCDate() === day + ) +} + +/** + * Duration in milliseconds between two date-like values, or null if either is invalid + * or the span is negative. + * + * @example + * durationMs("2026-01-01T00:00:00.000Z", "2026-01-01T00:00:05.000Z") // 5000 + */ +export function durationMs(startedAt: unknown, completedAt: unknown): number | null { + const start = toValidDate(startedAt) + const end = toValidDate(completedAt) + if (!start || !end) return null + const ms = end.getTime() - start.getTime() + if (ms < 0) return null + return ms +} diff --git a/src/lib/history-manager.ts b/src/lib/history-manager.ts index f42f7b1..47f113c 100644 --- a/src/lib/history-manager.ts +++ b/src/lib/history-manager.ts @@ -9,6 +9,26 @@ export class HistoryManager { private undoStack: HistoryState[] = [] private redoStack: HistoryState[] = [] private maxHistorySize = 50 + private revision = 0 + private readonly listeners = new Set<() => void>() + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + getRevision(): number { + return this.revision + } + + private notify() { + this.revision += 1 + for (const listener of this.listeners) { + listener() + } + } saveState(workflow: Workflow) { this.undoStack.push({ @@ -21,6 +41,7 @@ export class HistoryManager { } this.redoStack = [] + this.notify() } canUndo(): boolean { @@ -40,6 +61,7 @@ export class HistoryManager { }) const previousState = this.undoStack.pop()! + this.notify() return previousState.workflow } @@ -52,12 +74,14 @@ export class HistoryManager { }) const nextState = this.redoStack.pop()! + this.notify() return nextState.workflow } clear() { this.undoStack = [] this.redoStack = [] + this.notify() } } diff --git a/src/lib/version-store.ts b/src/lib/version-store.ts index cbb4b79..fbe46f2 100644 --- a/src/lib/version-store.ts +++ b/src/lib/version-store.ts @@ -1,91 +1,331 @@ +import { getSupabaseServerClient } from "@/lib/supabase/server" import type { Workflow, WorkflowVersion, VersionComparison } from "./workflow-types" +/** Row shape for public.workflow_versions (see scripts/001_create_schema.sql). */ +interface WorkflowVersionRow { + id: string + workflow_id: string + version: number + nodes: unknown + connections: unknown + tag: string | null + description: string | null + created_at: string +} + +/** Result of tag/delete when the caller must map HTTP status. */ +export type VersionWriteResult = "ok" | "not_found" | "forbidden" + +const UNIQUE_VIOLATION = "23505" +const NO_ROWS = "PGRST116" +const CREATE_VERSION_MAX_ATTEMPTS = 3 + +function serializeTags(tags?: string[]): string | null { + if (!tags || tags.length === 0) return null + return JSON.stringify(tags) +} + +function parseTags(tag: string | null): string[] { + if (!tag) return [] + try { + const parsed: unknown = JSON.parse(tag) + if (Array.isArray(parsed)) { + return parsed.filter((entry): entry is string => typeof entry === "string") + } + } catch { + // Plain string tag from older rows + } + return [tag] +} + +function mapVersionRow(row: WorkflowVersionRow): WorkflowVersion { + return { + id: row.id, + workflowId: row.workflow_id, + version: row.version, + name: `v${row.version}`, + description: row.description ?? undefined, + nodes: Array.isArray(row.nodes) ? row.nodes : [], + connections: Array.isArray(row.connections) ? row.connections : [], + createdAt: new Date(row.created_at), + tags: parseTags(row.tag), + } +} + +function cloneGraph(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} + +function nextVersionNumber(existing: WorkflowVersion[], workflowVersion: number): number { + if (existing.length === 0) return Math.max(1, workflowVersion) + return Math.max(...existing.map((v) => v.version)) + 1 +} + +function buildVersion( + workflow: Workflow, + versionNumber: number, + description?: string, +): WorkflowVersion { + return { + id: crypto.randomUUID(), + workflowId: workflow.id, + version: versionNumber, + name: `v${versionNumber}`, + description, + nodes: cloneGraph(workflow.nodes), + connections: cloneGraph(workflow.connections), + createdAt: new Date(), + tags: [], + } +} + +function compareSnapshots(v1: WorkflowVersion, v2: WorkflowVersion): VersionComparison { + const added = { + nodes: v2.nodes.filter((n2) => !v1.nodes.find((n1) => n1.id === n2.id)), + connections: v2.connections.filter((c2) => !v1.connections.find((c1) => c1.id === c2.id)), + } + + const removed = { + nodes: v1.nodes.filter((n1) => !v2.nodes.find((n2) => n2.id === n1.id)), + connections: v1.connections.filter((c1) => !v2.connections.find((c2) => c2.id === c1.id)), + } + + const modified = v2.nodes + .map((n2) => { + const n1 = v1.nodes.find((n) => n.id === n2.id) + if (n1 && JSON.stringify(n1) !== JSON.stringify(n2)) { + return { old: n1, new: n2 } + } + return null + }) + .filter((m): m is { old: (typeof v1.nodes)[0]; new: (typeof v2.nodes)[0] } => m !== null) + + return { added, removed, modified: { nodes: modified } } +} + +function requireSupabaseOrMemoryFallback() { + // Production must not silently degrade to a process-local Map (serverless amnesia). + if (process.env.NODE_ENV === "production") { + throw new Error( + "Database connection is required for workflow versions in production (Supabase client is null).", + ) + } +} + +function isNoRowError(error: { code?: string } | null): boolean { + return error?.code === NO_ROWS +} + +/** + * Prefers Supabase `workflow_versions` when a server client is available. + * Fallback: process-local Map — tests / local-dev only (throws in production). + */ class VersionStore { - private versions: Map = new Map() - - createVersion(workflow: Workflow, description?: string): WorkflowVersion { - const version: WorkflowVersion = { - id: crypto.randomUUID(), - workflowId: workflow.id, - version: workflow.version, - name: `v${workflow.version}`, - description, - nodes: JSON.parse(JSON.stringify(workflow.nodes)), - connections: JSON.parse(JSON.stringify(workflow.connections)), - createdAt: new Date(), - tags: [], + private memoryVersions: Map = new Map() + + async createVersion(workflow: Workflow, description?: string): Promise { + const supabase = await getSupabaseServerClient() + if (!supabase) { + requireSupabaseOrMemoryFallback() + return this.createVersionInMemory(workflow, description) } - const existingVersions = this.versions.get(workflow.id) || [] - existingVersions.push(version) - this.versions.set(workflow.id, existingVersions) + let lastErrorMessage = "unknown error" + for (let attempt = 0; attempt < CREATE_VERSION_MAX_ATTEMPTS; attempt++) { + const existing = await this.getVersionsFromDb(workflow.id) + const versionNumber = nextVersionNumber(existing, workflow.version) + const id = crypto.randomUUID() - return version + const { data, error } = await supabase + .from("workflow_versions") + .insert({ + id, + workflow_id: workflow.id, + version: versionNumber, + nodes: cloneGraph(workflow.nodes), + connections: cloneGraph(workflow.connections), + tag: null, + description: description ?? null, + }) + .select() + .single() + + if (!error && data) { + return mapVersionRow(data as WorkflowVersionRow) + } + + lastErrorMessage = error?.message ?? "no row returned" + if (error?.code === UNIQUE_VIOLATION) { + continue + } + + throw new Error( + `Failed to create workflow version for workflowId=${workflow.id}: ${lastErrorMessage}`, + ) + } + + throw new Error( + `Failed to create workflow version for workflowId=${workflow.id} after ${CREATE_VERSION_MAX_ATTEMPTS} attempts: ${lastErrorMessage}`, + ) } - getVersions(workflowId: string): WorkflowVersion[] { - return (this.versions.get(workflowId) || []).sort((a, b) => b.version - a.version) + async getVersions(workflowId: string): Promise { + const supabase = await getSupabaseServerClient() + if (!supabase) { + requireSupabaseOrMemoryFallback() + return this.getVersionsFromMemory(workflowId) + } + return this.getVersionsFromDb(workflowId) } - getVersion(workflowId: string, versionNumber: number): WorkflowVersion | undefined { - const versions = this.versions.get(workflowId) || [] - return versions.find((v) => v.version === versionNumber) + async getVersion( + workflowId: string, + versionNumber: number, + ): Promise { + const supabase = await getSupabaseServerClient() + if (!supabase) { + requireSupabaseOrMemoryFallback() + return this.getVersionsFromMemory(workflowId).find((v) => v.version === versionNumber) + } + + const { data, error } = await supabase + .from("workflow_versions") + .select("*") + .eq("workflow_id", workflowId) + .eq("version", versionNumber) + .single() + + if (error) { + if (isNoRowError(error)) return undefined + throw new Error( + `Failed to load workflow version workflowId=${workflowId} version=${versionNumber}: ${error.message}`, + ) + } + if (!data) return undefined + return mapVersionRow(data as WorkflowVersionRow) } - compareVersions( + async compareVersions( workflowId: string, version1: number, version2: number, - ): VersionComparison | null { - const v1 = this.getVersion(workflowId, version1) - const v2 = this.getVersion(workflowId, version2) - + ): Promise { + const v1 = await this.getVersion(workflowId, version1) + const v2 = await this.getVersion(workflowId, version2) if (!v1 || !v2) return null + return compareSnapshots(v1, v2) + } - const added = { - nodes: v2.nodes.filter((n2) => !v1.nodes.find((n1) => n1.id === n2.id)), - connections: v2.connections.filter((c2) => !v1.connections.find((c1) => c1.id === c2.id)), + async tagVersion( + workflowId: string, + versionNumber: number, + tag: string, + ): Promise { + const supabase = await getSupabaseServerClient() + if (!supabase) { + requireSupabaseOrMemoryFallback() + return this.tagVersionInMemory(workflowId, versionNumber, tag) ? "ok" : "not_found" } - const removed = { - nodes: v1.nodes.filter((n1) => !v2.nodes.find((n2) => n2.id === n1.id)), - connections: v1.connections.filter((c1) => !v2.connections.find((c2) => c2.id === c1.id)), + const existing = await this.getVersion(workflowId, versionNumber) + if (!existing) return "not_found" + + const tags = existing.tags ?? [] + if (!tags.includes(tag)) tags.push(tag) + + const { data, error } = await supabase + .from("workflow_versions") + .update({ tag: serializeTags(tags) }) + .eq("workflow_id", workflowId) + .eq("version", versionNumber) + .select("id") + .maybeSingle() + + if (error) { + throw new Error( + `Failed to tag workflow version workflowId=${workflowId} version=${versionNumber}: ${error.message}`, + ) + } + // Row visible via SELECT but UPDATE affected 0 rows → typically RLS denial. + if (!data) return "forbidden" + return "ok" + } + + async deleteVersion(workflowId: string, versionNumber: number): Promise { + const supabase = await getSupabaseServerClient() + if (!supabase) { + requireSupabaseOrMemoryFallback() + return this.deleteVersionInMemory(workflowId, versionNumber) ? "ok" : "not_found" } - const modified = v2.nodes - .map((n2) => { - const n1 = v1.nodes.find((n) => n.id === n2.id) - if (n1 && JSON.stringify(n1) !== JSON.stringify(n2)) { - return { old: n1, new: n2 } - } - return null - }) - .filter((m) => m !== null) as Array<{ old: (typeof v1.nodes)[0]; new: (typeof v2.nodes)[0] }> + const existing = await this.getVersion(workflowId, versionNumber) + if (!existing) return "not_found" - return { added, removed, modified: { nodes: modified } } + const { data, error } = await supabase + .from("workflow_versions") + .delete() + .eq("workflow_id", workflowId) + .eq("version", versionNumber) + .select("id") + .maybeSingle() + + if (error) { + throw new Error( + `Failed to delete workflow version workflowId=${workflowId} version=${versionNumber}: ${error.message}`, + ) + } + if (!data) return "forbidden" + return "ok" } - tagVersion(workflowId: string, versionNumber: number, tag: string): boolean { - const versions = this.versions.get(workflowId) || [] - const version = versions.find((v) => v.version === versionNumber) + private async getVersionsFromDb(workflowId: string): Promise { + const supabase = await getSupabaseServerClient() + if (!supabase) return [] - if (!version) return false + const { data, error } = await supabase + .from("workflow_versions") + .select("*") + .eq("workflow_id", workflowId) + .order("version", { ascending: false }) - if (!version.tags) version.tags = [] - if (!version.tags.includes(tag)) { - version.tags.push(tag) + if (error) { + throw new Error( + `Failed to list workflow versions for workflowId=${workflowId}: ${error.message}`, + ) } + return ((data ?? []) as WorkflowVersionRow[]).map(mapVersionRow) + } + + private createVersionInMemory(workflow: Workflow, description?: string): WorkflowVersion { + const existing = this.getVersionsFromMemory(workflow.id) + const version = buildVersion( + workflow, + nextVersionNumber(existing, workflow.version), + description, + ) + this.memoryVersions.set(workflow.id, [...existing, version]) + return version + } + + private getVersionsFromMemory(workflowId: string): WorkflowVersion[] { + return [...(this.memoryVersions.get(workflowId) || [])].sort((a, b) => b.version - a.version) + } + + private tagVersionInMemory(workflowId: string, versionNumber: number, tag: string): boolean { + const versions = this.memoryVersions.get(workflowId) || [] + const version = versions.find((v) => v.version === versionNumber) + if (!version) return false + if (!version.tags) version.tags = [] + if (!version.tags.includes(tag)) version.tags.push(tag) return true } - deleteVersion(workflowId: string, versionNumber: number): boolean { - const versions = this.versions.get(workflowId) || [] + private deleteVersionInMemory(workflowId: string, versionNumber: number): boolean { + const versions = this.memoryVersions.get(workflowId) || [] const filtered = versions.filter((v) => v.version !== versionNumber) - if (filtered.length === versions.length) return false - - this.versions.set(workflowId, filtered) + this.memoryVersions.set(workflowId, filtered) return true } } diff --git a/src/test/setup.ts b/src/test/setup.ts index 894c48e..fb599cb 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -23,6 +23,14 @@ if (typeof globalThis.IntersectionObserver === "undefined") { } as unknown as typeof IntersectionObserver } +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class MockResizeObserver { + observe = () => {} + unobserve = () => {} + disconnect = () => {} + } as unknown as typeof ResizeObserver +} + // Radix UI (Tabs, etc.) requires PointerEvent APIs that jsdom lacks if (typeof Element !== "undefined") { if (!Element.prototype.hasPointerCapture) { diff --git a/tests/components/builder-canvas.test.tsx b/tests/components/builder-canvas.test.tsx index 7721f4a..76248f4 100644 --- a/tests/components/builder-canvas.test.tsx +++ b/tests/components/builder-canvas.test.tsx @@ -1,16 +1,24 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from "vitest" import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" import { BuilderCanvas } from "@/components/builder/builder-canvas" -import useSWR from "swr" -import type { Workflow } from "@/lib/workflow-types" +import { getHistoryManager } from "@/lib/history-manager" +import useSWR, { mutate } from "swr" +import type { Workflow, WorkflowNode } from "@/lib/workflow-types" -const { mockWorkflow, mockWorkflows, baseSWR } = vi.hoisted(() => { +const { mockWorkflow, mockWorkflows, baseSWR, mockSetNodes, mockSetEdges } = vi.hoisted(() => { + const node: WorkflowNode = { + id: "n1", + type: "agent", + position: { x: 40, y: 40 }, + data: { label: "Agent" }, + } const wf: Workflow = { id: "wf-1", name: "Test Workflow", description: "", - nodes: [], + nodes: [node], connections: [], version: 1, createdAt: new Date(), @@ -23,7 +31,13 @@ const { mockWorkflow, mockWorkflows, baseSWR } = vi.hoisted(() => { isValidating: false, mutate: vi.fn(), }) - return { mockWorkflow: wf, mockWorkflows: [wf], baseSWR: base } + return { + mockWorkflow: wf, + mockWorkflows: [wf], + baseSWR: base, + mockSetNodes: vi.fn(), + mockSetEdges: vi.fn(), + } }) vi.mock("swr", () => ({ @@ -31,12 +45,9 @@ vi.mock("swr", () => ({ if (key === "/api/workflows") { return { ...baseSWR(), data: mockWorkflows } } - if (key?.startsWith("/api/workflows/") && !key.includes("/history/")) { + if (key?.startsWith("/api/workflows/")) { return { ...baseSWR(), data: mockWorkflow, isLoading: true } } - if (key?.includes("/history/status")) { - return { ...baseSWR(), data: { canUndo: false, canRedo: false } } - } return baseSWR() }), mutate: vi.fn(), @@ -53,7 +64,6 @@ vi.mock("next/link", () => ({ })) const mockScreenToFlowPosition = vi.fn(({ x, y }: { x: number; y: number }) => ({ x, y })) -const mockGetViewport = vi.fn(() => ({ x: 0, y: 0, zoom: 1 })) vi.mock("@xyflow/react", async () => { const actual = await vi.importActual("@xyflow/react") @@ -62,13 +72,13 @@ vi.mock("@xyflow/react", async () => { ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}, useReactFlow: () => ({ screenToFlowPosition: mockScreenToFlowPosition, - getViewport: mockGetViewport, zoomIn: vi.fn(), zoomOut: vi.fn(), fitView: vi.fn(), }), - useNodesState: (initial: unknown[]) => [initial, vi.fn(), vi.fn()], - useEdgesState: (initial: unknown[]) => [initial, vi.fn(), vi.fn()], + useViewport: () => ({ x: 0, y: 0, zoom: 1 }), + useNodesState: (initial: unknown[]) => [initial, mockSetNodes, vi.fn()], + useEdgesState: (initial: unknown[]) => [initial, mockSetEdges, vi.fn()], ReactFlow: () =>
, Background: () => null, } @@ -78,21 +88,98 @@ const defaultSWRImpl = (key: string | null, workflowData: Workflow | null) => { if (key === "/api/workflows") { return { ...baseSWR(), data: mockWorkflows } } - if (key?.startsWith("/api/workflows/") && !key.includes("/history/")) { + if (key?.startsWith("/api/workflows/")) { return { ...baseSWR(), data: workflowData, isLoading: true } } - if (key?.includes("/history/status")) { - return { ...baseSWR(), data: { canUndo: false, canRedo: false } } - } return baseSWR() } describe("BuilderCanvas", () => { beforeEach(() => { vi.clearAllMocks() + getHistoryManager("wf-1").clear() vi.mocked(useSWR).mockImplementation((key) => defaultSWRImpl(key as string | null, mockWorkflow), ) + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockWorkflow, + clone() { + return this + }, + text: async () => "", + }) as unknown as typeof fetch + }) + + it("select-all marks every node and edge as selected", async () => { + const user = userEvent.setup() + const multiNodeWorkflow: Workflow = { + ...mockWorkflow, + nodes: [ + { + id: "n1", + type: "agent", + position: { x: 0, y: 0 }, + data: { label: "A" }, + }, + { + id: "n2", + type: "agent", + position: { x: 120, y: 0 }, + data: { label: "B" }, + }, + { + id: "n3", + type: "end", + position: { x: 240, y: 0 }, + data: { label: "End" }, + }, + ], + connections: [ + { id: "e1", sourceId: "n1", targetId: "n2" }, + { id: "e2", sourceId: "n2", targetId: "n3" }, + ], + } + vi.mocked(useSWR).mockImplementation((key) => + defaultSWRImpl(key as string | null, multiNodeWorkflow), + ) + + render() + + await waitFor(() => { + expect(screen.getByTestId("builder-toolbar")).toBeInTheDocument() + }) + + mockSetNodes.mockClear() + mockSetEdges.mockClear() + + await user.keyboard("{Meta>}a{/Meta}") + + await waitFor(() => { + expect(mockSetNodes).toHaveBeenCalled() + }) + + const nodesUpdater = mockSetNodes.mock.calls.at(-1)?.[0] as ( + nodes: Array<{ id: string; selected?: boolean }>, + ) => Array<{ id: string; selected?: boolean }> + const selectedNodes = nodesUpdater([ + { id: "n1", selected: false }, + { id: "n2", selected: false }, + { id: "n3", selected: false }, + ]) + expect(selectedNodes.every((n) => n.selected === true)).toBe(true) + expect(selectedNodes).toHaveLength(3) + + expect(mockSetEdges).toHaveBeenCalled() + const edgesUpdater = mockSetEdges.mock.calls.at(-1)?.[0] as ( + edges: Array<{ id: string; selected?: boolean }>, + ) => Array<{ id: string; selected?: boolean }> + const selectedEdges = edgesUpdater([ + { id: "e1", selected: false }, + { id: "e2", selected: false }, + ]) + expect(selectedEdges.every((e) => e.selected === true)).toBe(true) }) it("does NOT show loading screen when workflow data exists but isLoading is true (revalidation)", async () => { @@ -115,4 +202,338 @@ describe("BuilderCanvas", () => { expect(screen.getByText("Loading workflow...")).toBeInTheDocument() }) }) + + it("does not subscribe to server history/status for undo state", async () => { + render() + + await waitFor(() => { + expect(screen.getByTestId("builder-toolbar")).toBeInTheDocument() + }) + + const keys = vi.mocked(useSWR).mock.calls.map(([key]) => key) + expect(keys.some((key) => typeof key === "string" && key.includes("/history/status"))).toBe( + false, + ) + }) + + it("disables Undo and Redo when the client history stacks are empty", async () => { + render() + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Undo" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Redo" })).toBeDisabled() + }) + }) + + it("enables Undo from the client HistoryManager stack (not server Map)", async () => { + const emptyGraph = { + ...mockWorkflow, + nodes: [] as WorkflowNode[], + connections: [], + } + getHistoryManager("wf-1").saveState(emptyGraph) + + render() + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Undo" })).toBeEnabled() + expect(screen.getByRole("button", { name: "Redo" })).toBeDisabled() + }) + }) + + it("undo restores previous graph via PATCH and does not call server /undo", async () => { + const user = userEvent.setup() + const emptyGraph = { + ...mockWorkflow, + nodes: [] as WorkflowNode[], + connections: [], + } + getHistoryManager("wf-1").saveState(emptyGraph) + + render() + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Undo" })).toBeEnabled() + }) + + await user.click(screen.getByRole("button", { name: "Undo" })) + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/workflows/wf-1", + expect.objectContaining({ + method: "PATCH", + body: expect.stringContaining('"nodes":[]'), + }), + ) + }) + + const fetchCalls = vi.mocked(global.fetch).mock.calls.map(([url]) => String(url)) + expect(fetchCalls.some((url) => url.includes("/undo"))).toBe(false) + expect(fetchCalls.some((url) => url.includes("/redo"))).toBe(false) + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Undo" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Redo" })).toBeEnabled() + }) + }) + + it("redo restores again via PATCH and does not call server /redo", async () => { + const user = userEvent.setup() + const emptyGraph = { + ...mockWorkflow, + nodes: [] as WorkflowNode[], + connections: [], + } + const manager = getHistoryManager("wf-1") + manager.saveState(emptyGraph) + manager.undo(mockWorkflow) + + render() + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Redo" })).toBeEnabled() + }) + + await user.click(screen.getByRole("button", { name: "Redo" })) + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/workflows/wf-1", + expect.objectContaining({ + method: "PATCH", + body: expect.stringContaining('"n1"'), + }), + ) + }) + + const fetchCalls = vi.mocked(global.fetch).mock.calls.map(([url]) => String(url)) + expect(fetchCalls.some((url) => url.includes("/redo"))).toBe(false) + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Undo" })).toBeEnabled() + expect(screen.getByRole("button", { name: "Redo" })).toBeDisabled() + }) + }) + + it("rolls back undo stacks and skips SWR mutate when PATCH fails", async () => { + const user = userEvent.setup() + const emptyGraph = { + ...mockWorkflow, + nodes: [] as WorkflowNode[], + connections: [], + } + getHistoryManager("wf-1").saveState(emptyGraph) + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: "persist failed" }), + clone() { + return this + }, + text: async () => "", + }) as unknown as typeof fetch + + render() + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Undo" })).toBeEnabled() + expect(screen.getByRole("button", { name: "Redo" })).toBeDisabled() + }) + + vi.mocked(mutate).mockClear() + + await user.click(screen.getByRole("button", { name: "Undo" })) + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/workflows/wf-1", + expect.objectContaining({ method: "PATCH" }), + ) + }) + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Undo" })).toBeEnabled() + expect(screen.getByRole("button", { name: "Redo" })).toBeDisabled() + }) + + expect(mutate).not.toHaveBeenCalledWith("/api/workflows/wf-1") + expect(getHistoryManager("wf-1").canUndo()).toBe(true) + expect(getHistoryManager("wf-1").canRedo()).toBe(false) + }) + + it("rolls back redo stacks and skips SWR mutate when PATCH fails", async () => { + const user = userEvent.setup() + const emptyGraph = { + ...mockWorkflow, + nodes: [] as WorkflowNode[], + connections: [], + } + const manager = getHistoryManager("wf-1") + manager.saveState(emptyGraph) + manager.undo(mockWorkflow) + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: "persist failed" }), + clone() { + return this + }, + text: async () => "", + }) as unknown as typeof fetch + + render() + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Redo" })).toBeEnabled() + expect(screen.getByRole("button", { name: "Undo" })).toBeDisabled() + }) + + vi.mocked(mutate).mockClear() + + await user.click(screen.getByRole("button", { name: "Redo" })) + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/workflows/wf-1", + expect.objectContaining({ method: "PATCH" }), + ) + }) + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Redo" })).toBeEnabled() + expect(screen.getByRole("button", { name: "Undo" })).toBeDisabled() + }) + + expect(mutate).not.toHaveBeenCalledWith("/api/workflows/wf-1") + expect(getHistoryManager("wf-1").canRedo()).toBe(true) + expect(getHistoryManager("wf-1").canUndo()).toBe(false) + }) + + it("ignores overlapping undo while a PATCH is in flight", async () => { + const user = userEvent.setup() + const emptyGraph = { + ...mockWorkflow, + nodes: [] as WorkflowNode[], + connections: [], + } + getHistoryManager("wf-1").saveState(emptyGraph) + + let resolvePatch: ((value: unknown) => void) | undefined + const deferredPatch = new Promise((resolve) => { + resolvePatch = resolve + }) + + global.fetch = vi.fn().mockReturnValue(deferredPatch) as unknown as typeof fetch + + render() + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Undo" })).toBeEnabled() + }) + + await user.click(screen.getByRole("button", { name: "Undo" })) + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Undo" })).toBeDisabled() + }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + + resolvePatch?.({ + ok: true, + status: 200, + json: async () => ({}), + clone() { + return this + }, + text: async () => "", + }) + + await waitFor(() => { + expect(mutate).toHaveBeenCalledWith("/api/workflows/wf-1") + }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(getHistoryManager("wf-1").canUndo()).toBe(false) + expect(getHistoryManager("wf-1").canRedo()).toBe(true) + }) + + it("restores a version by PATCHing that version's nodes/connections", async () => { + const user = userEvent.setup() + const versionNodes: WorkflowNode[] = [ + { + id: "saved-node", + type: "agent", + position: { x: 10, y: 20 }, + data: { label: "Saved" }, + }, + ] + const versionPayload = { + id: "ver-1", + workflowId: "wf-1", + version: 1, + name: "v1", + description: "checkpoint", + nodes: versionNodes, + connections: [], + createdAt: new Date("2026-01-01T00:00:00.000Z"), + tags: [], + } + + vi.mocked(useSWR).mockImplementation((key) => { + if (key === "/api/workflows") { + return { ...baseSWR(), data: mockWorkflows } + } + if (key === "/api/workflows/wf-1/versions") { + return { ...baseSWR(), data: [versionPayload] } + } + if (typeof key === "string" && key.startsWith("/api/workflows/")) { + return { ...baseSWR(), data: mockWorkflow, isLoading: false } + } + return baseSWR() + }) + + render() + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Version history" })).toBeInTheDocument() + }) + + await user.click(screen.getByRole("button", { name: "Version history" })) + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Restore version v1" })).toBeInTheDocument() + }) + + await user.click(screen.getByRole("button", { name: "Restore version v1" })) + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/workflows/wf-1", + expect.objectContaining({ + method: "PATCH", + body: expect.stringContaining('"saved-node"'), + }), + ) + }) + + const patchCall = vi + .mocked(global.fetch) + .mock.calls.find( + ([url, init]) => + String(url) === "/api/workflows/wf-1" && + typeof init === "object" && + init !== null && + "method" in init && + init.method === "PATCH" && + String(init.body).includes("saved-node"), + ) + expect(patchCall).toBeDefined() + const body = JSON.parse(String(patchCall?.[1]?.body)) + expect(body.nodes).toEqual(versionNodes) + expect(body.connections).toEqual([]) + }) }) diff --git a/tests/lib/api/version-param.test.ts b/tests/lib/api/version-param.test.ts new file mode 100644 index 0000000..a62422c --- /dev/null +++ b/tests/lib/api/version-param.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest" +import { parseVersionParam, versionParamSchema } from "@/lib/api/version-param" + +describe("versionParamSchema", () => { + it("accepts positive integer strings", () => { + expect(versionParamSchema.parse("1")).toBe(1) + expect(versionParamSchema.parse("42")).toBe(42) + }) + + it("rejects floats, junk suffixes, and zero", () => { + expect(versionParamSchema.safeParse("1.5").success).toBe(false) + expect(versionParamSchema.safeParse("1junk").success).toBe(false) + expect(versionParamSchema.safeParse("0").success).toBe(false) + expect(versionParamSchema.safeParse("-2").success).toBe(false) + }) +}) + +describe("parseVersionParam", () => { + it("returns ok with value for valid params", () => { + expect(parseVersionParam("3")).toEqual({ ok: true, value: 3 }) + }) + + it("returns not ok for invalid params", () => { + expect(parseVersionParam("1junk")).toEqual({ ok: false }) + }) +}) diff --git a/tests/lib/execution-duration.test.ts b/tests/lib/execution-duration.test.ts new file mode 100644 index 0000000..3909e0a --- /dev/null +++ b/tests/lib/execution-duration.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest" +import { toValidDate, durationMs } from "@/lib/execution-duration" + +describe("toValidDate", () => { + it("returns a Date for ISO strings", () => { + const result = toValidDate("2026-01-01T00:00:00.000Z") + expect(result).toBeInstanceOf(Date) + expect(result?.toISOString()).toBe("2026-01-01T00:00:00.000Z") + }) + + it("returns the same Date when already valid", () => { + const input = new Date("2026-06-15T12:00:00.000Z") + expect(toValidDate(input)).toBe(input) + }) + + it("returns null for invalid strings", () => { + expect(toValidDate("not-a-date")).toBeNull() + }) + + it("returns null for impossible calendar dates", () => { + expect(toValidDate("2026-02-30T00:00:00.000Z")).toBeNull() + }) + + it("returns null for Invalid Date instances", () => { + expect(toValidDate(new Date("invalid"))).toBeNull() + }) + + it("returns null for unsupported types", () => { + expect(toValidDate(null)).toBeNull() + expect(toValidDate(undefined)).toBeNull() + expect(toValidDate({})).toBeNull() + }) +}) + +describe("durationMs", () => { + it("computes duration from ISO string dates", () => { + expect(durationMs("2026-01-01T00:00:00.000Z", "2026-01-01T00:00:05.250Z")).toBe(5250) + }) + + it("computes duration from Date instances", () => { + const start = new Date("2026-01-01T00:00:00.000Z") + const end = new Date("2026-01-01T00:00:03.000Z") + expect(durationMs(start, end)).toBe(3000) + }) + + it("returns null when either date is invalid", () => { + expect(durationMs("bad", "2026-01-01T00:00:00.000Z")).toBeNull() + expect(durationMs("2026-01-01T00:00:00.000Z", "bad")).toBeNull() + expect(durationMs(undefined, "2026-01-01T00:00:00.000Z")).toBeNull() + }) + + it("returns null for negative spans", () => { + expect(durationMs("2026-01-01T00:00:05.000Z", "2026-01-01T00:00:00.000Z")).toBeNull() + }) + + it("returns null for impossible calendar dates that JS would roll over", () => { + expect(durationMs("2026-02-30T00:00:00.000Z", "2026-03-01T00:00:00.000Z")).toBeNull() + }) +}) diff --git a/tests/lib/history-manager.test.ts b/tests/lib/history-manager.test.ts new file mode 100644 index 0000000..1450321 --- /dev/null +++ b/tests/lib/history-manager.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { HistoryManager, getHistoryManager } from "@/lib/history-manager" +import type { Workflow, WorkflowNode } from "@/lib/workflow-types" + +function makeWorkflow(overrides: Partial = {}): Workflow { + return { + id: "wf-1", + name: "Test", + description: "", + nodes: [], + connections: [], + version: 1, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + ...overrides, + } +} + +function makeNode(id: string): WorkflowNode { + return { + id, + type: "agent", + position: { x: 0, y: 0 }, + data: { label: id }, + } +} + +describe("HistoryManager", () => { + let manager: HistoryManager + + beforeEach(() => { + manager = new HistoryManager() + }) + + it("starts with empty undo and redo stacks", () => { + expect(manager.canUndo()).toBe(false) + expect(manager.canRedo()).toBe(false) + }) + + it("enables undo after saveState and restores previous graph on undo", () => { + const empty = makeWorkflow() + const withNode = makeWorkflow({ nodes: [makeNode("n1")] }) + + manager.saveState(empty) + expect(manager.canUndo()).toBe(true) + expect(manager.canRedo()).toBe(false) + + const restored = manager.undo(withNode) + expect(restored?.nodes).toEqual([]) + expect(restored?.connections).toEqual([]) + expect(manager.canUndo()).toBe(false) + expect(manager.canRedo()).toBe(true) + }) + + it("redo restores the graph after undo", () => { + const empty = makeWorkflow() + const withNode = makeWorkflow({ nodes: [makeNode("n1")] }) + + manager.saveState(empty) + manager.undo(withNode) + const redone = manager.redo(empty) + + expect(redone?.nodes).toEqual([makeNode("n1")]) + expect(manager.canUndo()).toBe(true) + expect(manager.canRedo()).toBe(false) + }) + + it("supports multiple edits in one session", () => { + const v0 = makeWorkflow() + const v1 = makeWorkflow({ nodes: [makeNode("n1")] }) + const v2 = makeWorkflow({ nodes: [makeNode("n1"), makeNode("n2")] }) + + manager.saveState(v0) + manager.saveState(v1) + + expect(manager.undo(v2)?.nodes).toEqual([makeNode("n1")]) + expect(manager.undo(v1)?.nodes).toEqual([]) + expect(manager.redo(v0)?.nodes).toEqual([makeNode("n1")]) + expect(manager.redo(v1)?.nodes).toEqual([makeNode("n1"), makeNode("n2")]) + }) + + it("clears redo stack when a new state is saved after undo", () => { + const v0 = makeWorkflow() + const v1 = makeWorkflow({ nodes: [makeNode("n1")] }) + const vAlt = makeWorkflow({ nodes: [makeNode("alt")] }) + + manager.saveState(v0) + manager.undo(v1) + expect(manager.canRedo()).toBe(true) + + manager.saveState(vAlt) + expect(manager.canRedo()).toBe(false) + }) + + it("returns null when undo/redo stacks are empty", () => { + const current = makeWorkflow({ nodes: [makeNode("n1")] }) + expect(manager.undo(current)).toBeNull() + expect(manager.redo(current)).toBeNull() + }) + + it("deep-copies saved workflows so later mutations do not corrupt history", () => { + const snapshot = makeWorkflow({ nodes: [makeNode("n1")] }) + manager.saveState(snapshot) + snapshot.nodes.push(makeNode("mutated")) + + const restored = manager.undo(makeWorkflow({ nodes: [makeNode("current")] })) + expect(restored?.nodes).toEqual([makeNode("n1")]) + }) + + it("notifies subscribers when stacks change", () => { + const seen: number[] = [] + const unsubscribe = manager.subscribe(() => { + seen.push(manager.getRevision()) + }) + manager.saveState(makeWorkflow()) + manager.undo(makeWorkflow({ nodes: [makeNode("n1")] })) + unsubscribe() + const revisionAfterUnsubscribe = manager.getRevision() + manager.saveState(makeWorkflow()) + expect(seen.length).toBe(2) + expect(manager.getRevision()).toBeGreaterThan(revisionAfterUnsubscribe) + }) +}) + +describe("getHistoryManager", () => { + it("returns a stable client-side manager per workflow id", () => { + const a = getHistoryManager("client-history-a") + const b = getHistoryManager("client-history-a") + const c = getHistoryManager("client-history-b") + + expect(a).toBe(b) + expect(a).not.toBe(c) + + a.clear() + c.clear() + }) +}) diff --git a/tests/lib/version-store.test.ts b/tests/lib/version-store.test.ts new file mode 100644 index 0000000..8a77720 --- /dev/null +++ b/tests/lib/version-store.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import type { Workflow, WorkflowNode } from "@/lib/workflow-types" + +const { mockFrom, mockGetSupabase } = vi.hoisted(() => ({ + mockFrom: vi.fn(), + mockGetSupabase: vi.fn(), +})) + +vi.mock("@/lib/supabase/server", () => ({ + getSupabaseServerClient: () => mockGetSupabase(), +})) + +function makeWorkflow(overrides: Partial = {}): Workflow { + return { + id: "wf-1", + name: "Test", + description: "", + nodes: [], + connections: [], + version: 1, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + ...overrides, + } +} + +function makeNode(id: string): WorkflowNode { + return { + id, + type: "agent", + position: { x: 0, y: 0 }, + data: { label: id }, + } +} + +/** Chainable thenable mock for Supabase query builders */ +function createMockChain(result: { data: T; error: { message: string; code?: string } | null }) { + const thenable = { + then: (resolve: (v: typeof result) => void) => resolve(result), + catch: () => thenable, + finally: () => thenable, + select: () => thenable, + eq: () => thenable, + order: () => thenable, + single: () => thenable, + maybeSingle: () => thenable, + insert: () => thenable, + update: () => thenable, + delete: () => thenable, + } + return thenable +} + +describe("versionStore (memory fallback)", () => { + beforeEach(async () => { + vi.resetModules() + mockGetSupabase.mockResolvedValue(null) + mockFrom.mockReset() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("creates and lists versions in-memory when Supabase is unavailable", async () => { + const { versionStore } = await import("@/lib/version-store") + const workflow = makeWorkflow({ nodes: [makeNode("n1")] }) + + const created = await versionStore.createVersion(workflow, "checkpoint") + expect(created.workflowId).toBe("wf-1") + expect(created.nodes).toEqual([makeNode("n1")]) + expect(created.description).toBe("checkpoint") + + const listed = await versionStore.getVersions("wf-1") + expect(listed).toHaveLength(1) + expect(listed[0].id).toBe(created.id) + }) + + it("getVersion returns the matching snapshot for restore", async () => { + const { versionStore } = await import("@/lib/version-store") + const snapshot = makeWorkflow({ nodes: [makeNode("saved")], connections: [] }) + const created = await versionStore.createVersion(snapshot) + + const loaded = await versionStore.getVersion("wf-1", created.version) + expect(loaded?.nodes).toEqual([makeNode("saved")]) + expect(loaded?.connections).toEqual([]) + }) + + it("deleteVersion removes a version from memory", async () => { + const { versionStore } = await import("@/lib/version-store") + const created = await versionStore.createVersion(makeWorkflow()) + expect(await versionStore.deleteVersion("wf-1", created.version)).toBe("ok") + expect(await versionStore.getVersions("wf-1")).toHaveLength(0) + }) + + it("tagVersion appends a tag in memory", async () => { + const { versionStore } = await import("@/lib/version-store") + const created = await versionStore.createVersion(makeWorkflow()) + expect(await versionStore.tagVersion("wf-1", created.version, "stable")).toBe("ok") + const loaded = await versionStore.getVersion("wf-1", created.version) + expect(loaded?.tags).toContain("stable") + }) + + it("compareVersions reports added and removed nodes", async () => { + const { versionStore } = await import("@/lib/version-store") + const v1 = await versionStore.createVersion(makeWorkflow({ nodes: [makeNode("a")] })) + const v2 = await versionStore.createVersion( + makeWorkflow({ version: 2, nodes: [makeNode("a"), makeNode("b")] }), + ) + + const comparison = await versionStore.compareVersions("wf-1", v1.version, v2.version) + expect(comparison?.added.nodes.map((n) => n.id)).toEqual(["b"]) + expect(comparison?.removed.nodes).toEqual([]) + }) + + it("throws in production when Supabase client is null", async () => { + vi.stubEnv("NODE_ENV", "production") + const { versionStore } = await import("@/lib/version-store") + await expect(versionStore.createVersion(makeWorkflow())).rejects.toThrow( + /Database connection is required for workflow versions in production/, + ) + }) +}) + +describe("versionStore (Supabase persistence)", () => { + beforeEach(async () => { + vi.resetModules() + mockFrom.mockReset() + mockGetSupabase.mockReset() + mockGetSupabase.mockResolvedValue({ from: mockFrom }) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("inserts into workflow_versions when Supabase is available", async () => { + const row = { + id: "ver-1", + workflow_id: "wf-1", + version: 1, + nodes: [makeNode("n1")], + connections: [], + tag: null, + description: "Manual save", + created_at: "2026-01-01T00:00:00.000Z", + } + mockFrom + .mockReturnValueOnce(createMockChain({ data: [], error: null })) + .mockReturnValueOnce(createMockChain({ data: row, error: null })) + + const { versionStore } = await import("@/lib/version-store") + const created = await versionStore.createVersion( + makeWorkflow({ nodes: [makeNode("n1")] }), + "Manual save", + ) + + expect(mockFrom).toHaveBeenCalledWith("workflow_versions") + expect(created.id).toBe("ver-1") + expect(created.nodes).toEqual([makeNode("n1")]) + expect(created.description).toBe("Manual save") + }) + + it("retries createVersion on unique version conflicts", async () => { + const row = { + id: "ver-2", + workflow_id: "wf-1", + version: 2, + nodes: [], + connections: [], + tag: null, + description: null, + created_at: "2026-01-01T00:00:00.000Z", + } + mockFrom + .mockReturnValueOnce(createMockChain({ data: [], error: null })) + .mockReturnValueOnce( + createMockChain({ data: null, error: { message: "duplicate", code: "23505" } }), + ) + .mockReturnValueOnce( + createMockChain({ + data: [{ ...row, version: 1, id: "ver-1" }], + error: null, + }), + ) + .mockReturnValueOnce(createMockChain({ data: row, error: null })) + + const { versionStore } = await import("@/lib/version-store") + const created = await versionStore.createVersion(makeWorkflow()) + expect(created.version).toBe(2) + }) + + it("lists versions from workflow_versions ordered by version desc", async () => { + const rows = [ + { + id: "ver-2", + workflow_id: "wf-1", + version: 2, + nodes: [], + connections: [], + tag: '["prod"]', + description: null, + created_at: "2026-01-02T00:00:00.000Z", + }, + { + id: "ver-1", + workflow_id: "wf-1", + version: 1, + nodes: [], + connections: [], + tag: null, + description: null, + created_at: "2026-01-01T00:00:00.000Z", + }, + ] + mockFrom.mockReturnValue(createMockChain({ data: rows, error: null })) + + const { versionStore } = await import("@/lib/version-store") + const listed = await versionStore.getVersions("wf-1") + + expect(mockFrom).toHaveBeenCalledWith("workflow_versions") + expect(listed.map((v) => v.version)).toEqual([2, 1]) + expect(listed[0].tags).toEqual(["prod"]) + expect(listed[0].name).toBe("v2") + }) + + it("getVersion returns undefined only for true no-row errors", async () => { + mockFrom.mockReturnValueOnce( + createMockChain({ data: null, error: { message: "no rows", code: "PGRST116" } }), + ) + const { versionStore } = await import("@/lib/version-store") + await expect(versionStore.getVersion("wf-1", 9)).resolves.toBeUndefined() + }) + + it("getVersion throws on unexpected database errors", async () => { + mockFrom.mockReturnValueOnce( + createMockChain({ data: null, error: { message: "connection reset", code: "57P01" } }), + ) + const { versionStore } = await import("@/lib/version-store") + await expect(versionStore.getVersion("wf-1", 1)).rejects.toThrow(/connection reset/) + }) + + it("tagVersion returns forbidden when update affects zero rows", async () => { + const existing = { + id: "ver-1", + workflow_id: "wf-1", + version: 1, + nodes: [], + connections: [], + tag: null, + description: null, + created_at: "2026-01-01T00:00:00.000Z", + } + mockFrom + .mockReturnValueOnce(createMockChain({ data: existing, error: null })) + .mockReturnValueOnce(createMockChain({ data: null, error: null })) + + const { versionStore } = await import("@/lib/version-store") + await expect(versionStore.tagVersion("wf-1", 1, "stable")).resolves.toBe("forbidden") + }) + + it("deleteVersion returns ok when a row is deleted", async () => { + const existing = { + id: "ver-1", + workflow_id: "wf-1", + version: 1, + nodes: [], + connections: [], + tag: null, + description: null, + created_at: "2026-01-01T00:00:00.000Z", + } + mockFrom + .mockReturnValueOnce(createMockChain({ data: existing, error: null })) + .mockReturnValueOnce(createMockChain({ data: { id: "ver-1" }, error: null })) + + const { versionStore } = await import("@/lib/version-store") + await expect(versionStore.deleteVersion("wf-1", 1)).resolves.toBe("ok") + }) +}) diff --git a/tests/lib/workflow-to-reactflow.test.ts b/tests/lib/workflow-to-reactflow.test.ts new file mode 100644 index 0000000..197bd8c --- /dev/null +++ b/tests/lib/workflow-to-reactflow.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest" +import { + connectionIdsAlongNodePath, + connectionIdsTouchingNode, + workflowConnectionsToEdges, +} from "@/lib/builder/workflow-to-reactflow" +import type { Connection, WorkflowNode } from "@/lib/workflow-types" + +const connections: Connection[] = [ + { id: "e1", sourceId: "n1", targetId: "n2" }, + { id: "e2", sourceId: "n2", targetId: "n3" }, + { id: "e3", sourceId: "n4", targetId: "n5" }, +] + +const nodes: WorkflowNode[] = [ + { id: "n1", type: "start", position: { x: 0, y: 0 }, data: { label: "Start" } }, + { id: "n2", type: "agent", position: { x: 100, y: 0 }, data: { label: "Agent" } }, + { id: "n3", type: "end", position: { x: 200, y: 0 }, data: { label: "End" } }, +] + +describe("connectionIdsTouchingNode", () => { + it("returns edge ids where the node is source or target", () => { + expect(connectionIdsTouchingNode(connections, "n2")).toEqual(["e1", "e2"]) + }) + + it("returns empty array when the node has no connections", () => { + expect(connectionIdsTouchingNode(connections, "missing")).toEqual([]) + }) + + it("returns empty array for empty connections", () => { + expect(connectionIdsTouchingNode([], "n1")).toEqual([]) + }) +}) + +describe("connectionIdsAlongNodePath", () => { + it("returns only consecutive path edges", () => { + expect(connectionIdsAlongNodePath(connections, ["n1", "n2", "n3"])).toEqual(["e1", "e2"]) + }) + + it("skips missing hops without inventing adjacency", () => { + expect(connectionIdsAlongNodePath(connections, ["n1", "n3"])).toEqual([]) + }) +}) + +describe("workflowConnectionsToEdges runningEdgeIds", () => { + it("marks matching edges as animatedFlow with isRunning true", () => { + const edges = workflowConnectionsToEdges(connections, { + nodes, + runningEdgeIds: ["e1", "e2"], + }) + + const e1 = edges.find((e) => e.id === "e1") + const e2 = edges.find((e) => e.id === "e2") + const e3 = edges.find((e) => e.id === "e3") + + expect(e1?.type).toBe("animatedFlow") + expect(e1?.data).toMatchObject({ isRunning: true }) + expect(e2?.type).toBe("animatedFlow") + expect(e2?.data).toMatchObject({ isRunning: true }) + expect(e3?.type).toBe("gradient") + expect(e3?.data).toMatchObject({ isRunning: false }) + }) + + it("defaults to gradient edges when runningEdgeIds is omitted", () => { + const edges = workflowConnectionsToEdges(connections, { nodes }) + expect(edges.every((e) => e.type === "gradient")).toBe(true) + expect(edges.every((e) => e.data?.isRunning === false)).toBe(true) + }) +})