From 349f154617c688b4cd49a10ff865e3dd2024ba13 Mon Sep 17 00:00:00 2001 From: Alberto Perdomo Date: Thu, 13 Aug 2026 18:17:44 +0100 Subject: [PATCH 01/19] fix(flows): recover orphaned runs and add Stop button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduled flows could get permanently stuck when finalizeRun failed silently — the lease was released but the run stayed in 'running' status. recoverStaleRunningRuns only checked for expired leases (leaseExpiresAt < now), missing the NULL-lease case entirely. The noActiveRun guard then blocked all future claims for that flow. Two backend fixes: - Expand recoverStaleRunningRuns WHERE to also match NULL leases - Don't release the lease in settleFlowRun when finalizeRun fails; let it expire so the existing recovery path handles it Surface the existing cancel API as a Stop button in three places: flow list, run history header, and individual run cards. --- .../flows/flow-run-history-view.tsx | 52 ++++++++++++++---- .../src/components/flows/flow-run-history.tsx | 53 +++++++++++++++---- apps/web/src/components/flows/flows-page.tsx | 35 ++++++++++-- apps/web/src/lib/flows/runner.ts | 11 +++- apps/web/src/lib/services/flow-leases.ts | 5 +- 5 files changed, 132 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/flows/flow-run-history-view.tsx b/apps/web/src/components/flows/flow-run-history-view.tsx index 6f4fc3b26..d45afc2b0 100644 --- a/apps/web/src/components/flows/flow-run-history-view.tsx +++ b/apps/web/src/components/flows/flow-run-history-view.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { Lightning, PencilSimple, SpinnerGap } from '@phosphor-icons/react' +import { Lightning, PencilSimple, SpinnerGap, Stop } from '@phosphor-icons/react' import { FlowRunHistory } from '@/components/flows/flow-run-history' import { Button } from '@/components/ui/button' -import { fetchFlowDetail, runFlowRequest } from '@/lib/flows/client' +import { cancelFlowRunRequest, fetchFlowDetail, runFlowRequest } from '@/lib/flows/client' import { formatConnectorRequirement, getFlowErrorMessage } from '@/lib/flows/errors' import type { FlowDetail } from '@/lib/flows/types' @@ -24,6 +24,7 @@ export function FlowRunHistoryView({ editHref, flowId, slug }: FlowRunHistoryVie const [error, setError] = useState(null) const [actionError, setActionError] = useState(null) const [isRunning, setIsRunning] = useState(false) + const [isCancelling, setIsCancelling] = useState(false) const loadRequestIdRef = useRef(0) const mountedRef = useRef(false) @@ -110,6 +111,25 @@ export function FlowRunHistoryView({ editHref, flowId, slug }: FlowRunHistoryVie } }, [flow, flowId, loadFlow, slug]) + const activeRun = flow?.runs.find((run) => run.status === 'running' || run.status === 'waiting_for_human') ?? null + + const cancelRun = useCallback(async (runId: string) => { + setIsCancelling(true) + setActionError(null) + try { + const result = await cancelFlowRunRequest(slug, runId) + if (!result.ok) { + setActionError(result.error) + return + } + await loadFlow() + } catch { + setActionError('network_error') + } finally { + setIsCancelling(false) + } + }, [loadFlow, slug]) + const resolvedEditHref = editHref ?? `/u/${slug}/flows/${flowId}` return ( @@ -128,14 +148,26 @@ export function FlowRunHistoryView({ editHref, flowId, slug }: FlowRunHistoryVie {flow?.permissions.canEdit ? 'Edit flow' : 'View flow'} - + {activeRun ? ( + + ) : ( + + )} diff --git a/apps/web/src/components/flows/flow-run-history.tsx b/apps/web/src/components/flows/flow-run-history.tsx index 40dce4033..df9b194cd 100644 --- a/apps/web/src/components/flows/flow-run-history.tsx +++ b/apps/web/src/components/flows/flow-run-history.tsx @@ -1,5 +1,6 @@ 'use client' +import { useState } from 'react' import Link from 'next/link' import { ArrowSquareOut, @@ -10,10 +11,14 @@ import { Hourglass, MinusCircle, Prohibit, + SpinnerGap, + Stop, XCircle, } from '@phosphor-icons/react' import { HumanStepResponseCard } from '@/components/flows/human-step-response-card' +import { Button } from '@/components/ui/button' +import { cancelFlowRunRequest } from '@/lib/flows/client' import { formatFlowRunDate } from '@/lib/flows/cron' import { cn } from '@/lib/utils' import type { FlowDetail, FlowRunListItem, FlowRunStepListItem } from '@/lib/flows/types' @@ -185,6 +190,7 @@ function RunCard({ now: Date onRefresh?: () => Promise | void }) { + const [isCancelling, setIsCancelling] = useState(false) const tone = getRunTone(run.status) const startedDate = new Date(run.startedAt) const relative = formatRelativeTime(startedDate, now) @@ -192,6 +198,21 @@ function RunCard({ const duration = formatDuration(run.startedAt, run.finishedAt) const executionUser = run.executionUser ?? flow.owner const canOpenSession = Boolean(run.openCodeSessionId && (!executionUser || executionUser.slug === slug)) + const isActive = run.status === 'running' + const canCancel = isActive && (!executionUser || executionUser.slug === slug) + + async function handleCancel() { + setIsCancelling(true) + try { + const result = await cancelFlowRunRequest(slug, run.id) + if (!result.ok) return + await onRefresh?.() + } catch { + // API enforces permissions; silent on network error + } finally { + setIsCancelling(false) + } + } return (
  • @@ -227,15 +248,29 @@ function RunCard({ ) : null} - {canOpenSession && run.openCodeSessionId ? ( - - Open session - - - ) : null} +
    + {canCancel ? ( + + ) : null} + {canOpenSession && run.openCodeSessionId ? ( + + Open session + + + ) : null} +
    {run.steps.length > 0 ? ( diff --git a/apps/web/src/components/flows/flows-page.tsx b/apps/web/src/components/flows/flows-page.tsx index f1c88cd1d..834cca991 100644 --- a/apps/web/src/components/flows/flows-page.tsx +++ b/apps/web/src/components/flows/flows-page.tsx @@ -4,14 +4,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { ClockCountdown, ClockCounterClockwise, DotsThreeVertical, DownloadSimple, GitBranch, PencilSimple, Play, SpinnerGap, TreeStructure } from '@phosphor-icons/react' +import { ClockCountdown, ClockCounterClockwise, DotsThreeVertical, DownloadSimple, GitBranch, PencilSimple, Play, SpinnerGap, Stop, TreeStructure } from '@phosphor-icons/react' import { DashboardEmptyState } from '@/components/dashboard/dashboard-empty-state' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { fetchFlowList, runFlowRequest } from '@/lib/flows/client' +import { cancelFlowRunRequest, fetchFlowList, runFlowRequest } from '@/lib/flows/client' import { formatFlowRunDate } from '@/lib/flows/cron' import { getFlowErrorMessage } from '@/lib/flows/errors' import type { FlowListItem } from '@/lib/flows/types' @@ -51,6 +51,7 @@ export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hi const [loadError, setLoadError] = useState(null) const [actionError, setActionError] = useState(null) const [runningFlowId, setRunningFlowId] = useState(null) + const [cancellingFlowId, setCancellingFlowId] = useState(null) const loadFlows = useCallback(async () => { setIsLoading(true) @@ -103,6 +104,23 @@ export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hi } }, [getHistoryHref, loadFlows, navigateToHistoryOnRun, router, slug]) + const cancelFlow = useCallback(async (runId: string, flowId: string) => { + setCancellingFlowId(flowId) + setActionError(null) + try { + const result = await cancelFlowRunRequest(slug, runId) + if (!result.ok) { + setActionError(result.error) + return + } + await loadFlows() + } catch { + setActionError('network_error') + } finally { + setCancellingFlowId(null) + } + }, [loadFlows, slug]) + useEffect(() => { let cancelled = false @@ -218,7 +236,18 @@ export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hi - {flow.permissions.canRun ? ( + {flow.permissions.canRun && flow.latestRun && (flow.latestRun.status === 'running' || flow.latestRun.status === 'waiting_for_human') ? ( + + ) : flow.permissions.canRun ? (