From 2dc28fc380ec54fd09fa5b1fb31885d6b5ff64c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Oct 2025 21:04:48 +0000 Subject: [PATCH] Refactor: Integrate useworkflow.dev and Hono router Replaces custom API proxy logic with Hono router and useworkflow.dev SDK. Updates dependencies and component logic to align with the new SDK. Co-authored-by: atfpom4h --- apps/workflow/package.json | 4 +- .../api/workflow/definitions/[id]/route.ts | 37 +- .../src/app/api/workflow/definitions/route.ts | 33 +- .../src/app/api/workflow/execute/route.ts | 40 +- .../api/workflow/executions-stream/route.ts | 38 +- .../app/api/workflow/executions/[id]/route.ts | 37 +- .../workflow/executions/[id]/stream/route.ts | 41 +- .../src/app/api/workflow/executions/route.ts | 31 +- .../workflow/src/app/executions/[id]/page.tsx | 694 +++++++---------- apps/workflow/src/app/executions/page.tsx | 713 ++++++++---------- .../src/components/ExecutionGraph.tsx | 387 ++++------ .../src/components/SpanGanttChart.tsx | 2 +- apps/workflow/src/components/TraceViewer.tsx | 2 +- .../src/components/WorkflowVisualizer.tsx | 247 +++--- apps/workflow/src/lib/config.ts | 19 +- apps/workflow/src/lib/useworkflow/client.ts | 259 +++++++ apps/workflow/src/lib/useworkflow/types.ts | 219 ++++++ .../workflow/src/server/useworkflow-router.ts | 143 ++++ docs/guide/schema-driven-hook-chain.md | 39 + pnpm-lock.yaml | 12 +- 20 files changed, 1570 insertions(+), 1427 deletions(-) create mode 100644 apps/workflow/src/lib/useworkflow/client.ts create mode 100644 apps/workflow/src/lib/useworkflow/types.ts create mode 100644 apps/workflow/src/server/useworkflow-router.ts create mode 100644 docs/guide/schema-driven-hook-chain.md diff --git a/apps/workflow/package.json b/apps/workflow/package.json index bac5847..e260f19 100644 --- a/apps/workflow/package.json +++ b/apps/workflow/package.json @@ -14,9 +14,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", - "@c4c/core": "workspace:*", - "@c4c/workflow": "workspace:*", - "@c4c/workflow-react": "workspace:*", + "hono": "^4.9.12", "@xyflow/react": "^12.8.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/workflow/src/app/api/workflow/definitions/[id]/route.ts b/apps/workflow/src/app/api/workflow/definitions/[id]/route.ts index ed92432..74381cf 100644 --- a/apps/workflow/src/app/api/workflow/definitions/[id]/route.ts +++ b/apps/workflow/src/app/api/workflow/definitions/[id]/route.ts @@ -1,36 +1,3 @@ -/** - * API Route: GET /api/workflow/definitions/[id] - * Proxies request to backend server - */ +import { workflowRouter } from "@/server/useworkflow-router"; -import { NextResponse } from "next/server"; -import { config } from "@/lib/config"; - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id } = await params; - - // Proxy request to backend server - const response = await fetch(`${config.apiBase}/workflow/definitions/${id}`, { - cache: "no-store", - }); - - const data = await response.json(); - - if (!response.ok) { - return NextResponse.json(data, { status: response.status }); - } - - // Backend returns { definition }, we want to return just the definition - return NextResponse.json(data.definition || data); - } catch (error) { - console.error("Failed to get workflow definition:", error); - return NextResponse.json( - { error: "Failed to get workflow definition" }, - { status: 500 } - ); - } -} +export const GET = (request: Request) => workflowRouter.fetch(request); diff --git a/apps/workflow/src/app/api/workflow/definitions/route.ts b/apps/workflow/src/app/api/workflow/definitions/route.ts index e07ba19..74381cf 100644 --- a/apps/workflow/src/app/api/workflow/definitions/route.ts +++ b/apps/workflow/src/app/api/workflow/definitions/route.ts @@ -1,32 +1,3 @@ -/** - * API Route: GET /api/workflow/definitions - * Proxies request to backend server - */ +import { workflowRouter } from "@/server/useworkflow-router"; -import { NextResponse } from "next/server"; -import { config } from "@/lib/config"; - -export async function GET() { - try { - // Proxy request to backend server - const response = await fetch(`${config.apiBase}/workflow/definitions`, { - cache: "no-store", - }); - - const data = await response.json(); - - if (!response.ok) { - return NextResponse.json(data, { status: response.status }); - } - - // Transform data to match expected format - const workflows = data.workflows || []; - return NextResponse.json(workflows); - } catch (error) { - console.error("Failed to get workflow definitions:", error); - return NextResponse.json( - { error: "Failed to get workflow definitions" }, - { status: 500 } - ); - } -} +export const GET = (request: Request) => workflowRouter.fetch(request); diff --git a/apps/workflow/src/app/api/workflow/execute/route.ts b/apps/workflow/src/app/api/workflow/execute/route.ts index e02c30f..d15d01c 100644 --- a/apps/workflow/src/app/api/workflow/execute/route.ts +++ b/apps/workflow/src/app/api/workflow/execute/route.ts @@ -1,39 +1,3 @@ -/** - * API Route: POST /api/workflow/execute - * Proxies request to backend server - */ +import { workflowRouter } from "@/server/useworkflow-router"; -import { NextResponse } from "next/server"; -import { config } from "@/lib/config"; - -export async function POST(request: Request) { - try { - const body = await request.json(); - - // Proxy request to backend server - const response = await fetch(`${config.apiBase}/workflow/execute`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - const data = await response.json(); - - if (!response.ok) { - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json(data); - } catch (error) { - console.error("Failed to execute workflow:", error); - return NextResponse.json( - { - error: "Failed to execute workflow", - message: error instanceof Error ? error.message : String(error), - }, - { status: 500 } - ); - } -} +export const POST = (request: Request) => workflowRouter.fetch(request); diff --git a/apps/workflow/src/app/api/workflow/executions-stream/route.ts b/apps/workflow/src/app/api/workflow/executions-stream/route.ts index 2e723f3..e8fec94 100644 --- a/apps/workflow/src/app/api/workflow/executions-stream/route.ts +++ b/apps/workflow/src/app/api/workflow/executions-stream/route.ts @@ -1,39 +1,5 @@ -/** - * API Route: GET /api/workflow/executions-stream - * Proxies SSE stream for updates of all executions - */ - -import { config } from "@/lib/config"; +import { workflowRouter } from "@/server/useworkflow-router"; export const dynamic = "force-dynamic"; -export async function GET(request: Request) { - try { - // Proxy SSE stream from backend server - const response = await fetch(`${config.apiBase}/workflow/executions-stream`, { - signal: request.signal, - }); - - if (!response.ok) { - return new Response(JSON.stringify({ error: "Failed to connect to stream" }), { - status: response.status, - headers: { "Content-Type": "application/json" }, - }); - } - - // Forward the SSE stream - return new Response(response.body, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - }, - }); - } catch (error) { - console.error("Failed to proxy SSE stream:", error); - return new Response(JSON.stringify({ error: "Failed to connect to stream" }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); - } -} +export const GET = (request: Request) => workflowRouter.fetch(request); diff --git a/apps/workflow/src/app/api/workflow/executions/[id]/route.ts b/apps/workflow/src/app/api/workflow/executions/[id]/route.ts index 52191e8..e8fec94 100644 --- a/apps/workflow/src/app/api/workflow/executions/[id]/route.ts +++ b/apps/workflow/src/app/api/workflow/executions/[id]/route.ts @@ -1,38 +1,5 @@ -/** - * API Route: GET /api/workflow/executions/[id] - * Proxies request to backend server - */ - -import { NextResponse } from "next/server"; -import { config } from "@/lib/config"; +import { workflowRouter } from "@/server/useworkflow-router"; export const dynamic = "force-dynamic"; -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id } = await params; - - // Proxy request to backend server - const response = await fetch(`${config.apiBase}/workflow/executions/${id}`, { - cache: "no-store", - }); - - const data = await response.json(); - - if (!response.ok) { - return NextResponse.json(data, { status: response.status }); - } - - // Backend returns { execution }, we want to return just the execution - return NextResponse.json(data.execution || data); - } catch (error) { - console.error("Failed to get execution:", error); - return NextResponse.json( - { error: "Failed to get execution" }, - { status: 500 } - ); - } -} +export const GET = (request: Request) => workflowRouter.fetch(request); diff --git a/apps/workflow/src/app/api/workflow/executions/[id]/stream/route.ts b/apps/workflow/src/app/api/workflow/executions/[id]/stream/route.ts index 8a598c5..e8fec94 100644 --- a/apps/workflow/src/app/api/workflow/executions/[id]/stream/route.ts +++ b/apps/workflow/src/app/api/workflow/executions/[id]/stream/route.ts @@ -1,42 +1,5 @@ -/** - * API Route: GET /api/workflow/executions/[id]/stream - * Proxies SSE stream to backend server - */ - -import { config } from "@/lib/config"; +import { workflowRouter } from "@/server/useworkflow-router"; export const dynamic = "force-dynamic"; -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - - try { - const response = await fetch(`${config.apiBase}/workflow/executions/${id}/stream`, { - signal: request.signal, - }); - - if (!response.ok) { - return new Response(JSON.stringify({ error: "Failed to connect to stream" }), { - status: response.status, - headers: { "Content-Type": "application/json" }, - }); - } - - return new Response(response.body, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - }, - }); - } catch (error) { - console.error("Failed to proxy SSE stream:", error); - return new Response(JSON.stringify({ error: "Failed to connect to stream" }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); - } -} +export const GET = (request: Request) => workflowRouter.fetch(request); diff --git a/apps/workflow/src/app/api/workflow/executions/route.ts b/apps/workflow/src/app/api/workflow/executions/route.ts index e2b9728..e8fec94 100644 --- a/apps/workflow/src/app/api/workflow/executions/route.ts +++ b/apps/workflow/src/app/api/workflow/executions/route.ts @@ -1,32 +1,5 @@ -/** - * API Route: GET /api/workflow/executions - * Proxies request to backend server - */ - -import { NextResponse } from "next/server"; -import { config } from "@/lib/config"; +import { workflowRouter } from "@/server/useworkflow-router"; export const dynamic = "force-dynamic"; -export async function GET() { - try { - // Proxy request to backend server - const response = await fetch(`${config.apiBase}/workflow/executions`, { - cache: "no-store", - }); - - const data = await response.json(); - - if (!response.ok) { - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json(data); - } catch (error) { - console.error("Failed to get executions:", error); - return NextResponse.json( - { error: "Failed to get executions" }, - { status: 500 } - ); - } -} +export const GET = (request: Request) => workflowRouter.fetch(request); diff --git a/apps/workflow/src/app/executions/[id]/page.tsx b/apps/workflow/src/app/executions/[id]/page.tsx index 77da178..8bde59a 100644 --- a/apps/workflow/src/app/executions/[id]/page.tsx +++ b/apps/workflow/src/app/executions/[id]/page.tsx @@ -1,450 +1,320 @@ "use client"; /** - * Execution Detail Page - like in n8n - * Shows execution details with graph and node statuses + * Execution detail view aligned with useworkflow.dev SDHC model. + * Shows run status, step chain, telemetry traces, and stream updates. */ -import { useState, useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { CheckCircle2, XCircle, Loader2, ArrowLeft, Clock, AlertTriangle } from "lucide-react"; +import { CheckCircle2, XCircle, Loader2, ArrowLeft, Clock, AlertTriangle, Pause, StopCircle } from "lucide-react"; import ThemeToggle from "@/components/ThemeToggle"; import ExecutionGraph from "@/components/ExecutionGraph"; +import WorkflowVisualizer from "@/components/WorkflowVisualizer"; import TraceViewer from "@/components/TraceViewer"; import SpanGanttChart from "@/components/SpanGanttChart"; +import type { StepExecution, WorkflowDefinition, WorkflowRun } from "@/lib/useworkflow/types"; -interface NodeDetail { - nodeId: string; - status: "pending" | "running" | "completed" | "failed" | "skipped"; - startTime?: string; - endTime?: string; - duration?: number; - input?: Record; - output?: unknown; - error?: { - message: string; - name: string; - }; -} +const EMPTY_RUN: WorkflowRun | null = null; -interface ExecutionDetail { - executionId: string; - workflowId: string; - workflowName: string; - status: "completed" | "failed" | "cancelled" | "running"; - startTime: string; - endTime?: string; - executionTime?: number; - nodesExecuted: string[]; - nodeDetails: Record; - outputs: Record; - error?: { - message: string; - name: string; - stack?: string; - }; - spans?: Array<{ - spanId: string; - traceId: string; - parentSpanId?: string; - name: string; - kind: string; - startTime: number; - endTime: number; - duration: number; - status: { code: "OK" | "ERROR" | "UNSET"; message?: string }; - attributes: Record; - events?: Array<{ - name: string; - timestamp: number; - attributes?: Record; - }>; - }>; -} +export default function ExecutionDetailPage() { + const params = useParams(); + const router = useRouter(); + const executionId = params.id as string; -interface WorkflowDefinition { - id: string; - name: string; - version: string; - startNode: string; - nodes: Array<{ - id: string; - type: string; - procedureName?: string; - next?: string | string[]; - }>; -} + const [run, setRun] = useState(EMPTY_RUN); + const [workflow, setWorkflow] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [selectedStepKey, setSelectedStepKey] = useState(null); -export default function ExecutionDetailPage() { - const params = useParams(); - const router = useRouter(); - const executionId = params.id as string; - - const [execution, setExecution] = useState(null); - const [workflow, setWorkflow] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [selectedNodeId, setSelectedNodeId] = useState(null); + useEffect(() => { + if (!executionId) return; + loadExecution(); + + const eventSource = new EventSource(`/api/workflow/executions/${executionId}/stream`); + eventSource.onmessage = () => { + loadExecution(false); + }; + eventSource.onerror = () => { + console.warn("Execution SSE stream disconnected, attempting silent reload"); + }; + + return () => { + eventSource.close(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [executionId]); + + const loadExecution = async (showSpinner = true) => { + if (showSpinner) setIsLoading(true); + try { + const response = await fetch(`/api/workflow/executions/${executionId}`); + const runData: WorkflowRun = await response.json(); + setRun(runData); - useEffect(() => { - const loadExecution = async () => { - try { - // Load execution details - const execResponse = await fetch(`/api/workflow/executions/${executionId}`); - const execData = await execResponse.json(); - setExecution(execData); - - // Load workflow definition - if (execData.workflowId) { - const wfResponse = await fetch(`/api/workflow/definitions/${execData.workflowId}`); - const wfData = await wfResponse.json(); - setWorkflow(wfData); - } - } catch (error) { - console.error("Failed to load execution:", error); - } finally { - setIsLoading(false); - } - }; - - loadExecution(); - - // Setup SSE for live updates - const eventSource = new EventSource(`/api/workflow/executions/${executionId}/stream`); - - eventSource.addEventListener("node.started", (event) => { - try { - const data = JSON.parse(event.data); - setExecution(prev => { - if (!prev) return prev; - return { - ...prev, - nodeDetails: { - ...prev.nodeDetails, - [data.nodeId]: { - ...prev.nodeDetails[data.nodeId], - nodeId: data.nodeId, - status: "running", - startTime: new Date().toISOString(), - }, - }, - }; - }); - } catch (error) { - console.error("Failed to process node.started event:", error); - } - }); - - eventSource.addEventListener("node.completed", (event) => { - try { - const data = JSON.parse(event.data); - setExecution(prev => { - if (!prev) return prev; - - const nodesExecuted = prev.nodesExecuted.includes(data.nodeId) - ? prev.nodesExecuted - : [...prev.nodesExecuted, data.nodeId]; - - return { - ...prev, - nodesExecuted, - nodeDetails: { - ...prev.nodeDetails, - [data.nodeId]: { - ...prev.nodeDetails[data.nodeId], - nodeId: data.nodeId, - status: "completed", - startTime: prev.nodeDetails[data.nodeId]?.startTime, - endTime: new Date().toISOString(), - output: data.output, - }, - }, - }; - }); - } catch (error) { - console.error("Failed to process node.completed event:", error); - } - }); - - eventSource.addEventListener("workflow.completed", (event) => { - try { - const data = JSON.parse(event.data); - setExecution(prev => { - if (!prev) return prev; - return { - ...prev, - status: "completed", - endTime: new Date().toISOString(), - executionTime: data.executionTime, - nodesExecuted: data.nodesExecuted || prev.nodesExecuted, - }; - }); - eventSource.close(); - } catch (error) { - console.error("Failed to process workflow.completed event:", error); - } - }); - - eventSource.addEventListener("workflow.failed", (event) => { - try { - const data = JSON.parse(event.data); - setExecution(prev => { - if (!prev) return prev; - return { - ...prev, - status: "failed", - endTime: new Date().toISOString(), - executionTime: data.executionTime, - error: data.error, - }; - }); - eventSource.close(); - } catch (error) { - console.error("Failed to process workflow.failed event:", error); - } - }); - - eventSource.onerror = () => { - // SSE will auto-reconnect - }; - - return () => { - eventSource.close(); - }; - }, [executionId]); + if (!workflow || workflow.id !== runData.workflowId) { + const wfResponse = await fetch(`/api/workflow/definitions/${runData.workflowId}`); + const wfData: WorkflowDefinition = await wfResponse.json(); + setWorkflow(wfData); + } - const getStatusIcon = (status: ExecutionDetail["status"]) => { - switch (status) { - case "completed": - return ; - case "failed": - return ; - case "running": - return ; - default: - return ; - } - }; + if (!selectedStepKey && runData.stepExecutions.length > 0) { + setSelectedStepKey(runData.stepExecutions[0].stepKey); + } + } catch (error) { + console.error("Failed to load execution:", error); + } finally { + setIsLoading(false); + } + }; - const formatDuration = (ms?: number) => { - if (!ms) return "-"; - if (ms < 1000) return `${ms}ms`; - if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`; - return `${(ms / 60000).toFixed(2)}m`; - }; + const getStatusIcon = (status: WorkflowRun["status"]) => { + switch (status) { + case "completed": + return ; + case "failed": + return ; + case "running": + return ; + case "waiting": + return ; + case "cancelled": + return ; + default: + return ; + } + }; - const selectedNode = selectedNodeId && execution - ? execution.nodeDetails[selectedNodeId] - : null; + const formatDuration = (ms?: number) => { + if (!ms) return "-"; + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`; + return `${(ms / 60000).toFixed(2)}m`; + }; - if (isLoading) { - return ( -
-
- -
-
- ); - } + const selectedStep: StepExecution | null = useMemo(() => { + if (!run || !selectedStepKey) return null; + return run.stepExecutions.find((step) => step.stepKey === selectedStepKey) ?? null; + }, [run, selectedStepKey]); - if (!execution) { - return ( -
-
- - - Execution not found - - Could not find execution with ID: {executionId} - - - -
-
- ); - } + if (isLoading) { + return ( +
+
+ +
+
+ ); + } - return ( -
-
- {/* Header */} -
-
-
- -
-

- {getStatusIcon(execution.status)} - {execution.workflowName} -

-

- Execution ID: {execution.executionId} -

-
-
- -
-
+ if (!run || !workflow) { + return ( +
+
+ + + Execution not found + + Could not find execution with ID: {executionId} + + + +
+
+ ); + } - {/* Status Bar */} - - -
-
-

Status

- - {execution.status} - -
-
-

Duration

-

- {formatDuration(execution.executionTime)} -

-
-
-

Nodes Executed

-

- {execution.nodesExecuted.length} / {workflow?.nodes.length || 0} -

-
-
-

Started

-

- {new Date(execution.startTime).toLocaleString()} -

-
-
+ const completedSteps = run.stepExecutions.filter((step) => step.status === "completed").length; - {execution.error && ( - - - Error - -

{execution.error.name}

-

{execution.error.message}

- {execution.error.stack && ( -
-											{execution.error.stack}
-										
- )} -
-
- )} -
-
+ return ( +
+
+
+
+
+ +
+

+ {getStatusIcon(run.status)} + {run.workflowName || workflow.name} +

+

+ Run ID: {run.id} +

+
+
+ +
+
- {/* Main Content */} -
- {/* Left: Workflow Graph */} -
- - - - - 📊 Graph - - - 📈 Timeline - - - 🔍 Trace - - + + +
+
+

Status

+ + {run.status} + +
+
+

Duration

+

{formatDuration(run.durationMs)}

+
+
+

Steps Completed

+

+ {completedSteps} / {run.stepExecutions.length} +

+
+
+

Started

+

{new Date(run.startedAt).toLocaleString()}

+
+
- - {workflow && ( - - )} - + {run.error && ( + + + {run.error.name || "Run error"} + {run.error.message} + + )} +
+
- - - +
+
+ + + + + ?? Graph + + + ?? Chain + + + ?? Timeline + + + ?? Trace + + - - - - - -
+ + + - {/* Right: Node Details */} -
- - - - {selectedNode ? "Node Details" : "Select a Node"} - - - - {selectedNode ? ( -
-
-

Node ID

- {selectedNode.nodeId} -
+ + + -
-

Status

- - {selectedNode.status} - -
+ + + - {selectedNode.duration !== undefined && ( -
-

Duration

-

{formatDuration(selectedNode.duration)}

-
- )} + + + + + +
- {selectedNode.output !== undefined && ( -
-

Output

-
-													{typeof selectedNode.output === "string" 
-														? selectedNode.output 
-														: JSON.stringify(selectedNode.output, null, 2)}
-												
-
- )} +
+ + + {selectedStep ? "Step Details" : "Select a Step"} + + + {selectedStep ? ( +
+
+

Step key

+

{selectedStep.stepKey}

+
+
+

Status

+ {selectedStep.status} +
+
+
+

Started

+

{selectedStep.startedAt ? new Date(selectedStep.startedAt).toLocaleString() : "-"}

+
+
+

Finished

+

{selectedStep.finishedAt ? new Date(selectedStep.finishedAt).toLocaleString() : "-"}

+
+
+

Duration

+

{formatDuration(selectedStep.durationMs)}

+
+
+
+ {selectedStep.input && ( +
+

Input

+
+                          {JSON.stringify(selectedStep.input, null, 2)}
+                        
+
+ )} + {selectedStep.output && ( +
+

Output

+
+                          {JSON.stringify(selectedStep.output, null, 2)}
+                        
+
+ )} + {selectedStep.error && ( + + + {selectedStep.error.name || "Step error"} + {selectedStep.error.message} + + )} +
+ ) : ( +

Choose a step from the graph to inspect its payloads.

+ )} + + +
+
- {selectedNode.error && ( - - - {selectedNode.error.name} - {selectedNode.error.message} - - )} -
- ) : ( -

- Click on a node in the graph to see its details -

- )} - - -
-
-
-
- ); + {run.output && ( + + + Run Output + + +
+                {JSON.stringify(run.output, null, 2)}
+              
+
+
+ )} +
+
+ ); } diff --git a/apps/workflow/src/app/executions/page.tsx b/apps/workflow/src/app/executions/page.tsx index c599f70..7785f64 100644 --- a/apps/workflow/src/app/executions/page.tsx +++ b/apps/workflow/src/app/executions/page.tsx @@ -1,8 +1,8 @@ "use client"; /** - * Executions List Page - like in n8n - * Shows history of all workflow executions + * Executions List Page powered by useworkflow.dev + * Shows run history with schema-driven step insights */ import { useState, useEffect } from "react"; @@ -12,398 +12,355 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { CheckCircle2, XCircle, Loader2, Clock, Eye, Play } from "lucide-react"; +import { CheckCircle2, XCircle, Loader2, Clock, Eye, Play, Pause, StopCircle } from "lucide-react"; import ThemeToggle from "@/components/ThemeToggle"; +import type { WorkflowRun, WorkflowStats } from "@/lib/useworkflow/types"; -interface ExecutionRecord { - executionId: string; - workflowId: string; - workflowName: string; - status: "completed" | "failed" | "cancelled" | "running"; - startTime: string; - endTime?: string; - executionTime?: number; - nodesExecuted: string[]; - error?: { - message: string; - name: string; - }; -} - -interface ExecutionStats { - total: number; - completed: number; - failed: number; - running: number; -} +type WorkflowSummary = { + id: string; + name: string; + version: string; + entryStep: string; + stepCount: number; + description?: string; +}; -interface WorkflowDefinition { - id: string; - name: string; - nodeCount: number; -} +const EMPTY_STATS: WorkflowStats = { + total: 0, + running: 0, + waiting: 0, + completed: 0, + failed: 0, + cancelled: 0, +}; export default function ExecutionsPage() { - const router = useRouter(); - const [executions, setExecutions] = useState([]); - const [stats, setStats] = useState({ total: 0, completed: 0, failed: 0, running: 0 }); - const [isLoading, setIsLoading] = useState(true); - const [workflows, setWorkflows] = useState([]); - const [selectedWorkflow, setSelectedWorkflow] = useState(""); - const [isExecuting, setIsExecuting] = useState(false); + const router = useRouter(); + const [runs, setRuns] = useState([]); + const [stats, setStats] = useState(EMPTY_STATS); + const [isLoading, setIsLoading] = useState(true); + const [workflows, setWorkflows] = useState([]); + const [selectedWorkflow, setSelectedWorkflow] = useState(""); + const [isExecuting, setIsExecuting] = useState(false); + + useEffect(() => { + loadExecutions(); + loadWorkflows(); + + const eventSource = new EventSource("/api/workflow/executions-stream"); + const refresh = () => { + loadExecutions(); + }; + + eventSource.onmessage = refresh; + eventSource.onerror = () => { + console.warn("SSE connection error, retry scheduled reload"); + }; + + return () => { + eventSource.close(); + }; + }, []); + + const loadWorkflows = async () => { + try { + const response = await fetch("/api/workflow/definitions"); + const data = await response.json(); + const summaries: WorkflowSummary[] = Array.isArray(data) ? data : []; + setWorkflows(summaries); + if (summaries.length > 0) { + setSelectedWorkflow(summaries[0].id); + } + } catch (error) { + console.error("Failed to load workflows:", error); + } + }; + + const handleExecute = async () => { + if (!selectedWorkflow || isExecuting) return; + + setIsExecuting(true); + try { + const response = await fetch("/api/workflow/execute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workflowId: selectedWorkflow, + input: {}, + }), + }); + + const result = await response.json(); + + if (response.ok) { + await loadExecutions(); + router.push(`/executions/${result.id || result.executionId}`); + } else { + console.error("Failed to execute workflow:", result.error); + } + } catch (error) { + console.error("Failed to execute workflow:", error); + } finally { + setIsExecuting(false); + } + }; + + const loadExecutions = async () => { + try { + const response = await fetch("/api/workflow/executions"); + const data = await response.json(); + setRuns(data.runs || []); + setStats(data.stats || EMPTY_STATS); + } catch (error) { + console.error("Failed to load executions:", error); + } finally { + setIsLoading(false); + } + }; + + const getStatusIcon = (status: WorkflowRun["status"]) => { + switch (status) { + case "completed": + return ; + case "failed": + return ; + case "running": + return ; + case "waiting": + return ; + case "cancelled": + return ; + default: + return ; + } + }; + + const getStatusBadge = (status: WorkflowRun["status"]) => { + const variants: Partial> = { + completed: "default", + failed: "destructive", + running: "outline", + waiting: "secondary", + cancelled: "secondary", + }; - useEffect(() => { - loadExecutions(); - loadWorkflows(); - - // Setup SSE for real-time updates instead of polling - const eventSource = new EventSource("/api/workflow/executions-stream"); - - eventSource.addEventListener("executions.initial", (event) => { - try { - const data = JSON.parse(event.data); - setExecutions(data.executions || []); - setStats(data.stats || { total: 0, completed: 0, failed: 0, running: 0 }); - } catch (error) { - console.error("Failed to process SSE initial event:", error); - } - }); - - eventSource.addEventListener("executions.update", (event) => { - try { - const data = JSON.parse(event.data); - setExecutions(data.executions || []); - setStats(data.stats || { total: 0, completed: 0, failed: 0, running: 0 }); - } catch (error) { - console.error("Failed to process SSE update event:", error); - } - }); - - eventSource.onerror = () => { - console.warn("SSE connection error, will auto-reconnect"); - }; - - return () => { - eventSource.close(); - }; - }, []); + return {status}; + }; - const loadWorkflows = async () => { - try { - const response = await fetch("/api/workflow/definitions"); - const data = await response.json(); - setWorkflows(data); - if (data.length > 0) { - setSelectedWorkflow(data[0].id); - } - } catch (error) { - console.error("Failed to load workflows:", error); - } - }; + const formatDuration = (ms?: number) => { + if (!ms) return "-"; + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`; + return `${(ms / 60000).toFixed(2)}m`; + }; - const handleExecute = async () => { - if (!selectedWorkflow || isExecuting) return; - - setIsExecuting(true); - try { - const response = await fetch("/api/workflow/execute", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - workflowId: selectedWorkflow, - input: {}, - options: { - executionId: `wf_exec_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - }, - }), - }); - - const result = await response.json(); - - if (response.ok) { - // Reload executions to show new one - await loadExecutions(); - // Navigate to execution detail - router.push(`/executions/${result.executionId}`); - } else { - console.error("Failed to execute workflow:", result.error); - } - } catch (error) { - console.error("Failed to execute workflow:", error); - } finally { - setIsExecuting(false); - } - }; + const formatTime = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diff = now.getTime() - date.getTime(); - const loadExecutions = async () => { - try { - const response = await fetch("/api/workflow/executions"); - const data = await response.json(); - setExecutions(data.executions || []); - setStats(data.stats || { total: 0, completed: 0, failed: 0, running: 0 }); - } catch (error) { - console.error("Failed to load executions:", error); - } finally { - setIsLoading(false); - } - }; + if (diff < 60000) return "Just now"; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; - const getStatusIcon = (status: ExecutionRecord["status"]) => { - switch (status) { - case "completed": - return ; - case "failed": - return ; - case "running": - return ; - default: - return ; - } - }; + return date.toLocaleString(); + }; - const getStatusBadge = (status: ExecutionRecord["status"]) => { - const variants = { - completed: "default", - failed: "destructive", - running: "outline", - cancelled: "secondary", - } as const; - - return ( - - {status} - - ); - }; + return ( +
+
+
+
+
+

?? Workflow Executions

+

Monitor and track all workflow executions

+
+ +
+
- const formatDuration = (ms?: number) => { - if (!ms) return "-"; - if (ms < 1000) return `${ms}ms`; - if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`; - return `${(ms / 60000).toFixed(2)}m`; - }; + + +
+
+ + +
+ +
+
+
- const formatTime = (dateString: string) => { - const date = new Date(dateString); - const now = new Date(); - const diff = now.getTime() - date.getTime(); - - if (diff < 60000) return "Just now"; - if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; - if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; - - return date.toLocaleString(); - }; +
+ + +
+
+

Total

+

{stats.total}

+
+ +
+
+
- return ( -
-
- {/* Header */} -
-
-
-

- 📊 Workflow Executions -

-

- Monitor and track all workflow executions -

-
- -
-
+ + +
+
+

Completed

+

{stats.completed}

+
+ +
+
+
- {/* Execute Workflow */} - - -
-
- - -
- -
-
-
+ + +
+
+

Failed

+

{stats.failed}

+
+ +
+
+
- {/* Stats Cards */} -
- - -
-
-

Total

-

{stats.total}

-
- -
-
-
- - - -
-
-

Completed

-

{stats.completed}

-
- -
-
-
- - - -
-
-

Failed

-

{stats.failed}

-
- -
-
-
- - - -
-
-

Running

-

{stats.running}

-
- -
-
-
-
+ + +
+
+

Active

+

{stats.running}

+
+ +
+
+
+
- {/* Executions Table */} - - - Recent Executions - - - {isLoading ? ( -
- -
- ) : executions.length === 0 ? ( -
-

No executions yet

-

Execute a workflow to see it here

-
- ) : ( -
- - - - Status - Workflow - Execution ID - Started - Duration - Nodes - Actions - - - - {executions.map((exec) => ( - router.push(`/executions/${exec.executionId}`)} - > - -
- {getStatusIcon(exec.status)} - {getStatusBadge(exec.status)} -
-
- -
-

{exec.workflowName}

-

- {exec.workflowId} -

-
-
- - {exec.executionId.slice(0, 16)}... - - - {formatTime(exec.startTime)} - - - - {formatDuration(exec.executionTime)} - - - - - {exec.nodesExecuted.length} nodes - - - - - -
- ))} -
-
-
- )} -
-
+ + + Recent Runs + + + {isLoading ? ( +
+ +
+ ) : runs.length === 0 ? ( +
+

No runs yet

+

Execute a workflow to see it here

+
+ ) : ( +
+ + + + Status + Workflow + Run ID + Started + Duration + Steps + Actions + + + + {runs.map((run) => { + const totalSteps = run.stepExecutions.length; + const completedSteps = run.stepExecutions.filter((step) => step.status === "completed").length; - {/* Footer */} -
-

Workflow Execution Monitor - c4c Framework

-
- - - ); + return ( + router.push(`/executions/${run.id}`)} + > + +
+ {getStatusIcon(run.status)} + {getStatusBadge(run.status)} +
+
+ +
+

{run.workflowName || run.workflowId}

+

{run.workflowId}

+
+
+ + {run.id.slice(0, 16)}... + + + {formatTime(run.startedAt)} + + + {formatDuration(run.durationMs)} + + + + {completedSteps}/{totalSteps} steps + + + + + +
+ ); + })} +
+
+
+ )} +
+
+
+
+ ); } diff --git a/apps/workflow/src/components/ExecutionGraph.tsx b/apps/workflow/src/components/ExecutionGraph.tsx index 5e8bcd8..389dd2c 100644 --- a/apps/workflow/src/components/ExecutionGraph.tsx +++ b/apps/workflow/src/components/ExecutionGraph.tsx @@ -7,257 +7,168 @@ import { useCallback, useMemo, useEffect } from "react"; import { - ReactFlow, - Background, - Controls, - type Node, - type Edge, - MarkerType, - useNodesState, - useEdgesState, + ReactFlow, + Background, + Controls, + type Node, + type Edge, + MarkerType, + useNodesState, + useEdgesState, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; +import type { + StepExecution, + WorkflowDefinition, + WorkflowRun, +} from "@/lib/useworkflow/types"; -interface NodeDetail { - nodeId: string; - status: "pending" | "running" | "completed" | "failed" | "skipped"; - startTime?: string; - endTime?: string; - duration?: number; +interface ExecutionGraphProps { + workflow: WorkflowDefinition; + run: WorkflowRun; + onStepClick?: (stepKey: string) => void; } -interface ExecutionDetail { - executionId: string; - status: string; - nodesExecuted: string[]; - nodeDetails: Record; -} +const STATUS_COLORS: Record = { + pending: { bg: "#e5e7eb", border: "#d1d5db", text: "#374151" }, + running: { bg: "#3b82f6", border: "#2563eb", text: "#ffffff" }, + waiting: { bg: "#fbbf24", border: "#d97706", text: "#1f2937" }, + completed: { bg: "#10b981", border: "#059669", text: "#ffffff" }, + failed: { bg: "#ef4444", border: "#dc2626", text: "#ffffff" }, + cancelled: { bg: "#a855f7", border: "#6d28d9", text: "#ffffff" }, + skipped: { bg: "#6b7280", border: "#4b5563", text: "#e5e7eb" }, +}; -interface WorkflowDefinition { - id: string; - name: string; - nodes: Array<{ - id: string; - type: string; - procedureName?: string; - next?: string | string[]; - config?: { - branches?: string[]; - [key: string]: unknown; - }; - }>; -} +export default function ExecutionGraph({ workflow, run, onStepClick }: ExecutionGraphProps) { + const stepMap = useMemo(() => new Map(run.stepExecutions.map((step) => [step.stepKey, step])), [run.stepExecutions]); -interface ExecutionGraphProps { - workflow: WorkflowDefinition; - execution: ExecutionDetail; - onNodeClick?: (nodeId: string) => void; -} + const initialNodes: Node[] = useMemo(() => { + return workflow.steps.map((step, index) => { + const execution = stepMap.get(step.key); + const status = execution?.status ?? "pending"; + const colors = STATUS_COLORS[status]; + const executed = status !== "pending" && status !== "waiting"; + + return { + id: step.key, + type: "default", + position: { + x: 100 + (index % 3) * 300, + y: 100 + Math.floor(index / 3) * 150, + }, + data: { + label: ( +
+
+ 🔗 + {step.title || step.key} +
+
{status}
+ {step.hook && ( +
+ {step.hook} +
+ )} + {execution?.durationMs !== undefined && ( +
+ {execution.durationMs < 1000 + ? `${execution.durationMs}ms` + : `${(execution.durationMs / 1000).toFixed(2)}s`} +
+ )} +
+ ), + }, + style: { + background: colors.bg, + border: `2px solid ${colors.border}`, + borderRadius: "8px", + color: colors.text, + opacity: executed ? 1 : 0.6, + boxShadow: status === "running" ? "0 0 0 3px rgba(59, 130, 246, 0.3)" : undefined, + }, + }; + }); + }, [workflow, stepMap]); + + const initialEdges: Edge[] = useMemo(() => { + const edges: Edge[] = []; + + workflow.steps.forEach((step) => { + step.transitions + ?.filter((transition) => transition.to) + .forEach((transition, index) => { + const target = transition.to as string; + const status = stepMap.get(step.key)?.status ?? "pending"; + const wasTraversed = status === "completed" || status === "running"; -export default function ExecutionGraph({ workflow, execution, onNodeClick }: ExecutionGraphProps) { - // Create nodes with statuses - const initialNodes: Node[] = useMemo(() => { - return workflow.nodes.map((node, index) => { - const nodeDetail = execution.nodeDetails[node.id]; - const status = nodeDetail?.status || "pending"; - - // Determine color by status - const getNodeColor = (status: string) => { - switch (status) { - case "completed": - return { bg: "#10b981", border: "#059669", text: "#ffffff" }; // green - case "failed": - return { bg: "#ef4444", border: "#dc2626", text: "#ffffff" }; // red - case "running": - return { bg: "#3b82f6", border: "#2563eb", text: "#ffffff" }; // blue - case "skipped": - return { bg: "#6b7280", border: "#4b5563", text: "#ffffff" }; // gray - default: - return { bg: "#e5e7eb", border: "#d1d5db", text: "#374151" }; // light gray - } - }; - - const colors = getNodeColor(status); - const isExecuted = execution.nodesExecuted.includes(node.id); - - // Icons for node types - const getNodeIcon = (type: string) => { - switch (type) { - case "trigger": - return "🎯"; - case "procedure": - return "⚙️"; - case "condition": - return "🔀"; - case "parallel": - return "⚡"; - default: - return "📦"; - } - }; + edges.push({ + id: `${step.key}-${target}-${index}`, + source: step.key, + target, + type: "smoothstep", + animated: wasTraversed, + style: { + stroke: wasTraversed ? "#10b981" : "#d1d5db", + strokeWidth: wasTraversed ? 2 : 1, + }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: wasTraversed ? "#10b981" : "#d1d5db", + }, + label: transition.label || transition.on || `branch ${index + 1}`, + }); + }); + }); - return { - id: node.id, - type: "default", - position: { - x: 100 + (index % 3) * 300, - y: 100 + Math.floor(index / 3) * 150, - }, - data: { - label: ( -
-
- {getNodeIcon(node.type)} - {node.id} -
-
{node.type}
- {node.procedureName && ( -
- {node.procedureName} -
- )} - {nodeDetail?.duration !== undefined && ( -
- {nodeDetail.duration < 1000 - ? `${nodeDetail.duration}ms` - : `${(nodeDetail.duration / 1000).toFixed(2)}s`} -
- )} -
- ), - }, - style: { - background: colors.bg, - border: `2px solid ${colors.border}`, - borderRadius: "8px", - color: colors.text, - opacity: isExecuted ? 1 : 0.5, - boxShadow: status === "running" ? "0 0 0 3px rgba(59, 130, 246, 0.3)" : undefined, - }, - }; - }); - }, [workflow, execution]); + return edges; + }, [workflow, stepMap]); - // Create edges - const initialEdges: Edge[] = useMemo(() => { - const edges: Edge[] = []; - - workflow.nodes.forEach((node) => { - // Handle regular next transitions - if (node.next) { - const nextNodes = Array.isArray(node.next) ? node.next : [node.next]; - nextNodes.forEach((nextId) => { - const sourceExecuted = execution.nodesExecuted.includes(node.id); - const targetExecuted = execution.nodesExecuted.includes(nextId); - const wasTraversed = sourceExecuted && targetExecuted; - - edges.push({ - id: `${node.id}-${nextId}`, - source: node.id, - target: nextId, - type: "smoothstep", - animated: wasTraversed, - style: { - stroke: wasTraversed ? "#10b981" : "#d1d5db", - strokeWidth: wasTraversed ? 2 : 1, - }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: wasTraversed ? "#10b981" : "#d1d5db", - }, - }); - }); - } - - // Handle parallel nodes - create edges to branches - if (node.type === "parallel" && node.config?.branches) { - const branches = node.config.branches; - branches.forEach((branchId) => { - const sourceExecuted = execution.nodesExecuted.includes(node.id); - const targetExecuted = execution.nodesExecuted.includes(branchId); - const wasTraversed = sourceExecuted && targetExecuted; - - edges.push({ - id: `${node.id}-branch-${branchId}`, - source: node.id, - target: branchId, - type: "smoothstep", - animated: wasTraversed, - style: { - stroke: wasTraversed ? "#10b981" : "#d1d5db", - strokeWidth: wasTraversed ? 2 : 1, - strokeDasharray: "5,5", // Dashed line for parallel branches - }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: wasTraversed ? "#10b981" : "#d1d5db", - }, - }); - }); - } - }); - - return edges; - }, [workflow, execution]); + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + useEffect(() => { + setNodes(initialNodes); + }, [initialNodes, setNodes]); - // Update nodes and edges when execution changes - useEffect(() => { - setNodes(initialNodes); - }, [initialNodes, setNodes]); + useEffect(() => { + setEdges(initialEdges); + }, [initialEdges, setEdges]); - useEffect(() => { - setEdges(initialEdges); - }, [initialEdges, setEdges]); + const handleNodeClick = useCallback( + (_event: React.MouseEvent, node: Node) => { + onStepClick?.(node.id); + }, + [onStepClick], + ); - const handleNodeClick = useCallback( - (_event: React.MouseEvent, node: Node) => { - if (onNodeClick) { - onNodeClick(node.id); - } - }, - [onNodeClick] - ); + return ( +
+ + + + - return ( -
- - - - - - {/* Legend */} -
-
Status Legend
-
-
-
- Completed -
-
-
- Running -
-
-
- Failed -
-
-
- Pending -
-
-
-
- ); + {/* Legend */} +
+
Status Legend
+
+ {Object.entries(STATUS_COLORS).map(([status, colors]) => ( +
+
+ {status} +
+ ))} +
+
+
+ ); } diff --git a/apps/workflow/src/components/SpanGanttChart.tsx b/apps/workflow/src/components/SpanGanttChart.tsx index eb8b415..40a8945 100644 --- a/apps/workflow/src/components/SpanGanttChart.tsx +++ b/apps/workflow/src/components/SpanGanttChart.tsx @@ -6,7 +6,7 @@ */ import { useMemo, useState } from "react"; -import type { TraceSpan } from "@c4c/workflow"; +import type { TraceSpan } from "@/lib/useworkflow/types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; diff --git a/apps/workflow/src/components/TraceViewer.tsx b/apps/workflow/src/components/TraceViewer.tsx index 0941f83..b74dfd4 100644 --- a/apps/workflow/src/components/TraceViewer.tsx +++ b/apps/workflow/src/components/TraceViewer.tsx @@ -4,7 +4,7 @@ * OpenTelemetry Trace Viewer Component */ -import type { TraceSpan } from "@c4c/workflow"; +import type { TraceSpan } from "@/lib/useworkflow/types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; diff --git a/apps/workflow/src/components/WorkflowVisualizer.tsx b/apps/workflow/src/components/WorkflowVisualizer.tsx index bcef3a1..c2b5f85 100644 --- a/apps/workflow/src/components/WorkflowVisualizer.tsx +++ b/apps/workflow/src/components/WorkflowVisualizer.tsx @@ -17,65 +17,91 @@ import { Panel, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import type { WorkflowDefinition, TraceSpan } from "@c4c/workflow"; +import type { + TraceSpan, + WorkflowDefinition, + StepExecution, +} from "@/lib/useworkflow/types"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; interface WorkflowVisualizerProps { workflow: WorkflowDefinition; - executionResult?: { - nodesExecuted: string[]; - spans: TraceSpan[]; - }; + stepExecutions?: StepExecution[]; + spans?: TraceSpan[]; } // Node type colors -const getNodeTypeColor = (type: string) => { - const colors = { - procedure: "#4ade80", // Green - condition: "#fbbf24", // Yellow - parallel: "#818cf8", // Purple - sequential: "#60a5fa", // Blue - } as const; - return colors[type as keyof typeof colors] || "#60a5fa"; +const getStatusColor = (status: StepExecution["status"]) => { + const colors: Record = { + pending: "#e5e7eb", + running: "#3b82f6", + waiting: "#fbbf24", + completed: "#4ade80", + failed: "#ef4444", + cancelled: "#a855f7", + skipped: "#6b7280", + }; + return colors[status] ?? "#e5e7eb"; }; export default function WorkflowVisualizer({ workflow, - executionResult, + stepExecutions, + spans, }: WorkflowVisualizerProps) { + const spanIndex = useMemo(() => { + if (!spans) return new Map(); + const index = new Map(); + for (const span of spans) { + const stepKey = + (span.attributes["step.key"] as string | undefined) || + (span.attributes["node.id"] as string | undefined); + if (stepKey) { + index.set(stepKey, span); + } + } + return index; + }, [spans]); + + const statusByStep = useMemo(() => { + const map = new Map(); + stepExecutions?.forEach((execution) => { + map.set(execution.stepKey, execution.status); + }); + return map; + }, [stepExecutions]); + // Convert workflow nodes to React Flow nodes const initialNodes = useMemo(() => { const nodes: Node[] = []; const nodeMap = new Map(); // Calculate positions based on node hierarchy - workflow.nodes.forEach((node, index) => { - const level = calculateNodeLevel(node.id, workflow, nodeMap); + workflow.steps.forEach((step, index) => { + const level = calculateStepLevel(step.key, workflow, nodeMap); const position = { x: level * 300 + 50, y: index * 150 + 50, }; - const isExecuted = executionResult?.nodesExecuted.includes(node.id); - const nodeSpan = executionResult?.spans.find( - (s) => s.attributes["node.id"] === node.id - ); + const status = statusByStep.get(step.key) ?? "pending"; + const nodeSpan = spanIndex.get(step.key); nodes.push({ - id: node.id, + id: step.key, type: "default", position, data: { label: (
-
{node.id}
+
{step.title || step.key}
- {node.type} - {node.procedureName && ( + {status} + {step.hook && ( <>
- {node.procedureName} + {step.hook} )}
@@ -88,101 +114,49 @@ export default function WorkflowVisualizer({ ), }, style: { - background: isExecuted - ? getNodeTypeColor(node.type) - : "#e5e7eb", + background: getStatusColor(status), border: `2px solid ${nodeSpan && nodeSpan.status.code === "ERROR" ? "#ef4444" : "#1e40af"}`, borderRadius: "8px", padding: "10px", width: 180, - opacity: isExecuted ? 1 : 0.5, - color: isExecuted ? "#ffffff" : "#000000", + opacity: status === "pending" ? 0.6 : 1, + color: status === "pending" ? "#111827" : "#ffffff", }, }); }); return nodes; - }, [workflow, executionResult]); + }, [workflow, statusByStep, spanIndex]); // Convert workflow edges to React Flow edges const initialEdges = useMemo(() => { const edges: Edge[] = []; - workflow.nodes.forEach((node) => { - if (node.next) { - const nextNodes = Array.isArray(node.next) ? node.next : [node.next]; - nextNodes.forEach((nextId, index) => { - edges.push({ - id: `${node.id}-${nextId}`, - source: node.id, - target: nextId, - animated: executionResult?.nodesExecuted.includes(node.id) ?? false, - style: { - stroke: executionResult?.nodesExecuted.includes(node.id) - ? "#4ade80" - : "#94a3b8", - strokeWidth: 2, - }, - label: Array.isArray(node.next) ? `branch ${index + 1}` : undefined, - }); - }); - } - - // Add conditional branches - if (node.type === "condition" && node.config) { - const config = node.config as { trueBranch?: string; falseBranch?: string }; - if (config.trueBranch) { - edges.push({ - id: `${node.id}-true-${config.trueBranch}`, - source: node.id, - target: config.trueBranch, - animated: executionResult?.nodesExecuted.includes(node.id) ?? false, - style: { - stroke: "#22c55e", - strokeWidth: 2, - }, - label: "✓ true", - }); - } - if (config.falseBranch) { - edges.push({ - id: `${node.id}-false-${config.falseBranch}`, - source: node.id, - target: config.falseBranch, - animated: false, - style: { - stroke: "#ef4444", - strokeWidth: 2, - }, - label: "✗ false", - }); - } - } - - // Add parallel branches - if (node.type === "parallel" && node.config) { - const config = node.config as { branches?: string[] }; - if (config.branches) { - config.branches.forEach((branchId: string, index: number) => { + workflow.steps.forEach((step) => { + if (step.transitions) { + step.transitions + .filter((transition) => transition.to) + .forEach((transition, index) => { + const target = transition.to as string; + const status = statusByStep.get(step.key) ?? "pending"; + const animated = status === "completed" || status === "running"; edges.push({ - id: `${node.id}-branch-${branchId}`, - source: node.id, - target: branchId, - animated: - executionResult?.nodesExecuted.includes(node.id) ?? false, + id: `${step.key}-${target}-${index}`, + source: step.key, + target, + animated, style: { - stroke: "#818cf8", - strokeWidth: 2, + stroke: status === "completed" ? "#4ade80" : "#94a3b8", + strokeWidth: status === "completed" ? 2 : 1, }, - label: `parallel ${index + 1}`, + label: transition.label || transition.on || `branch ${index + 1}`, }); }); - } } }); return edges; - }, [workflow, executionResult]); + }, [workflow, statusByStep]); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -197,22 +171,18 @@ export default function WorkflowVisualizer({
{/* Legend */}
-
-
- Procedure -
-
-
- Condition -
-
-
- Parallel -
-
-
- Sequential -
+ {[ + { label: "Completed", color: getStatusColor("completed") }, + { label: "Running", color: getStatusColor("running") }, + { label: "Waiting", color: getStatusColor("waiting") }, + { label: "Pending", color: getStatusColor("pending") }, + { label: "Failed", color: getStatusColor("failed") }, + ].map((item) => ( +
+
+ {item.label} +
+ ))}
@@ -228,8 +198,8 @@ export default function WorkflowVisualizer({ { - const isExecuted = executionResult?.nodesExecuted.includes(node.id); - return isExecuted ? "#4ade80" : "#e5e7eb"; + const status = statusByStep.get(node.id) ?? "pending"; + return getStatusColor(status); }} /> @@ -243,12 +213,12 @@ export default function WorkflowVisualizer({
v{workflow.version} - {workflow.nodes.length} nodes + {workflow.steps.length} steps
- {executionResult && ( + {stepExecutions && ( @@ -256,13 +226,17 @@ export default function WorkflowVisualizer({
- Nodes: - {executionResult.nodesExecuted.length} -
-
- Spans: - {executionResult.spans.length} + Completed: + + {stepExecutions.filter((step) => step.status === "completed").length} +
+ {spans && ( +
+ Spans: + {spans.length} +
+ )}
@@ -273,36 +247,33 @@ export default function WorkflowVisualizer({ ); } -function calculateNodeLevel( - nodeId: string, +function calculateStepLevel( + stepKey: string, workflow: WorkflowDefinition, cache: Map ): number { - if (cache.has(nodeId)) { - return cache.get(nodeId)!; + if (cache.has(stepKey)) { + return cache.get(stepKey)!; } - if (nodeId === workflow.startNode) { - cache.set(nodeId, 0); + if (stepKey === workflow.entryStep) { + cache.set(stepKey, 0); return 0; } - // Find parent nodes - const parents = workflow.nodes.filter((n) => { - if (!n.next) return false; - const nextNodes = Array.isArray(n.next) ? n.next : [n.next]; - return nextNodes.includes(nodeId); - }); + const parents = workflow.steps.filter((step) => + step.transitions?.some((transition) => transition.to === stepKey) + ); if (parents.length === 0) { - cache.set(nodeId, 0); + cache.set(stepKey, 0); return 0; } const maxParentLevel = Math.max( - ...parents.map((p) => calculateNodeLevel(p.id, workflow, cache)) + ...parents.map((parent) => calculateStepLevel(parent.key, workflow, cache)) ); const level = maxParentLevel + 1; - cache.set(nodeId, level); + cache.set(stepKey, level); return level; } diff --git a/apps/workflow/src/lib/config.ts b/apps/workflow/src/lib/config.ts index c14930b..45f3771 100644 --- a/apps/workflow/src/lib/config.ts +++ b/apps/workflow/src/lib/config.ts @@ -1,9 +1,20 @@ /** - * Configuration for the workflow UI - * Gets API base URL from environment variables set by the CLI + * Runtime configuration for the useworkflow.dev integration. */ +const DEFAULT_API_BASE = "http://localhost:8787"; + +const apiBase = + process.env.NEXT_PUBLIC_USEWORKFLOW_API_BASE || + process.env.USEWORKFLOW_API_BASE || + DEFAULT_API_BASE; + +const streamBase = + process.env.NEXT_PUBLIC_USEWORKFLOW_STREAM_BASE || + process.env.USEWORKFLOW_STREAM_BASE || + `${apiBase.replace(/\/$/, "")}/runs`; + export const config = { - apiBase: process.env.NEXT_PUBLIC_C4C_API_BASE || process.env.C4C_API_BASE || "http://localhost:3000", - workflowStreamBase: process.env.NEXT_PUBLIC_C4C_WORKFLOW_STREAM_BASE || `${process.env.NEXT_PUBLIC_C4C_API_BASE || "http://localhost:3000"}/workflow/executions`, + useWorkflowApiBase: apiBase.replace(/\/$/, ""), + useWorkflowStreamBase: streamBase.replace(/\/$/, ""), }; diff --git a/apps/workflow/src/lib/useworkflow/client.ts b/apps/workflow/src/lib/useworkflow/client.ts new file mode 100644 index 0000000..2231ec5 --- /dev/null +++ b/apps/workflow/src/lib/useworkflow/client.ts @@ -0,0 +1,259 @@ +import { z } from "zod"; +import { config } from "@/lib/config"; +import { + RunEvent, + WorkflowDefinition, + WorkflowRun, + WorkflowRunBase, + WorkflowStats, + runListResponseSchema, + schemaDescriptorSchema, + schemaVariantSchema, + stepExecutionSchema, + traceSpanSchema, + workflowDefinitionSchema, + workflowListResponseSchema, + workflowRunBaseSchema, +} from "./types"; + +type FetcherInit = RequestInit & { skipNormalization?: boolean }; + +const JSON_HEADERS = { + "Content-Type": "application/json", + Accept: "application/json", +}; + +async function fetchJson(path: string, init?: FetcherInit): Promise { + const url = path.startsWith("http") + ? path + : `${config.useWorkflowApiBase}${path.startsWith("/") ? "" : "/"}${path}`; + + const response = await fetch(url, { + ...init, + headers: { + ...JSON_HEADERS, + ...init?.headers, + }, + }); + + if (!response.ok) { + let errorBody: unknown; + try { + errorBody = await response.json(); + } catch { + errorBody = await response.text(); + } + + const message = + typeof errorBody === "object" && errorBody && "message" in errorBody + ? String((errorBody as { message: unknown }).message) + : `Request to ${url} failed with status ${response.status}`; + + throw new Error(message, { cause: errorBody }); + } + + if (response.status === 204) { + return undefined as T; + } + + const data = await response.json(); + return data as T; +} + +function normalizeWorkflow(data: unknown): WorkflowDefinition { + // use zod parsing with lax fallback for variant arrays that may be plain records + const parsed = workflowDefinitionSchema.parse( + convertStepSchemaVariants(data), + ); + return parsed; +} + +function convertStepSchemaVariants(value: unknown): unknown { + if (!value || typeof value !== "object") return value; + + if (Array.isArray(value)) { + return value.map(convertStepSchemaVariants); + } + + const record = value as Record; + const entries = Object.entries(record).map(([key, val]) => { + if (key === "schema" && val && typeof val === "object" && !Array.isArray(val)) { + const schemaRecord = val as Record; + if (schemaRecord.variants && Array.isArray(schemaRecord.variants)) { + schemaRecord.variants = schemaRecord.variants.map((variant) => { + if (typeof variant === "string") { + return { value: variant } satisfies z.infer; + } + if (variant && typeof variant === "object") { + return variant; + } + return { value: String(variant) }; + }); + } + return [key, schemaDescriptorSchema.parse(schemaRecord)]; + } + + return [key, convertStepSchemaVariants(val)]; + }); + + return Object.fromEntries(entries); +} + +function normalizeRun(base: WorkflowRunBase): WorkflowRun { + const stepExecutions = base.stepExecutions ?? base.steps ?? []; + + const spans = base.spans?.map((span) => traceSpanSchema.parse(span)); + + return { + id: base.id, + workflowId: base.workflowId, + workflowName: base.workflowName, + status: base.status, + startedAt: base.startedAt, + finishedAt: base.finishedAt, + durationMs: base.durationMs, + output: base.output, + error: base.error, + spans, + stepExecutions: stepExecutions.map((step) => stepExecutionSchema.parse(step)), + }; +} + +function deriveStats(runs: WorkflowRun[]): WorkflowStats { + return runs.reduce( + (acc, run) => { + acc.total += 1; + acc[run.status as keyof WorkflowStats] = + (acc[run.status as keyof WorkflowStats] ?? 0) + 1; + if (run.status === "running" || run.status === "waiting" || run.status === "queued") { + acc.running += 1; + } + if (run.status === "waiting") { + acc.waiting += 1; + } + return acc; + }, + { + total: 0, + running: 0, + waiting: 0, + completed: 0, + failed: 0, + cancelled: 0, + }, + ); +} + +type ListResponse = { items: T[] } | T[]; + +function unwrapList(value: ListResponse): T[] { + if (Array.isArray(value)) { + return value; + } + if (value && typeof value === "object" && Array.isArray(value.items)) { + return value.items; + } + return []; +} + +export async function listWorkflows(): Promise { + const raw = await fetchJson("/workflows"); + + const parsedList = (() => { + try { + // First attempt to parse as envelope { items: [] } + return workflowListResponseSchema.parse(convertStepSchemaVariants(raw)).items; + } catch { + // Fallback to array of definitions + const items = unwrapList(raw as ListResponse); + return items.map((item) => normalizeWorkflow(item)); + } + })(); + + return parsedList.map((wf) => normalizeWorkflow(wf)); +} + +export async function getWorkflow(workflowId: string): Promise { + const raw = await fetchJson(`/workflows/${workflowId}`); + return normalizeWorkflow(raw); +} + +export async function listRuns(): Promise<{ runs: WorkflowRun[]; stats: WorkflowStats }> { + const raw = await fetchJson("/runs?limit=50"); + + let bases: WorkflowRunBase[]; + try { + const parsed = runListResponseSchema.parse(raw); + bases = parsed.items; + } catch { + const items = unwrapList(raw as ListResponse); + bases = items.map((item) => workflowRunBaseSchema.parse(item)); + } + + const runs = bases.map(normalizeRun); + const stats = deriveStats(runs); + return { runs, stats }; +} + +export async function getRun(runId: string): Promise { + const raw = await fetchJson(`/runs/${runId}`); + const base = workflowRunBaseSchema.parse(raw); + return normalizeRun(base); +} + +const startRunPayloadSchema = z.object({ + workflowId: z.string(), + input: z.record(z.unknown()).optional().default({}), + options: z + .object({ + runId: z.string().optional(), + tags: z.array(z.string()).optional(), + metadata: z.record(z.unknown()).optional(), + resumeToken: z.string().optional(), + }) + .optional(), +}); + +export type StartRunPayload = z.infer; + +export async function startRun(payload: StartRunPayload): Promise { + const parsed = startRunPayloadSchema.parse(payload); + + const body = { + input: parsed.input, + options: parsed.options ?? {}, + }; + + const raw = await fetchJson(`/workflows/${parsed.workflowId}/runs`, { + method: "POST", + body: JSON.stringify(body), + }); + + const base = workflowRunBaseSchema.parse(raw); + return normalizeRun(base); +} + +export function getRunsStreamUrl(): string { + return `${config.useWorkflowStreamBase}/stream`; +} + +export function getRunStreamUrl(runId: string): string { + return `${config.useWorkflowStreamBase}/${runId}/stream`; +} + +export function parseRunEvent(data: string): RunEvent | null { + try { + const parsed = JSON.parse(data) as RunEvent; + if (!parsed || typeof parsed !== "object") { + return null; + } + if (!("type" in parsed)) { + return null; + } + return parsed; + } catch (error) { + console.warn("[useworkflow] Failed to parse run event", error); + return null; + } +} + diff --git a/apps/workflow/src/lib/useworkflow/types.ts b/apps/workflow/src/lib/useworkflow/types.ts new file mode 100644 index 0000000..4d07a47 --- /dev/null +++ b/apps/workflow/src/lib/useworkflow/types.ts @@ -0,0 +1,219 @@ +import { z } from "zod"; + +export const traceSpanSchema = z.object({ + spanId: z.string(), + traceId: z.string(), + parentSpanId: z.string().optional(), + name: z.string(), + kind: z.string().default("INTERNAL"), + startTime: z.number(), + endTime: z.number(), + duration: z.number(), + status: z.object({ + code: z.enum(["OK", "ERROR", "UNSET"]), + message: z.string().optional(), + }), + attributes: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])).default({}), + events: z + .array( + z.object({ + name: z.string(), + timestamp: z.number(), + attributes: z.record(z.unknown()).optional(), + }), + ) + .optional(), +}); + +export type TraceSpan = z.infer; + +export const schemaVariantSchema = z.object({ + value: z.string(), + label: z.string().optional(), + description: z.string().optional(), + schema: z.unknown().optional(), +}); + +export const schemaDescriptorSchema = z.object({ + discriminator: z.string().optional(), + description: z.string().optional(), + variants: z.array(schemaVariantSchema).default([]), + input: z.unknown().optional(), + output: z.unknown().optional(), + summary: z + .object({ + input: z.string().optional(), + output: z.string().optional(), + }) + .optional(), +}); + +export type SchemaDescriptor = z.infer; + +export const stepTransitionSchema = z.object({ + on: z.string(), + to: z.string().optional().nullable(), + guard: z.string().optional(), + resume: z.boolean().optional(), + label: z.string().optional(), +}); + +export type StepTransition = z.infer; + +export const workflowStepSchema = z.object({ + key: z.string(), + title: z.string(), + description: z.string().optional(), + hook: z.string(), + schema: schemaDescriptorSchema.optional(), + transitions: z.array(stepTransitionSchema).default([]), + timeoutMs: z.number().optional(), +}); + +export type WorkflowStep = z.infer; + +export const workflowDefinitionSchema = z.object({ + id: z.string(), + name: z.string(), + version: z.string().default("1"), + description: z.string().optional(), + entryStep: z.string(), + steps: z.array(workflowStepSchema), + metadata: z.record(z.unknown()).optional(), +}); + +export type WorkflowDefinition = z.infer; + +export const runErrorSchema = z.object({ + message: z.string(), + name: z.string().optional(), + retryable: z.boolean().optional(), + details: z.unknown().optional(), +}); + +export type RunError = z.infer; + +export const stepExecutionSchema = z.object({ + stepKey: z.string(), + status: z + .enum([ + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "skipped", + ]) + .default("pending"), + startedAt: z.string().optional(), + finishedAt: z.string().optional(), + durationMs: z.number().optional(), + input: z.unknown().optional(), + output: z.unknown().optional(), + checkpoint: z.unknown().optional(), + error: runErrorSchema.optional(), + events: z + .array( + z.object({ + type: z.string(), + at: z.string(), + payload: z.unknown().optional(), + }), + ) + .optional(), +}); + +export type StepExecution = z.infer; + +export const workflowRunBaseSchema = z.object({ + id: z.string(), + workflowId: z.string(), + workflowName: z.string().optional(), + status: z + .enum(["queued", "running", "waiting", "completed", "failed", "cancelled"]) + .default("running"), + startedAt: z.string(), + finishedAt: z.string().optional(), + durationMs: z.number().optional(), + output: z.unknown().optional(), + error: runErrorSchema.optional(), + spans: z.array(traceSpanSchema).optional(), + steps: z.array(stepExecutionSchema).optional(), + stepExecutions: z.array(stepExecutionSchema).optional(), +}); + +export type WorkflowRunBase = z.infer; + +export interface WorkflowRun { + id: string; + workflowId: string; + workflowName?: string; + status: "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled"; + startedAt: string; + finishedAt?: string; + durationMs?: number; + output?: unknown; + error?: RunError; + spans?: TraceSpan[]; + stepExecutions: StepExecution[]; +} + +export const workflowListResponseSchema = z.object({ + items: z.array(workflowDefinitionSchema).default([]), + nextCursor: z.string().optional(), +}); + +export type WorkflowListResponse = z.infer; + +export const runListResponseSchema = z.object({ + items: z.array(workflowRunBaseSchema).default([]), + nextCursor: z.string().optional(), +}); + +export type RunListResponse = z.infer; + +export interface WorkflowStats { + total: number; + running: number; + waiting: number; + completed: number; + failed: number; + cancelled: number; +} + +export type RunEvent = + | { + type: "run.started"; + runId: string; + workflowId: string; + startedAt: string; + } + | { + type: "run.completed" | "run.failed" | "run.cancelled"; + runId: string; + workflowId: string; + finishedAt: string; + durationMs?: number; + } + | { + type: "step.started" | "step.waiting"; + runId: string; + stepKey: string; + timestamp: string; + } + | { + type: "step.completed"; + runId: string; + stepKey: string; + timestamp: string; + output?: unknown; + } + | { + type: "step.failed"; + runId: string; + stepKey: string; + timestamp: string; + error: RunError; + }; + diff --git a/apps/workflow/src/server/useworkflow-router.ts b/apps/workflow/src/server/useworkflow-router.ts new file mode 100644 index 0000000..51e100a --- /dev/null +++ b/apps/workflow/src/server/useworkflow-router.ts @@ -0,0 +1,143 @@ +import { Hono, type Context } from "hono"; +import { ZodError } from "zod"; +import { + getRun, + getRunStreamUrl, + getRunsStreamUrl, + getWorkflow, + listRuns, + listWorkflows, + startRun, +} from "@/lib/useworkflow/client"; + +const app = new Hono(); + +function jsonError(c: Context, error: unknown, fallbackStatus = 500) { + console.error("[workflow-api]", error); + + if (error instanceof ZodError) { + return c.json({ error: "Invalid payload", issues: error.format() }, 400); + } + + if (error instanceof Error) { + const status = typeof (error as { status?: number }).status === "number" + ? (error as { status: number }).status + : fallbackStatus; + + return c.json({ error: error.message }, status); + } + + return c.json({ error: "Unknown error" }, fallbackStatus); +} + +app.get("/api/workflow/definitions", async (c) => { + try { + const workflows = await listWorkflows(); + return c.json( + workflows.map((wf) => ({ + id: wf.id, + name: wf.name, + version: wf.version, + description: wf.description, + entryStep: wf.entryStep, + stepCount: wf.steps.length, + metadata: wf.metadata, + })), + ); + } catch (error) { + return jsonError(c, error); + } +}); + +app.get("/api/workflow/definitions/:id", async (c) => { + try { + const workflow = await getWorkflow(c.req.param("id")); + return c.json(workflow); + } catch (error) { + return jsonError(c, error, 404); + } +}); + +app.post("/api/workflow/execute", async (c) => { + try { + const payload = await c.req.json(); + const workflowId = (payload?.workflowId ?? payload?.id) as string | undefined; + + if (!workflowId) { + return c.json({ error: "workflowId is required" }, 400); + } + + const run = await startRun({ + workflowId, + input: payload?.input ?? {}, + options: payload?.options, + }); + + return c.json(run, 201); + } catch (error) { + return jsonError(c, error, 400); + } +}); + +app.get("/api/workflow/executions", async (c) => { + try { + const { runs, stats } = await listRuns(); + return c.json({ runs, stats }); + } catch (error) { + return jsonError(c, error); + } +}); + +app.get("/api/workflow/executions/:id", async (c) => { + try { + const run = await getRun(c.req.param("id")); + return c.json(run); + } catch (error) { + return jsonError(c, error, 404); + } +}); + +async function proxyStream(request: Request, targetUrl: string): Promise { + const upstream = await fetch(targetUrl, { + headers: request.headers, + signal: request.signal, + }); + + if (!upstream.ok || !upstream.body) { + const body = await upstream.text(); + const message = body || `Failed to proxy stream (${upstream.status})`; + return new Response(JSON.stringify({ error: message }), { + status: upstream.status, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(upstream.body, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} + +app.get("/api/workflow/executions-stream", async (c) => { + try { + return await proxyStream(c.req.raw, getRunsStreamUrl()); + } catch (error) { + return jsonError(c, error); + } +}); + +app.get("/api/workflow/executions/:id/stream", async (c) => { + try { + const runId = c.req.param("id"); + return await proxyStream(c.req.raw, getRunStreamUrl(runId)); + } catch (error) { + return jsonError(c, error); + } +}); + +export const workflowRouter = app; + diff --git a/docs/guide/schema-driven-hook-chain.md b/docs/guide/schema-driven-hook-chain.md new file mode 100644 index 0000000..a3df262 --- /dev/null +++ b/docs/guide/schema-driven-hook-chain.md @@ -0,0 +1,39 @@ +# Schema-Driven Hook Chain (SDHC) + +SDHC ? ??? ?????? ??? ????, ??? ?? ?????? ????????????? ??????? ????? ?????? useworkflow.dev ? Workflow Developer Kit (WDK). ????? ?????? ???????????? ?? ?????????? ?????? WDK, ??????? ????? ??????? ? ???????????? ??????????????????. ???? ? ??????? ???????????? ? ????????????? ??????????. + +## ??? ?????? (? ?????) + +- **Hooks & Webhooks (?????????)** ? ????????? ?????? `pause/resume`, ???????? ???????? ??????? ??????? ? ??, ??? ??? ?????????? ??????? ?????? ? ???????. ?????? ????? ?????????? ????????? SDHC. +- **`defineHook()` (API)** ? ?????????????? ???. ? SDHC ?? ???????? discriminated union Zod-????? ? ?????? ???? (Chain of Responsibility) ?? ?????? union??. ?????? ???????? ??????, ??? ??????? ????? ?? ????. +- **`sleep()` (API)** ? ????????????????? ????????? ??? ??????????? ????????. ??? ????????? ??? ????????, ????? ???? ? ?????????????? ??????? ??????. +- **Workflows & Steps (?????? ??????????)** ? ??? ?????? ?durable?, ??? ????????? ??????????? ? ???????? ???????, ??? ????????????? ????, ????????? ??????? ???????. +- **Starting Workflows ? Streaming updates** ? ??? ????????? ??? ? ????????????? ????????. ????? ??? ????????? ?????? ????? ???? ?????????? ? ??? UI-????????? ???????. +- **`getWritable()` (API)** ? ????? ?????? ???-?????. ??????????? ??? ??? ?????????? ?????? ? ???-??????; ? ???????????? ? ????? ????????????? ????????? ?????????. +- **`createHook()` ? `createWebhook()` (API)** ? ?????????????? ????????? ingress-????????????? ? ?????? HTTP-???????. ?????, ????? ????? ??????? SDHC ? ???????? ??????????? ???????. +- **???????????? (Foundations)** ? ?????????? ? ?????? ????? workflow/step. ???????? ??? ????????? Zod-?????????? ? ???????? ?????? ????? ??????. + +## ??? ??????? SDHC ?? ????? + +- **????-????? WDK** ? ????? ????????? durability: ??????? ???????? ????????, ???????? ? ??????? ? ??? ??? ?????????? ? ???????. +- **???? ?? stateful Slack-?????** ? ?????????? ??????, ??????? ????? ??????????? ?? Telegram/CRM. ????????????? SDHC ??? ??????????????? ??????????. +- **??????????? ??????? (???????????)** ? ????????? RAG, ?????????, ????????? AI. ??? ???? ??? ?????? AI-????? ? ??????-????????? ??????????. + +## ??? ?????? ? hook? + +?????? ?? ????????? API: PR, ??????????? ????? ? `HookOptions/defineHook`, ?????????? ??? ? ????????? ?? ?????? ? ???????? SDHC (?????????/????????????? ?? ???????????). ???????? ??????? ??? ????????? Zod-???? ?? OpenAPI. + +## ???????? ??? ???????? + +1. ?????? ????????? ? ???????: Hooks/Webhooks ? `defineHook` ? `sleep` ? `getWritable` ? Starting/Streaming. +2. ????? ?????????? ???? ?? Slack-???? ? ??????????? workflow-???????: ??? ????????? 100?% ??????, ?? ??????? ?????????? SDHC ??? ??????????, ???????? ? ????????????? ??????. +3. ????????????? ???? Zod-?????: SDHC ????? ?? ???? ????, ??? ?????? ??? ? ??? ??????????? ????????. ????? ? `defineHook` ? ??????? ??????, Zod ? ????????? ? ?????????? ??????-?????. + +## ??????? ???????? ??????? + +- SDHC = ?????????????????? ?????, ?????? ?? ??????? ?????? ?????? ? ????????? ???????????? ??????? ????? ?????????? ???????. +- ??????? ???????? ?? ?????? discriminated union; ???????? ??????????? ?????? (`defineHook`) ? ??????????? ????????? (`sleep`). +- ??????????? ???????? ????????????????? ? ?durable?: ???????? ??????? ???????? ? ????, ? `getWritable()`/???????? ???? ???????????? ???????? ?????. +- ?????????? ???????? ????? `createHook/createWebhook`; ???????????? ? Zod ???????????, ??? ?????? ????? ?????? ??????? ? ?????? ? ?????????? ???????. + +??????????? ???? ??????? ??? ????????: ?? ???????? ?????????? SDHC ? ?????? ? ?????? ???????? ?????? ??????? ???????????? useworkflow.dev. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95e4808..d73ffe5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,15 +85,6 @@ importers: apps/workflow: dependencies: - '@c4c/core': - specifier: workspace:* - version: link:../../packages/core - '@c4c/workflow': - specifier: workspace:* - version: link:../../packages/workflow - '@c4c/workflow-react': - specifier: workspace:* - version: link:../../packages/workflow-react '@radix-ui/react-collapsible': specifier: ^1.1.12 version: 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -118,6 +109,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + hono: + specifier: ^4.9.12 + version: 4.9.12 lucide-react: specifier: ^0.545.0 version: 0.545.0(react@19.2.0)