From 0d45f0cc02ab38c4a53c582f8ad31723459d035b Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 27 Jul 2026 01:18:54 -0300 Subject: [PATCH 01/12] fix(builder): deprecate server undo/redo history routes Return 410 Gone so UI relies on client HistoryManager only and empty server Maps cannot lie about canUndo. --- .../workflows/[id]/history/status/route.ts | 18 +++++------ src/app/api/workflows/[id]/redo/route.ts | 32 ++++++------------- src/app/api/workflows/[id]/undo/route.ts | 32 ++++++------------- 3 files changed, 27 insertions(+), 55 deletions(-) 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]/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]/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 }, + ) } From f9325aade6100b4770f376d16f0299ff320a0a9e Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 27 Jul 2026 01:19:01 -0300 Subject: [PATCH 02/12] fix(builder): persist workflow versions in Supabase Prefer workflow_versions with RLS UPDATE/DELETE; keep in-memory Map only as non-prod fallback when Supabase is unavailable. --- scripts/002_create_rls_policies.sql | 26 ++ .../[id]/versions/[version]/route.ts | 46 ++- .../workflows/[id]/versions/compare/route.ts | 8 +- src/app/api/workflows/[id]/versions/route.ts | 17 +- src/lib/version-store.ts | 286 ++++++++++++++---- tests/lib/version-store.test.ts | 180 +++++++++++ 6 files changed, 494 insertions(+), 69 deletions(-) create mode 100644 tests/lib/version-store.test.ts 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]/versions/[version]/route.ts b/src/app/api/workflows/[id]/versions/[version]/route.ts index c5f51be..66c93e6 100644 --- a/src/app/api/workflows/[id]/versions/[version]/route.ts +++ b/src/app/api/workflows/[id]/versions/[version]/route.ts @@ -1,7 +1,12 @@ import { type NextRequest, NextResponse } from "next/server" +import { z } from "zod" import { withWorkspace } from "@/lib/api/with-workspace" import { versionStore } from "@/lib/version-store" +const tagVersionBodySchema = z.object({ + tag: z.string().min(1, "tag must be a non-empty string"), +}) + export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string; version: string }> }, @@ -10,8 +15,15 @@ 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) + const versionNumber = Number.parseInt(version, 10) + if (Number.isNaN(versionNumber)) { + return NextResponse.json( + { error: `Invalid version number: expected integer, got ${JSON.stringify(version)}` }, + { status: 400 }, + ) + } + + const versionData = await versionStore.getVersion(id, versionNumber) if (!versionData) { return NextResponse.json({ error: "Version not found" }, { status: 404 }) @@ -28,8 +40,15 @@ 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) + const versionNumber = Number.parseInt(version, 10) + if (Number.isNaN(versionNumber)) { + return NextResponse.json( + { error: `Invalid version number: expected integer, got ${JSON.stringify(version)}` }, + { status: 400 }, + ) + } + + const success = await versionStore.deleteVersion(id, versionNumber) if (!success) { return NextResponse.json({ error: "Version not found" }, { status: 404 }) @@ -47,9 +66,16 @@ 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) + const versionNumber = Number.parseInt(version, 10) + if (Number.isNaN(versionNumber)) { + return NextResponse.json( + { error: `Invalid version number: expected integer, got ${JSON.stringify(version)}` }, + { status: 400 }, + ) + } + + const body = tagVersionBodySchema.parse(await request.json()) + const success = await versionStore.tagVersion(id, versionNumber, body.tag) if (!success) { return NextResponse.json({ error: "Version not found" }, { status: 404 }) @@ -57,6 +83,12 @@ export async function PATCH( return NextResponse.json({ success: true }) } 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..4f2cf65 100644 --- a/src/app/api/workflows/[id]/versions/compare/route.ts +++ b/src/app/api/workflows/[id]/versions/compare/route.ts @@ -8,14 +8,14 @@ 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 v1 = Number.parseInt(searchParams.get("v1") || "", 10) + const v2 = Number.parseInt(searchParams.get("v2") || "", 10) - if (isNaN(v1) || isNaN(v2)) { + if (Number.isNaN(v1) || Number.isNaN(v2)) { return NextResponse.json({ error: "Invalid version numbers" }, { status: 400 }) } - const comparison = versionStore.compareVersions(id, v1, v2) + const comparison = await versionStore.compareVersions(id, v1, v2) if (!comparison) { return NextResponse.json({ error: "Versions not found" }, { status: 404 }) diff --git a/src/app/api/workflows/[id]/versions/route.ts b/src/app/api/workflows/[id]/versions/route.ts index 902f5e3..ad0d877 100644 --- a/src/app/api/workflows/[id]/versions/route.ts +++ b/src/app/api/workflows/[id]/versions/route.ts @@ -1,14 +1,19 @@ 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 versions = await versionStore.getVersions(id) return NextResponse.json(versions) } @@ -18,16 +23,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ try { const { id } = await params - const { description } = await request.json() + const body = createVersionBodySchema.parse(await request.json().catch(() => ({}))) const workflow = await getWorkflow(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/lib/version-store.ts b/src/lib/version-store.ts index cbb4b79..4d2ffee 100644 --- a/src/lib/version-store.ts +++ b/src/lib/version-store.ts @@ -1,91 +1,267 @@ +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 +} + +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 } } +} + +/** + * Prefers Supabase `workflow_versions` when a server client is available. + * Fallback: process-local Map — non-prod only (serverless amnesia across instances). + */ 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) { + return this.createVersionInMemory(workflow, description) } - const existingVersions = this.versions.get(workflow.id) || [] - existingVersions.push(version) - this.versions.set(workflow.id, existingVersions) + 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) { + throw new Error( + `Failed to create workflow version for workflowId=${workflow.id}: ${error?.message ?? "no row returned"}`, + ) + } + + return mapVersionRow(data as WorkflowVersionRow) } - 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) { + 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) { + 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 || !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) { + return this.tagVersionInMemory(workflowId, versionNumber, tag) } - 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 false + + const tags = existing.tags ?? [] + if (!tags.includes(tag)) tags.push(tag) + + const { error } = await supabase + .from("workflow_versions") + .update({ tag: serializeTags(tags) }) + .eq("workflow_id", workflowId) + .eq("version", versionNumber) + + return !error + } + + async deleteVersion(workflowId: string, versionNumber: number): Promise { + const supabase = await getSupabaseServerClient() + if (!supabase) { + return this.deleteVersionInMemory(workflowId, versionNumber) } - 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 false - return { added, removed, modified: { nodes: modified } } + const { error } = await supabase + .from("workflow_versions") + .delete() + .eq("workflow_id", workflowId) + .eq("version", versionNumber) + + return !error } - 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/tests/lib/version-store.test.ts b/tests/lib/version-store.test.ts new file mode 100644 index 0000000..829a601 --- /dev/null +++ b/tests/lib/version-store.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } 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: Error | null }) { + const thenable = { + then: (resolve: (v: typeof result) => void) => resolve(result), + catch: () => thenable, + finally: () => thenable, + select: () => thenable, + eq: () => thenable, + order: () => thenable, + single: () => thenable, + insert: () => thenable, + update: () => thenable, + delete: () => thenable, + } + return thenable +} + +describe("versionStore (memory fallback)", () => { + beforeEach(async () => { + vi.resetModules() + mockGetSupabase.mockResolvedValue(null) + mockFrom.mockReset() + }) + + 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(true) + 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(true) + 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([]) + }) +}) + +describe("versionStore (Supabase persistence)", () => { + beforeEach(async () => { + vi.resetModules() + mockFrom.mockReset() + mockGetSupabase.mockReset() + mockGetSupabase.mockResolvedValue({ from: mockFrom }) + }) + + 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("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") + }) +}) From a6067be972f96ba4d693990f714a85bdbd46c4a9 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 27 Jul 2026 01:19:01 -0300 Subject: [PATCH 03/12] fix(builder): parse ISO execution dates safely Coerce startedAt/completedAt before duration math so JSON-serialized runs no longer throw on .getTime(). --- src/components/builder/execution-monitor.tsx | 7 +-- src/lib/execution-duration.ts | 30 +++++++++++++ tests/lib/execution-duration.test.ts | 47 ++++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 src/lib/execution-duration.ts create mode 100644 tests/lib/execution-duration.test.ts diff --git a/src/components/builder/execution-monitor.tsx b/src/components/builder/execution-monitor.tsx index a5a4e1e..a372476 100644 --- a/src/components/builder/execution-monitor.tsx +++ b/src/components/builder/execution-monitor.tsx @@ -15,6 +15,7 @@ import { import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { cn } from "@/lib/utils" +import { durationMs } from "@/lib/execution-duration" import type { WorkflowExecution, ExecutionLog } from "@/lib/workflow-types" import { ScrollArea } from "@/components/ui/scroll-area" import { EmptyState } from "@/components/ui/empty-state" @@ -141,6 +142,8 @@ export function ExecutionMonitor({ if (!isOpen) return null + const elapsedMs = execution ? durationMs(execution.startedAt, execution.completedAt) : null + return (
{/* Header */} @@ -195,9 +198,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/lib/execution-duration.ts b/src/lib/execution-duration.ts new file mode 100644 index 0000000..540de3c --- /dev/null +++ b/src/lib/execution-duration.ts @@ -0,0 +1,30 @@ +/** + * 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" || typeof value === "number") { + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed + } + return null +} + +/** + * Duration in milliseconds between two date-like values, or null if either is invalid. + * + * @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 + return end.getTime() - start.getTime() +} diff --git a/tests/lib/execution-duration.test.ts b/tests/lib/execution-duration.test.ts new file mode 100644 index 0000000..939df49 --- /dev/null +++ b/tests/lib/execution-duration.test.ts @@ -0,0 +1,47 @@ +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 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() + }) +}) From 6bffcd1150395bb8f0598fb9f94631c576cbbba4 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 27 Jul 2026 01:19:01 -0300 Subject: [PATCH 04/12] fix(builder): derive running edge ids from connections Expose connectionIdsTouchingNode so canvas highlight can animate edges on the active run path. --- src/lib/builder/workflow-to-reactflow.ts | 12 +++++ tests/lib/workflow-to-reactflow.test.ts | 58 ++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 tests/lib/workflow-to-reactflow.test.ts diff --git a/src/lib/builder/workflow-to-reactflow.ts b/src/lib/builder/workflow-to-reactflow.ts index dfef174..faf6ab4 100644 --- a/src/lib/builder/workflow-to-reactflow.ts +++ b/src/lib/builder/workflow-to-reactflow.ts @@ -47,6 +47,18 @@ 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) +} + export function workflowConnectionsToEdges( connections: Connection[], options: WorkflowConnectionsToEdgesOptions = {}, diff --git a/tests/lib/workflow-to-reactflow.test.ts b/tests/lib/workflow-to-reactflow.test.ts new file mode 100644 index 0000000..4a28e3d --- /dev/null +++ b/tests/lib/workflow-to-reactflow.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest" +import { + 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("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) + }) +}) From 652c30f7089694bef43a479a9e7aa40baafce1f4 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 27 Jul 2026 01:19:01 -0300 Subject: [PATCH 05/12] fix(builder): fill canvas under app shell height Replace nested h-screen with dvh shell offset so mobile header and sidebar no longer clip the builder. --- src/components/builder/builder-client.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/builder/builder-client.tsx b/src/components/builder/builder-client.tsx index 350c7aa..153fa80 100644 --- a/src/components/builder/builder-client.tsx +++ b/src/components/builder/builder-client.tsx @@ -2,12 +2,15 @@ import dynamic from "next/dynamic" +/** Mobile shell header offset (`main` uses `pt-14`); desktop has no top padding. */ +const BUILDER_SHELL_HEIGHT = "h-[calc(100dvh-3.5rem)] w-full overflow-hidden lg:h-dvh" + const BuilderCanvas = dynamic( () => import("./builder-canvas").then((m) => ({ default: m.BuilderCanvas })), { ssr: false, loading: () => ( -
+
Loading workflow builder...
), @@ -16,7 +19,7 @@ const BuilderCanvas = dynamic( export function BuilderClient() { return ( -
+
) From 1e3e4f6576880a3ea798c3a25b725a8591c4a4ab Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 27 Jul 2026 01:19:08 -0300 Subject: [PATCH 06/12] fix(builder): wire client undo, restore, select-all, and run highlight Use client HistoryManager for undo/redo and canUndo flags, implement version restore via PATCH, select all nodes/edges, highlight running edges, and fix toolbar GlassContainer inner layout. --- src/components/builder/builder-canvas.tsx | 215 +++++++++++++++------- 1 file changed, 146 insertions(+), 69 deletions(-) diff --git a/src/components/builder/builder-canvas.tsx b/src/components/builder/builder-canvas.tsx index 15571d5..1f5f400 100644 --- a/src/components/builder/builder-canvas.tsx +++ b/src/components/builder/builder-canvas.tsx @@ -31,7 +31,14 @@ 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 { + WorkflowNode, + Position, + NodeType, + Workflow, + Connection, + WorkflowVersion, +} from "@/lib/workflow-types" import { NodeSidebar } from "./node-sidebar" import { CanvasNode } from "./canvas-node" import { FrameNode } from "./frame-node" @@ -49,6 +56,7 @@ import { workflowNodesToReactFlow, workflowConnectionsToEdges, reactFlowEdgesToConnections, + connectionIdsTouchingNode, } from "@/lib/builder/workflow-to-reactflow" import { edgeTypes } from "./edges" import type { WorkflowNodeData, WorkflowNodeProps } from "./canvas-node" @@ -151,11 +159,6 @@ 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) @@ -163,19 +166,28 @@ function BuilderCanvasInner() { const [showExecutionMonitor, setShowExecutionMonitor] = useState(false) const [commandPaletteOpen, setCommandPaletteOpen] = useState(false) const [highlightedNodeId, setHighlightedNodeId] = useState(null) - const [highlightedEdgeIds, _setHighlightedEdgeIds] = useState([]) + const [highlightedEdgeIds, setHighlightedEdgeIds] = useState([]) const [isLayoutTransitioning, setIsLayoutTransitioning] = useState(false) + // Bumped after history mutations so canUndo/canRedo re-read from the client manager. + const [historyEpoch, setHistoryEpoch] = useState(0) + + const syncHistoryFlags = useCallback((_workflowKey: string) => { + setHistoryEpoch((epoch) => epoch + 1) + }, []) + + const canUndo = historyEpoch >= 0 && workflowId ? getHistoryManager(workflowId).canUndo() : false + const canRedo = historyEpoch >= 0 && workflowId ? getHistoryManager(workflowId).canRedo() : false const saveToHistory = useCallback(() => { if (workflow && workflowId) { const historyManager = getHistoryManager(workflowId) historyManager.saveState(workflow) + syncHistoryFlags(workflowId) } - }, [workflow, workflowId]) + }, [workflow, workflowId, syncHistoryFlags]) - const mutateWorkflowAndHistory = useCallback((id: string) => { + const mutateWorkflow = useCallback((id: string) => { mutate(`/api/workflows/${id}`) - mutate(`/api/workflows/${id}/history/status`) }, []) const [menuType, setMenuType] = useState<"node" | "pane" | null>(null) @@ -210,9 +222,9 @@ function BuilderCanvasInner() { if (!workflowId) return saveToHistory() await safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { method: "DELETE" }) - mutateWorkflowAndHistory(workflowId) + mutateWorkflow(workflowId) }, - [workflowId, saveToHistory, mutateWorkflowAndHistory, safeFetch], + [workflowId, saveToHistory, mutateWorkflow, safeFetch], ) const handleAssignToFrame = useCallback( @@ -224,10 +236,10 @@ function BuilderCanvasInner() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ parentId: frameId }), }) - mutateWorkflowAndHistory(workflowId) + mutateWorkflow(workflowId) toast({ title: "Node added to frame" }) }, - [workflowId, saveToHistory, mutateWorkflowAndHistory, toast, safeFetch], + [workflowId, saveToHistory, mutateWorkflow, toast, safeFetch], ) const handleRemoveFromFrame = useCallback( @@ -239,10 +251,10 @@ function BuilderCanvasInner() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ parentId: null }), }) - mutateWorkflowAndHistory(workflowId) + mutateWorkflow(workflowId) toast({ title: "Node removed from frame" }) }, - [workflowId, saveToHistory, mutateWorkflowAndHistory, toast, safeFetch], + [workflowId, saveToHistory, mutateWorkflow, toast, safeFetch], ) const handleFrameLabelChange = useCallback( @@ -256,9 +268,9 @@ function BuilderCanvasInner() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: { ...node.data, label: newLabel } }), }) - mutateWorkflowAndHistory(workflowId) + mutateWorkflow(workflowId) }, - [workflowId, workflow?.nodes, saveToHistory, mutateWorkflowAndHistory, safeFetch], + [workflowId, workflow?.nodes, saveToHistory, mutateWorkflow, safeFetch], ) const initialNodes = workflow @@ -343,10 +355,10 @@ function BuilderCanvasInner() { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ connections: reactFlowEdgesToConnections(updatedEdges) }), - }).then(() => mutateWorkflowAndHistory(workflowId!)) + }).then(() => mutateWorkflow(workflowId!)) } }, - [onEdgesChange, edges, workflowId, saveToHistory, mutateWorkflowAndHistory, safeFetch], + [onEdgesChange, edges, workflowId, saveToHistory, mutateWorkflow, safeFetch], ) const handleConnect = useCallback( @@ -363,9 +375,9 @@ function BuilderCanvasInner() { targetHandle: connection.targetHandle ?? undefined, }), }) - mutateWorkflowAndHistory(workflowId) + mutateWorkflow(workflowId) }, - [workflowId, saveToHistory, mutateWorkflowAndHistory, safeFetch], + [workflowId, saveToHistory, mutateWorkflow, safeFetch], ) const handleNodeDragStop = useCallback( @@ -381,9 +393,9 @@ function BuilderCanvasInner() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ position: snappedPosition }), }) - mutateWorkflowAndHistory(workflowId) + mutateWorkflow(workflowId) }, - [workflowId, saveToHistory, mutateWorkflowAndHistory, safeFetch], + [workflowId, saveToHistory, mutateWorkflow, safeFetch], ) const handleAddNode = useCallback( @@ -443,16 +455,9 @@ function BuilderCanvasInner() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(nodePayload), }) - mutateWorkflowAndHistory(workflowId) + mutateWorkflow(workflowId) }, - [ - workflowId, - workflow?.nodes, - saveToHistory, - mutateWorkflowAndHistory, - screenToFlowPosition, - safeFetch, - ], + [workflowId, workflow?.nodes, saveToHistory, mutateWorkflow, screenToFlowPosition, safeFetch], ) const handleAddFrame = useCallback(() => { @@ -463,16 +468,38 @@ function BuilderCanvasInner() { 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]) + if (!workflowId || !workflow) return + const historyManager = getHistoryManager(workflowId) + const previous = historyManager.undo(workflow) + if (!previous) return + syncHistoryFlags(workflowId) + await safeFetch(`/api/workflows/${workflowId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nodes: previous.nodes, + connections: previous.connections, + }), + }) + mutateWorkflow(workflowId) + }, [workflowId, workflow, syncHistoryFlags, mutateWorkflow, safeFetch]) const handleRedo = useCallback(async () => { - if (!workflowId) return - await safeFetch(`/api/workflows/${workflowId}/redo`, { method: "POST" }) - mutateWorkflowAndHistory(workflowId) - }, [workflowId, mutateWorkflowAndHistory, safeFetch]) + if (!workflowId || !workflow) return + const historyManager = getHistoryManager(workflowId) + const next = historyManager.redo(workflow) + if (!next) return + syncHistoryFlags(workflowId) + await safeFetch(`/api/workflows/${workflowId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nodes: next.nodes, + connections: next.connections, + }), + }) + mutateWorkflow(workflowId) + }, [workflowId, workflow, syncHistoryFlags, mutateWorkflow, safeFetch]) const handleCopy = useCallback(async () => { if (!selectedNodeId || !workflowId) return @@ -503,12 +530,12 @@ function BuilderCanvasInner() { }) if (response.ok) { const result = await response.json() - mutateWorkflowAndHistory(workflowId) + mutateWorkflow(workflowId) toast({ title: `Pasted ${result.nodeIds?.length ?? 0} node(s)` }) } else { toast({ title: "Nothing to paste", variant: "destructive" }) } - }, [workflowId, saveToHistory, mutateWorkflowAndHistory, toast, safeFetch]) + }, [workflowId, saveToHistory, mutateWorkflow, toast, safeFetch]) const handleDuplicate = useCallback(async () => { if (!selectedNodeId || !workflowId) return @@ -533,16 +560,44 @@ function BuilderCanvasInner() { }), }) if (pasteRes.ok) { - mutateWorkflowAndHistory(workflowId) + mutateWorkflow(workflowId) toast({ title: "Node duplicated" }) } - }, [selectedNodeId, workflowId, saveToHistory, mutateWorkflowAndHistory, toast, safeFetch]) + }, [selectedNodeId, workflowId, saveToHistory, mutateWorkflow, toast, safeFetch]) const handleSelectAll = useCallback(() => { - if (workflow?.nodes.length) { - setNodes((nds) => nds.map((n) => ({ ...n, selected: n.id === workflow.nodes[0].id }))) + setNodes((nds) => nds.map((n) => ({ ...n, selected: true }))) + setEdges((eds) => eds.map((e) => ({ ...e, selected: true }))) + }, [setNodes, setEdges]) + + const clearExecutionHighlights = useCallback(() => { + setHighlightedNodeId(null) + setHighlightedEdgeIds([]) + }, []) + + const handleNodeHighlight = useCallback( + (nodeId: string | null) => { + setHighlightedNodeId(nodeId) + if (!nodeId) { + setHighlightedEdgeIds([]) + return + } + setHighlightedEdgeIds(connectionIdsTouchingNode(workflow?.connections ?? [], nodeId)) + }, + [workflow?.connections], + ) + + const handleCloseExecutionMonitor = useCallback(() => { + setShowExecutionMonitor(false) + clearExecutionHighlights() + }, [clearExecutionHighlights]) + + const handleToggleExecutionMonitor = useCallback(() => { + if (showExecutionMonitor) { + clearExecutionHighlights() } - }, [workflow, setNodes]) + setShowExecutionMonitor(!showExecutionMonitor) + }, [showExecutionMonitor, clearExecutionHighlights]) const handleAutoLayout = useCallback(async () => { if (!workflowId) return @@ -554,7 +609,7 @@ function BuilderCanvasInner() { setIsLayoutTransitioning(true) const response = await safeFetch(`/api/workflows/${workflowId}/auto-layout`, { method: "POST" }) if (response.ok) { - mutateWorkflowAndHistory(workflowId) + mutateWorkflow(workflowId) toast({ title: "Layout applied successfully" }) layoutTransitionTimeoutRef.current = setTimeout(() => { layoutTransitionTimeoutRef.current = null @@ -564,7 +619,7 @@ function BuilderCanvasInner() { setIsLayoutTransitioning(false) toast({ title: "Failed to apply layout", variant: "destructive" }) } - }, [workflowId, saveToHistory, mutateWorkflowAndHistory, toast, safeFetch]) + }, [workflowId, saveToHistory, mutateWorkflow, toast, safeFetch]) useEffect(() => { return () => { @@ -579,8 +634,8 @@ function BuilderCanvasInner() { if (!selectedNodeId || !workflowId) return saveToHistory() await safeFetch(`/api/workflows/${workflowId}/nodes/${selectedNodeId}`, { method: "DELETE" }) - mutateWorkflowAndHistory(workflowId) - }, [selectedNodeId, workflowId, saveToHistory, mutateWorkflowAndHistory, safeFetch]) + mutateWorkflow(workflowId) + }, [selectedNodeId, workflowId, saveToHistory, mutateWorkflow, safeFetch]) const handleDuplicateById = useCallback( async (nodeId: string) => { @@ -602,11 +657,11 @@ function BuilderCanvasInner() { }), }) if (pasteRes.ok) { - mutateWorkflowAndHistory(workflowId) + mutateWorkflow(workflowId) toast({ title: "Node duplicated" }) } }, - [workflowId, saveToHistory, mutateWorkflowAndHistory, toast, safeFetch], + [workflowId, saveToHistory, mutateWorkflow, toast, safeFetch], ) const handleCopyById = useCallback( @@ -644,6 +699,28 @@ function BuilderCanvasInner() { }) }, [workflowId, safeFetch]) + const handleRestoreVersion = useCallback( + async (version: WorkflowVersion) => { + if (!workflowId || !workflow) return + saveToHistory() + 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 + mutateWorkflow(workflowId) + toast({ + title: "Version restored", + description: `Restored ${version.name}`, + }) + }, + [workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch, toast], + ) + const { zoomIn, zoomOut, fitView } = useReactFlow() const handleZoomIn = useCallback(() => zoomIn(), [zoomIn]) @@ -748,7 +825,7 @@ function BuilderCanvasInner() { if (isUnauthorized) { return ( -
+

Sign in to access the workflow builder

+ {runError ?

{runError}

: null}
{/* Execution Results */} diff --git a/src/components/builder/hooks/use-builder-clipboard.ts b/src/components/builder/hooks/use-builder-clipboard.ts index 90aef42..f5910e4 100644 --- a/src/components/builder/hooks/use-builder-clipboard.ts +++ b/src/components/builder/hooks/use-builder-clipboard.ts @@ -1,7 +1,7 @@ "use client" import { useCallback, useRef } from "react" -import type { Connection, WorkflowNode } from "@/lib/workflow-types" +import type { Connection, Workflow, WorkflowNode } from "@/lib/workflow-types" import type { SafeFetch } from "../builder-constants" type ToastFn = (props: { @@ -15,39 +15,58 @@ type ClipboardPayload = { connections: Connection[] } +type SaveToHistory = (snapshot?: Workflow) => void + export function useBuilderClipboard(options: { workflowId: string | null - selectedNodeId: string | null - saveToHistory: () => void + selectedNodeIds: string[] + workflow: Workflow | null | undefined + saveToHistory: SaveToHistory mutateWorkflow: (id: string) => void safeFetch: SafeFetch toast: ToastFn }) { - const { workflowId, selectedNodeId, saveToHistory, mutateWorkflow, safeFetch, toast } = options + const { workflowId, selectedNodeIds, workflow, saveToHistory, mutateWorkflow, safeFetch, toast } = + options const clipboardRef = useRef(null) - 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 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 ?? [] } - toast({ title: "Node copied to clipboard" }) + 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`, + }) } - }, [selectedNodeId, workflowId, toast, safeFetch]) + }, [selectedNodeIds, copyNodeIds, toast]) const handlePaste = useCallback(async () => { - if (!workflowId) return + if (!workflowId || !workflow) return const clipboard = clipboardRef.current if (!clipboard?.nodes?.length) { toast({ title: "Nothing to paste", variant: "destructive" }) return } - saveToHistory() + const previous = workflow const response = await safeFetch(`/api/workflows/${workflowId}/paste`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -55,83 +74,61 @@ export function useBuilderClipboard(options: { }) 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, saveToHistory, mutateWorkflow, 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) { - mutateWorkflow(workflowId) - toast({ title: "Node duplicated" }) - } - }, [selectedNodeId, workflowId, saveToHistory, mutateWorkflow, toast, safeFetch]) + }, [workflowId, workflow, saveToHistory, mutateWorkflow, toast, safeFetch]) - 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 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: copyResult.nodes ?? [], - connections: copyResult.connections ?? [], + nodes: clipboardRef.current.nodes, + connections: clipboardRef.current.connections, }), }) if (pasteRes.ok) { + saveToHistory(previous) mutateWorkflow(workflowId) - toast({ title: "Node duplicated" }) + toast({ title: successTitle }) } }, - [workflowId, saveToHistory, mutateWorkflow, toast, safeFetch], + [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) => { - 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 ?? [] } + const ok = await copyNodeIds([nodeId]) + if (ok) { toast({ title: "Node copied to clipboard" }) } }, - [workflowId, toast, safeFetch], + [copyNodeIds, toast], ) return { diff --git a/src/components/builder/hooks/use-builder-execution-highlight.ts b/src/components/builder/hooks/use-builder-execution-highlight.ts index 0e4d33f..f72fb9e 100644 --- a/src/components/builder/hooks/use-builder-execution-highlight.ts +++ b/src/components/builder/hooks/use-builder-execution-highlight.ts @@ -1,17 +1,22 @@ "use client" import { useCallback, useState } from "react" -import { connectionIdsTouchingNode } from "@/lib/builder/workflow-to-reactflow" +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( @@ -21,8 +26,24 @@ export function useBuilderExecutionHighlight(connections: Connection[] | undefin 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], ) @@ -43,6 +64,7 @@ export function useBuilderExecutionHighlight(connections: Connection[] | undefin 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 index 5b9c4e7..4eda492 100644 --- a/src/components/builder/hooks/use-builder-graph-mutations.ts +++ b/src/components/builder/hooks/use-builder-graph-mutations.ts @@ -20,11 +20,13 @@ type ToastFn = (props: { variant?: "default" | "destructive" }) => void +type SaveToHistory = (snapshot?: Workflow) => void + export function useBuilderGraphMutations(options: { workflowId: string | null workflow: Workflow | null | undefined edges: Edge[] - saveToHistory: () => void + saveToHistory: SaveToHistory mutateWorkflow: (id: string) => void safeFetch: SafeFetch toast: ToastFn @@ -59,58 +61,76 @@ export function useBuilderGraphMutations(options: { const handleNodeDeleteById = useCallback( async (nodeId: string) => { - if (!workflowId) return - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { method: "DELETE" }) + 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, saveToHistory, mutateWorkflow, safeFetch], + [workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch], ) const handleAssignToFrame = useCallback( async (nodeId: string, frameId: string) => { - if (!workflowId) return - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { + 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, saveToHistory, mutateWorkflow, toast, safeFetch], + [workflowId, workflow, saveToHistory, mutateWorkflow, toast, safeFetch], ) const handleRemoveFromFrame = useCallback( async (nodeId: string) => { - if (!workflowId) return - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { + 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, saveToHistory, mutateWorkflow, toast, safeFetch], + [workflowId, workflow, saveToHistory, mutateWorkflow, toast, safeFetch], ) const handleFrameLabelChange = useCallback( async (nodeId: string, newLabel: string) => { - if (!workflowId) return - const node = workflow?.nodes.find((n) => n.id === nodeId) + if (!workflowId || !workflow) return + const node = workflow.nodes.find((n) => n.id === nodeId) if (!node) return - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/nodes/${nodeId}`, { + 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?.nodes, saveToHistory, mutateWorkflow, safeFetch], + [workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch], ) const handleNodesChange = useCallback( @@ -124,24 +144,29 @@ export function useBuilderGraphMutations(options: { (changes: EdgeChange[]) => { onEdgesChange(changes) const removeChanges = changes.filter((c) => c.type === "remove") as { id: string }[] - if (removeChanges.length > 0 && workflowId) { - saveToHistory() + if (removeChanges.length > 0 && workflowId && workflow) { + const previous = workflow const updatedEdges = edges.filter((e) => !removeChanges.some((r) => r.id === e.id)) - safeFetch(`/api/workflows/${workflowId}`, { + void safeFetch(`/api/workflows/${workflowId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ connections: reactFlowEdgesToConnections(updatedEdges) }), - }).then(() => mutateWorkflow(workflowId)) + }).then((response) => { + if (response.ok) { + saveToHistory(previous) + } + mutateWorkflow(workflowId) + }) } }, - [onEdgesChange, edges, workflowId, saveToHistory, mutateWorkflow, safeFetch], + [onEdgesChange, edges, workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch], ) const handleConnect = useCallback( async (connection: ReactFlowConnection) => { - if (!workflowId || !connection.source || !connection.target) return - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/connections`, { + 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({ @@ -151,33 +176,39 @@ export function useBuilderGraphMutations(options: { targetHandle: connection.targetHandle ?? undefined, }), }) + if (response.ok) { + saveToHistory(previous) + } mutateWorkflow(workflowId) }, - [workflowId, saveToHistory, mutateWorkflow, safeFetch], + [workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch], ) const handleNodeDragStop = useCallback( async (_: React.MouseEvent, node: Node) => { - if (!workflowId) return + 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, } - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/nodes/${node.id}`, { + 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, saveToHistory, mutateWorkflow, safeFetch], + [workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch], ) const handleAddNode = useCallback( async (type: NodeType, position?: Position) => { - if (!workflowId) return - saveToHistory() + if (!workflowId || !workflow) return + const previous = workflow let posX: number let posY: number @@ -195,7 +226,7 @@ export function useBuilderGraphMutations(options: { } while ( - workflow?.nodes.some( + workflow.nodes.some( (n) => Math.abs(n.position.x - posX) < 10 && Math.abs(n.position.y - posY) < 10, ) ) { @@ -226,14 +257,17 @@ export function useBuilderGraphMutations(options: { nodePayload.style = { width: 400, height: 300 } } - await safeFetch(`/api/workflows/${workflowId}/nodes`, { + 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?.nodes, saveToHistory, mutateWorkflow, screenToFlowPosition, safeFetch], + [workflowId, workflow, saveToHistory, mutateWorkflow, screenToFlowPosition, safeFetch], ) const handleAddFrame = useCallback(() => { @@ -241,15 +275,16 @@ export function useBuilderGraphMutations(options: { }, [handleAddNode]) const handleAutoLayout = useCallback(async () => { - if (!workflowId) return + if (!workflowId || !workflow) return if (layoutTransitionTimeoutRef.current) { clearTimeout(layoutTransitionTimeoutRef.current) layoutTransitionTimeoutRef.current = null } - saveToHistory() + 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(() => { @@ -260,26 +295,37 @@ export function useBuilderGraphMutations(options: { setIsLayoutTransitioning(false) toast({ title: "Failed to apply layout", variant: "destructive" }) } - }, [workflowId, saveToHistory, mutateWorkflow, toast, safeFetch]) + }, [workflowId, workflow, saveToHistory, mutateWorkflow, toast, safeFetch]) const handleNodeDelete = useCallback( - async (selectedNodeId: string | null) => { - if (!selectedNodeId || !workflowId) return - saveToHistory() - await safeFetch(`/api/workflows/${workflowId}/nodes/${selectedNodeId}`, { method: "DELETE" }) + 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, saveToHistory, mutateWorkflow, safeFetch], + [workflowId, workflow, saveToHistory, mutateWorkflow, safeFetch], ) const handleSaveVersion = useCallback(async () => { if (!workflowId) return - await safeFetch(`/api/workflows/${workflowId}/versions`, { + const response = await safeFetch(`/api/workflows/${workflowId}/versions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ description: "Manual save" }), }) - }, [workflowId, safeFetch]) + if (response.ok) { + toast({ title: "Version saved" }) + } + }, [workflowId, safeFetch, toast]) return { isLayoutTransitioning, diff --git a/src/components/builder/hooks/use-builder-history.ts b/src/components/builder/hooks/use-builder-history.ts index 404c8a8..7606ba5 100644 --- a/src/components/builder/hooks/use-builder-history.ts +++ b/src/components/builder/hooks/use-builder-history.ts @@ -1,6 +1,6 @@ "use client" -import { useCallback, useSyncExternalStore } from "react" +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" @@ -24,6 +24,13 @@ function getHistoryFlagsSnapshot(workflowId: string | null): string { 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", @@ -50,6 +57,18 @@ export function useBuilderHistory(options: { 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), @@ -64,30 +83,46 @@ export function useBuilderHistory(options: { mutate(`/api/workflows/${id}`) }, []) - const saveToHistory = useCallback(() => { - if (workflow && workflowId) { - getHistoryManager(workflowId).saveState(workflow) - } - }, [workflow, workflowId]) + 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(workflow) + snapshot = historyManager.undo(current) break case "redo": - snapshot = historyManager.redo(workflow) + snapshot = historyManager.redo(current) break default: { const _exhaustive: never = direction + transitionInFlightRef.current = false + setIsHistoryTransitioning(false) return _exhaustive } } - if (!snapshot) return + if (!snapshot) { + transitionInFlightRef.current = false + setIsHistoryTransitioning(false) + return + } try { const response = await safeFetch(`/api/workflows/${workflowId}`, { @@ -102,9 +137,13 @@ export function useBuilderHistory(options: { 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], @@ -116,6 +155,9 @@ export function useBuilderHistory(options: { 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}`, { @@ -128,6 +170,11 @@ export function useBuilderHistory(options: { }) if (!response.ok) return getHistoryManager(workflowId).saveState(previous) + lastPersistedRef.current = { + ...previous, + nodes: version.nodes, + connections: version.connections, + } mutateWorkflow(workflowId) toast({ title: "Version restored", @@ -135,6 +182,9 @@ export function useBuilderHistory(options: { }) } catch { // Leave stacks untouched when network fails before a successful persist. + } finally { + transitionInFlightRef.current = false + setIsHistoryTransitioning(false) } }, [workflowId, workflow, mutateWorkflow, safeFetch, toast], @@ -143,6 +193,7 @@ export function useBuilderHistory(options: { return { canUndo, canRedo, + isHistoryTransitioning, saveToHistory, mutateWorkflow, handleUndo, 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 faf6ab4..77602a4 100644 --- a/src/lib/builder/workflow-to-reactflow.ts +++ b/src/lib/builder/workflow-to-reactflow.ts @@ -59,6 +59,25 @@ export function connectionIdsTouchingNode(connections: Connection[], nodeId: str .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 index 540de3c..a9dd77c 100644 --- a/src/lib/execution-duration.ts +++ b/src/lib/execution-duration.ts @@ -9,15 +9,34 @@ export function toValidDate(value: unknown): Date | null { if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : value } - if (typeof value === "string" || typeof value === "number") { + 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. + * 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 @@ -26,5 +45,7 @@ export function durationMs(startedAt: unknown, completedAt: unknown): number | n const start = toValidDate(startedAt) const end = toValidDate(completedAt) if (!start || !end) return null - return end.getTime() - start.getTime() + const ms = end.getTime() - start.getTime() + if (ms < 0) return null + return ms } diff --git a/src/lib/version-store.ts b/src/lib/version-store.ts index 44b810d..fbe46f2 100644 --- a/src/lib/version-store.ts +++ b/src/lib/version-store.ts @@ -13,6 +13,13 @@ interface WorkflowVersionRow { 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) @@ -105,6 +112,10 @@ function requireSupabaseOrMemoryFallback() { } } +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). @@ -119,31 +130,43 @@ class VersionStore { return this.createVersionInMemory(workflow, description) } - const existing = await this.getVersionsFromDb(workflow.id) - const versionNumber = nextVersionNumber(existing, workflow.version) - const id = crypto.randomUUID() + 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() + + 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) + } - 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() + lastErrorMessage = error?.message ?? "no row returned" + if (error?.code === UNIQUE_VIOLATION) { + continue + } - if (error || !data) { throw new Error( - `Failed to create workflow version for workflowId=${workflow.id}: ${error?.message ?? "no row returned"}`, + `Failed to create workflow version for workflowId=${workflow.id}: ${lastErrorMessage}`, ) } - return mapVersionRow(data as WorkflowVersionRow) + throw new Error( + `Failed to create workflow version for workflowId=${workflow.id} after ${CREATE_VERSION_MAX_ATTEMPTS} attempts: ${lastErrorMessage}`, + ) } async getVersions(workflowId: string): Promise { @@ -172,7 +195,13 @@ class VersionStore { .eq("version", versionNumber) .single() - if (error || !data) return undefined + 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) } @@ -187,45 +216,66 @@ class VersionStore { return compareSnapshots(v1, v2) } - async tagVersion(workflowId: string, versionNumber: number, tag: string): Promise { + async tagVersion( + workflowId: string, + versionNumber: number, + tag: string, + ): Promise { const supabase = await getSupabaseServerClient() if (!supabase) { requireSupabaseOrMemoryFallback() - return this.tagVersionInMemory(workflowId, versionNumber, tag) + return this.tagVersionInMemory(workflowId, versionNumber, tag) ? "ok" : "not_found" } const existing = await this.getVersion(workflowId, versionNumber) - if (!existing) return false + if (!existing) return "not_found" const tags = existing.tags ?? [] if (!tags.includes(tag)) tags.push(tag) - const { error } = await supabase + const { data, error } = await supabase .from("workflow_versions") .update({ tag: serializeTags(tags) }) .eq("workflow_id", workflowId) .eq("version", versionNumber) + .select("id") + .maybeSingle() - return !error + 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 { + async deleteVersion(workflowId: string, versionNumber: number): Promise { const supabase = await getSupabaseServerClient() if (!supabase) { requireSupabaseOrMemoryFallback() - return this.deleteVersionInMemory(workflowId, versionNumber) + return this.deleteVersionInMemory(workflowId, versionNumber) ? "ok" : "not_found" } const existing = await this.getVersion(workflowId, versionNumber) - if (!existing) return false + if (!existing) return "not_found" - const { error } = await supabase + const { data, error } = await supabase .from("workflow_versions") .delete() .eq("workflow_id", workflowId) .eq("version", versionNumber) + .select("id") + .maybeSingle() - return !error + if (error) { + throw new Error( + `Failed to delete workflow version workflowId=${workflowId} version=${versionNumber}: ${error.message}`, + ) + } + if (!data) return "forbidden" + return "ok" } private async getVersionsFromDb(workflowId: string): Promise { diff --git a/tests/components/builder-canvas.test.tsx b/tests/components/builder-canvas.test.tsx index 87f4aff..76248f4 100644 --- a/tests/components/builder-canvas.test.tsx +++ b/tests/components/builder-canvas.test.tsx @@ -64,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") @@ -73,11 +72,11 @@ 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(), }), + useViewport: () => ({ x: 0, y: 0, zoom: 1 }), useNodesState: (initial: unknown[]) => [initial, mockSetNodes, vi.fn()], useEdgesState: (initial: unknown[]) => [initial, mockSetEdges, vi.fn()], ReactFlow: () =>
, @@ -413,6 +412,55 @@ describe("BuilderCanvas", () => { 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[] = [ 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 index 939df49..3909e0a 100644 --- a/tests/lib/execution-duration.test.ts +++ b/tests/lib/execution-duration.test.ts @@ -17,6 +17,10 @@ describe("toValidDate", () => { 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() }) @@ -44,4 +48,12 @@ describe("durationMs", () => { 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/version-store.test.ts b/tests/lib/version-store.test.ts index 51dbfd5..8a77720 100644 --- a/tests/lib/version-store.test.ts +++ b/tests/lib/version-store.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import type { Workflow, WorkflowNode } from "@/lib/workflow-types" const { mockFrom, mockGetSupabase } = vi.hoisted(() => ({ @@ -34,7 +34,7 @@ function makeNode(id: string): WorkflowNode { } /** Chainable thenable mock for Supabase query builders */ -function createMockChain(result: { data: T; error: Error | null }) { +function createMockChain(result: { data: T; error: { message: string; code?: string } | null }) { const thenable = { then: (resolve: (v: typeof result) => void) => resolve(result), catch: () => thenable, @@ -43,6 +43,7 @@ function createMockChain(result: { data: T; error: Error | null }) { eq: () => thenable, order: () => thenable, single: () => thenable, + maybeSingle: () => thenable, insert: () => thenable, update: () => thenable, delete: () => thenable, @@ -57,6 +58,10 @@ describe("versionStore (memory fallback)", () => { 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")] }) @@ -84,14 +89,14 @@ describe("versionStore (memory fallback)", () => { 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(true) + 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(true) + 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") }) @@ -125,6 +130,10 @@ describe("versionStore (Supabase persistence)", () => { mockGetSupabase.mockResolvedValue({ from: mockFrom }) }) + afterEach(() => { + vi.unstubAllEnvs() + }) + it("inserts into workflow_versions when Supabase is available", async () => { const row = { id: "ver-1", @@ -152,6 +161,35 @@ describe("versionStore (Supabase persistence)", () => { 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 = [ { @@ -185,4 +223,58 @@ describe("versionStore (Supabase persistence)", () => { 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 index 4a28e3d..197bd8c 100644 --- a/tests/lib/workflow-to-reactflow.test.ts +++ b/tests/lib/workflow-to-reactflow.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest" import { + connectionIdsAlongNodePath, connectionIdsTouchingNode, workflowConnectionsToEdges, } from "@/lib/builder/workflow-to-reactflow" @@ -31,6 +32,16 @@ describe("connectionIdsTouchingNode", () => { }) }) +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, {