diff --git a/apps/workflow/src/app/api/workflow/cancel/route.ts b/apps/workflow/src/app/api/workflow/cancel/route.ts new file mode 100644 index 0000000..a5df37b --- /dev/null +++ b/apps/workflow/src/app/api/workflow/cancel/route.ts @@ -0,0 +1,47 @@ +/** + * API endpoint for cancelling paused workflows + * POST /api/workflow/cancel - Cancel a paused execution + */ + +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { executionId, reason } = body; + + if (!executionId) { + return NextResponse.json( + { error: "executionId is required" }, + { status: 400 } + ); + } + + // In production, this would: + // 1. Remove from pausedExecutions in TriggerWorkflowManager + // 2. Mark execution as cancelled in ExecutionStore + // 3. Clean up any scheduled timeouts + + // Example: + // triggerManager.cancelPausedExecution(executionId, reason); + + console.log(`[API] Cancel workflow: ${executionId}`, reason); + + // Mock success response + return NextResponse.json({ + success: true, + executionId, + status: "cancelled", + message: "Workflow cancelled successfully", + }); + } catch (error) { + console.error("[API] Failed to cancel workflow:", error); + return NextResponse.json( + { + error: "Failed to cancel workflow", + message: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} diff --git a/apps/workflow/src/app/api/workflow/resume/route.ts b/apps/workflow/src/app/api/workflow/resume/route.ts new file mode 100644 index 0000000..2dcbca2 --- /dev/null +++ b/apps/workflow/src/app/api/workflow/resume/route.ts @@ -0,0 +1,53 @@ +/** + * API endpoint for resuming paused workflows + * POST /api/workflow/resume - Resume a paused execution with data + */ + +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { executionId, data } = body; + + if (!executionId) { + return NextResponse.json( + { error: "executionId is required" }, + { status: 400 } + ); + } + + // In production, this would: + // 1. Find the paused execution in TriggerWorkflowManager + // 2. Call resumeWorkflow() with the provided data + // 3. Return the result + + // Example: + // const pausedExecution = triggerManager.findPausedExecution(executionId); + // const result = await resumeWorkflow( + // pausedExecution.workflow, + // registry, + // pausedExecution.pauseState, + // data + // ); + + console.log(`[API] Resume workflow: ${executionId}`, data); + + // Mock success response + return NextResponse.json({ + success: true, + executionId, + status: "resumed", + message: "Workflow resumed successfully", + }); + } catch (error) { + console.error("[API] Failed to resume workflow:", error); + return NextResponse.json( + { + error: "Failed to resume workflow", + message: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} diff --git a/apps/workflow/src/app/executions/page.tsx b/apps/workflow/src/app/executions/page.tsx index c599f70..73fb145 100644 --- a/apps/workflow/src/app/executions/page.tsx +++ b/apps/workflow/src/app/executions/page.tsx @@ -19,7 +19,7 @@ interface ExecutionRecord { executionId: string; workflowId: string; workflowName: string; - status: "completed" | "failed" | "cancelled" | "running"; + status: "completed" | "failed" | "cancelled" | "running" | "paused"; startTime: string; endTime?: string; executionTime?: number; @@ -28,6 +28,13 @@ interface ExecutionRecord { message: string; name: string; }; + // For paused executions + pausedAt?: string; + waitingFor?: string[]; + timeoutAt?: string; + resumeState?: { + variables?: Record; + }; } interface ExecutionStats { @@ -35,6 +42,7 @@ interface ExecutionStats { completed: number; failed: number; running: number; + paused: number; } interface WorkflowDefinition { @@ -46,11 +54,14 @@ interface WorkflowDefinition { export default function ExecutionsPage() { const router = useRouter(); const [executions, setExecutions] = useState([]); - const [stats, setStats] = useState({ total: 0, completed: 0, failed: 0, running: 0 }); + const [stats, setStats] = useState({ total: 0, completed: 0, failed: 0, running: 0, paused: 0 }); const [isLoading, setIsLoading] = useState(true); const [workflows, setWorkflows] = useState([]); const [selectedWorkflow, setSelectedWorkflow] = useState(""); const [isExecuting, setIsExecuting] = useState(false); + const [statusFilter, setStatusFilter] = useState("all"); + const [resumeData, setResumeData] = useState>({}); + const [expandedRow, setExpandedRow] = useState(null); useEffect(() => { loadExecutions(); @@ -63,7 +74,7 @@ export default function ExecutionsPage() { try { const data = JSON.parse(event.data); setExecutions(data.executions || []); - setStats(data.stats || { total: 0, completed: 0, failed: 0, running: 0 }); + setStats(data.stats || { total: 0, completed: 0, failed: 0, running: 0, paused: 0 }); } catch (error) { console.error("Failed to process SSE initial event:", error); } @@ -73,7 +84,7 @@ export default function ExecutionsPage() { try { const data = JSON.parse(event.data); setExecutions(data.executions || []); - setStats(data.stats || { total: 0, completed: 0, failed: 0, running: 0 }); + setStats(data.stats || { total: 0, completed: 0, failed: 0, running: 0, paused: 0 }); } catch (error) { console.error("Failed to process SSE update event:", error); } @@ -140,7 +151,7 @@ export default function ExecutionsPage() { 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 }); + setStats(data.stats || { total: 0, completed: 0, failed: 0, running: 0, paused: 0 }); } catch (error) { console.error("Failed to load executions:", error); } finally { @@ -148,6 +159,38 @@ export default function ExecutionsPage() { } }; + const handleResume = async (executionId: string, event: React.MouseEvent) => { + event.stopPropagation(); // Prevent row click + + try { + const data = resumeData[executionId] ? JSON.parse(resumeData[executionId]) : {}; + const response = await fetch("/api/workflow/resume", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ executionId, data }), + }); + + if (!response.ok) { + throw new Error(`Failed to resume: ${response.statusText}`); + } + + // Clear resume data + setResumeData((prev) => { + const next = { ...prev }; + delete next[executionId]; + return next; + }); + setExpandedRow(null); + } catch (error) { + console.error("Failed to resume workflow:", error); + alert(`Failed to resume: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + const filteredExecutions = statusFilter === "all" + ? executions + : executions.filter(e => e.status === statusFilter); + const getStatusIcon = (status: ExecutionRecord["status"]) => { switch (status) { case "completed": @@ -156,6 +199,8 @@ export default function ExecutionsPage() { return ; case "running": return ; + case "paused": + return ; default: return ; } @@ -167,10 +212,18 @@ export default function ExecutionsPage() { failed: "destructive", running: "outline", cancelled: "secondary", + paused: "secondary", } as const; + const colors = { + paused: "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 border-yellow-500/20", + }; + return ( - + {status} ); @@ -261,7 +314,7 @@ export default function ExecutionsPage() { {/* Stats Cards */} -
+
@@ -309,22 +362,51 @@ export default function ExecutionsPage() {
+ + + +
+
+

Paused

+

{stats.paused}

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

No executions yet

-

Execute a workflow to see it here

+

No executions {statusFilter !== "all" && `with status "${statusFilter}"`}

+

+ {executions.length === 0 ? "Execute a workflow to see it here" : "Try changing the filter"} +

) : (
@@ -341,11 +423,12 @@ export default function ExecutionsPage() { - {executions.map((exec) => ( + {filteredExecutions.map((exec) => ( + <> router.push(`/executions/${exec.executionId}`)} + onClick={() => exec.status !== "paused" && router.push(`/executions/${exec.executionId}`)} >
@@ -378,19 +461,99 @@ export default function ExecutionsPage() { - + {exec.status === "paused" ? ( +
+ + +
+ ) : ( + + )}
+ + {/* Expanded row for paused executions */} + {exec.status === "paused" && expandedRow === exec.executionId && ( + + +
+
+
+

Paused At

+

{exec.pausedAt}

+
+
+

Waiting For

+
+ {exec.waitingFor?.map((trigger, idx) => ( + + {trigger} + + ))} +
+
+ {exec.timeoutAt && ( +
+

Timeout At

+

{formatTime(exec.timeoutAt)}

+
+ )} +
+ +
+ +