diff --git a/.changeset/fine-moles-sit.md b/.changeset/fine-moles-sit.md new file mode 100644 index 000000000..fcefaf63f --- /dev/null +++ b/.changeset/fine-moles-sit.md @@ -0,0 +1,6 @@ +--- +"@workflow/web-shared": patch +"@workflow/web": patch +--- + +Add buttons to wake up workflow from sleep or scheduling issues diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index 0c5fb409e..86bf7678e 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -23,6 +23,10 @@ import { fetchStreams, readStreamServerAction, recreateRun as recreateRunServerAction, + wakeUpRun as wakeUpRunServerAction, + type StopSleepOptions, + type StopSleepResult, + reenqueueRun as reenqueueRunServerAction, } from './workflow-server-actions'; const MAX_ITEMS = 1000; @@ -1101,6 +1105,35 @@ export async function recreateRun(env: EnvMap, runId: string): Promise { return resultData; } +/** + * Wake up a workflow run by re-enqueuing it + */ +export async function reenqueueRun(env: EnvMap, runId: string): Promise { + const { error } = await unwrapServerActionResult( + reenqueueRunServerAction(env, runId) + ); + if (error) { + throw error; + } +} + +/** + * Wake up a workflow run by interrupting any pending sleep() calls + */ +export async function wakeUpRun( + env: EnvMap, + runId: string, + options?: StopSleepOptions +): Promise { + const { error, result: resultData } = await unwrapServerActionResult( + wakeUpRunServerAction(env, runId, options) + ); + if (error) { + throw error; + } + return resultData; +} + function isServerActionError(value: unknown): value is ServerActionError { return ( typeof value === 'object' && diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index 8f1800f48..94afa7507 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -507,6 +507,132 @@ export async function recreateRun( } } +/** + * Re-enqueue a workflow run. + * + * This re-enqueues the workflow orchestration layer. It's a no-op unless the workflow + * got stuck due to an implementation issue in the World. Useful for debugging custom Worlds. + */ +export async function reenqueueRun( + worldEnv: EnvMap, + runId: string +): Promise> { + try { + const world = getWorldFromEnv({ ...worldEnv }); + const run = await world.runs.get(runId); + const deploymentId = run.deploymentId; + + await world.queue( + `__wkf_workflow_${run.workflowName}`, + { + runId, + }, + { + deploymentId, + } + ); + + return createResponse(undefined); + } catch (error) { + return createServerActionError(error, 'reenqueueRun', { runId }); + } +} + +export interface StopSleepResult { + /** Number of pending sleeps that were stopped */ + stoppedCount: number; +} + +export interface StopSleepOptions { + /** + * Optional list of specific correlation IDs to target. + * If provided, only these sleep calls will be interrupted. + * If not provided, all pending sleep calls will be interrupted. + */ + correlationIds?: string[]; +} + +/** + * Wake up a workflow run by interrupting pending sleep() calls. + * + * This finds wait_created events without matching wait_completed events, + * creates wait_completed events for them, and then re-enqueues the run. + * + * @param worldEnv - Environment configuration for the World + * @param runId - The run ID to wake up + * @param options - Optional settings to narrow down targeting (specific correlation IDs) + */ +export async function wakeUpRun( + worldEnv: EnvMap, + runId: string, + options?: StopSleepOptions +): Promise> { + try { + const world = getWorldFromEnv({ ...worldEnv }); + const run = await world.runs.get(runId); + const deploymentId = run.deploymentId; + + // Fetch all events for the run + const eventsResult = await world.events.list({ + runId, + pagination: { limit: 1000 }, + resolveData: 'none', + }); + + // Find wait_created events without matching wait_completed events + const waitCreatedEvents = eventsResult.data.filter( + (e) => e.eventType === 'wait_created' + ); + const waitCompletedCorrelationIds = new Set( + eventsResult.data + .filter((e) => e.eventType === 'wait_completed') + .map((e) => e.correlationId) + ); + + let pendingWaits = waitCreatedEvents.filter( + (e) => !waitCompletedCorrelationIds.has(e.correlationId) + ); + + // If specific correlation IDs are provided, filter to only those + if (options?.correlationIds && options.correlationIds.length > 0) { + const targetCorrelationIds = new Set(options.correlationIds); + pendingWaits = pendingWaits.filter( + (e) => e.correlationId && targetCorrelationIds.has(e.correlationId) + ); + } + + // Create wait_completed events for each pending wait + for (const waitEvent of pendingWaits) { + if (waitEvent.correlationId) { + await world.events.create(runId, { + eventType: 'wait_completed', + correlationId: waitEvent.correlationId, + }); + } + } + + // Re-enqueue the run to wake it up + if (pendingWaits.length > 0) { + await world.queue( + `__wkf_workflow_${run.workflowName}`, + { + runId, + }, + { + deploymentId, + } + ); + } + + return createResponse({ stoppedCount: pendingWaits.length }); + } catch (error) { + return createServerActionError(error, 'wakeUpRun', { + runId, + correlationIds: options?.correlationIds, + }); + } +} + export async function readStreamServerAction( env: EnvMap, streamId: string, diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 8968b73dd..74aa4f64a 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -2,10 +2,20 @@ export { parseStepName, parseWorkflowName, } from '@workflow/core/parse-name'; - export type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; + export * from './api/workflow-api-client'; export type { EnvMap } from './api/workflow-server-actions'; + +export type { EventAnalysis } from './lib/event-analysis'; +export { + analyzeEvents, + hasPendingHooksFromEvents, + hasPendingSleepsFromEvents, + hasPendingStepsFromEvents, + isTerminalStatus, + shouldShowReenqueueButton, +} from './lib/event-analysis'; export type { StreamStep } from './lib/utils'; export { extractConversation, @@ -13,6 +23,7 @@ export { identifyStreamSteps, isDoStreamStep, } from './lib/utils'; + export { RunTraceView } from './run-trace-view'; export { ConversationView } from './sidebar/conversation-view'; export { StreamViewer } from './stream-viewer'; diff --git a/packages/web-shared/src/lib/event-analysis.ts b/packages/web-shared/src/lib/event-analysis.ts new file mode 100644 index 000000000..0058c0b0f --- /dev/null +++ b/packages/web-shared/src/lib/event-analysis.ts @@ -0,0 +1,241 @@ +/** + * Shared utilities for analyzing workflow events. + * Used by run-actions and trace viewer components. + */ + +import type { Event, WorkflowRunStatus } from '@workflow/world'; + +// Time thresholds for Re-enqueue button visibility +const STEP_ACTIVITY_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes +const STEP_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Result of analyzing events for a workflow run + */ +export interface EventAnalysis { + /** Whether there are pending sleep/wait calls */ + hasPendingSleeps: boolean; + /** Whether there are pending steps (started but not completed/failed) */ + hasPendingSteps: boolean; + /** Whether there are pending hooks (created but not disposed) */ + hasPendingHooks: boolean; + /** Correlation IDs of pending sleeps */ + pendingSleepIds: string[]; + /** Correlation IDs of pending steps */ + pendingStepIds: string[]; + /** Correlation IDs of pending hooks */ + pendingHookIds: string[]; + /** Timestamp of the last step_started or step_retrying event */ + lastStepActivityAt: Date | null; + /** Timestamp of the last step completion (step_completed or step_failed) */ + lastStepCompletionAt: Date | null; +} + +/** + * Analyze events to determine pending sleeps, steps, and hooks. + */ +export function analyzeEvents(events: Event[] | undefined): EventAnalysis { + if (!events || events.length === 0) { + return { + hasPendingSleeps: false, + hasPendingSteps: false, + hasPendingHooks: false, + pendingSleepIds: [], + pendingStepIds: [], + pendingHookIds: [], + lastStepActivityAt: null, + lastStepCompletionAt: null, + }; + } + + // Group events by correlation ID for each type + const waitCreated = new Map(); + const waitCompleted = new Set(); + const stepStarted = new Map(); + const stepCompleted = new Set(); + const hookCreated = new Map(); + const hookDisposed = new Set(); + + let lastStepActivityAt: Date | null = null; + let lastStepCompletionAt: Date | null = null; + + for (const event of events) { + const correlationId = event.correlationId; + if (!correlationId) continue; + + switch (event.eventType) { + // Sleeps/Waits + case 'wait_created': + waitCreated.set(correlationId, event); + break; + case 'wait_completed': + waitCompleted.add(correlationId); + break; + + // Steps + case 'step_started': + stepStarted.set(correlationId, event); + if ( + !lastStepActivityAt || + new Date(event.createdAt) > lastStepActivityAt + ) { + lastStepActivityAt = new Date(event.createdAt); + } + break; + case 'step_retrying': + if ( + !lastStepActivityAt || + new Date(event.createdAt) > lastStepActivityAt + ) { + lastStepActivityAt = new Date(event.createdAt); + } + break; + case 'step_completed': + case 'step_failed': + stepCompleted.add(correlationId); + if ( + !lastStepCompletionAt || + new Date(event.createdAt) > lastStepCompletionAt + ) { + lastStepCompletionAt = new Date(event.createdAt); + } + break; + + // Hooks + case 'hook_created': + hookCreated.set(correlationId, event); + break; + case 'hook_disposed': + hookDisposed.add(correlationId); + break; + } + } + + // Find pending items (created but not completed) + const pendingSleepIds = Array.from(waitCreated.keys()).filter( + (id) => !waitCompleted.has(id) + ); + const pendingStepIds = Array.from(stepStarted.keys()).filter( + (id) => !stepCompleted.has(id) + ); + const pendingHookIds = Array.from(hookCreated.keys()).filter( + (id) => !hookDisposed.has(id) + ); + + return { + hasPendingSleeps: pendingSleepIds.length > 0, + hasPendingSteps: pendingStepIds.length > 0, + hasPendingHooks: pendingHookIds.length > 0, + pendingSleepIds, + pendingStepIds, + pendingHookIds, + lastStepActivityAt, + lastStepCompletionAt, + }; +} + +/** + * Check if a workflow run status is terminal (completed, failed, or cancelled) + */ +export function isTerminalStatus( + status: WorkflowRunStatus | undefined +): boolean { + return ( + status === 'completed' || status === 'failed' || status === 'cancelled' + ); +} + +/** + * Determine if the Re-enqueue button should be shown without the debug flag. + * + * The Re-enqueue button is shown when the workflow appears to be stuck: + * - The workflow is not in a terminal state + * - There are no pending sleeps (which would show the Wake up button instead) + * - There are no pending hooks (which are waiting for external input) + * - Either: + * - The last step_started or step_retrying event was >30 minutes ago, OR + * - There have been no pending steps for >5 minutes (all steps completed/failed) + */ +export function shouldShowReenqueueButton( + events: Event[] | undefined, + status: WorkflowRunStatus | undefined +): boolean { + // Never show if in terminal state + if (isTerminalStatus(status)) { + return false; + } + + const analysis = analyzeEvents(events); + + // Don't show if there are pending sleeps (Wake up button handles this) + if (analysis.hasPendingSleeps) { + return false; + } + + // Don't show if there are pending hooks (waiting for external input) + if (analysis.hasPendingHooks) { + return false; + } + + const now = Date.now(); + + // Check if last step activity was >30 minutes ago + if (analysis.lastStepActivityAt) { + const timeSinceLastActivity = now - analysis.lastStepActivityAt.getTime(); + if (timeSinceLastActivity > STEP_ACTIVITY_TIMEOUT_MS) { + return true; + } + } + + // Check if there are no pending steps and last completion was >5 minutes ago + if (!analysis.hasPendingSteps && analysis.lastStepCompletionAt) { + const timeSinceLastCompletion = + now - analysis.lastStepCompletionAt.getTime(); + if (timeSinceLastCompletion > STEP_IDLE_TIMEOUT_MS) { + return true; + } + } + + // If there's no step activity at all but the run is not terminal, + // and we've been waiting for a while, show the button + if ( + !analysis.lastStepActivityAt && + !analysis.hasPendingSteps && + !analysis.hasPendingSleeps && + !analysis.hasPendingHooks + ) { + // This case handles runs that haven't started any steps yet + // but aren't in a terminal state - they might be stuck + return true; + } + + return false; +} + +/** + * Check if there are pending sleeps from an events list. + * This is a convenience function for backwards compatibility. + */ +export function hasPendingSleepsFromEvents( + events: Event[] | undefined +): boolean { + return analyzeEvents(events).hasPendingSleeps; +} + +/** + * Check if there are pending steps from an events list. + */ +export function hasPendingStepsFromEvents( + events: Event[] | undefined +): boolean { + return analyzeEvents(events).hasPendingSteps; +} + +/** + * Check if there are pending hooks from an events list. + */ +export function hasPendingHooksFromEvents( + events: Event[] | undefined +): boolean { + return analyzeEvents(events).hasPendingHooks; +} diff --git a/packages/web-shared/src/sidebar/workflow-detail-panel.tsx b/packages/web-shared/src/sidebar/workflow-detail-panel.tsx index fac59f1c6..a1472e602 100644 --- a/packages/web-shared/src/sidebar/workflow-detail-panel.tsx +++ b/packages/web-shared/src/sidebar/workflow-detail-panel.tsx @@ -2,9 +2,10 @@ import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; import clsx from 'clsx'; -import { useEffect, useMemo } from 'react'; +import { Zap } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; -import { useWorkflowResourceData } from '../api/workflow-api-client'; +import { useWorkflowResourceData, wakeUpRun } from '../api/workflow-api-client'; import type { EnvMap } from '../api/workflow-server-actions'; import { EventsList } from '../sidebar/events-list'; import { useTraceViewer } from '../trace-viewer'; @@ -25,6 +26,7 @@ export function WorkflowDetailPanel({ }): React.JSX.Element | null { const { state } = useTraceViewer(); const { selected } = state; + const [stoppingSleep, setStoppingSleep] = useState(false); const data = selected?.span.attributes?.data as | Step @@ -54,6 +56,20 @@ export function WorkflowDetailPanel({ return { resource: undefined, resourceId: undefined, runId: undefined }; }, [selected, data]); + // Check if this sleep is still pending (no wait_completed event) + // We include events length to ensure recomputation when new events are added + // (the array reference might not change when events are pushed to it) + const spanEvents = selected?.span.events; + const spanEventsLength = spanEvents?.length ?? 0; + const isSleepPending = useMemo(() => { + void spanEventsLength; // Force dependency on length for reactivity + if (resource !== 'sleep' || !spanEvents) return false; + const hasWaitCompleted = spanEvents.some( + (e) => e.name === 'wait_completed' + ); + return !hasWaitCompleted; + }, [resource, spanEvents, spanEventsLength]); + // Fetch full resource data with events const { data: fetchedData, @@ -74,6 +90,35 @@ export function WorkflowDetailPanel({ } }, [error, resource, selected]); + const handleWakeUp = async () => { + if (stoppingSleep || !resourceId) return; + + try { + setStoppingSleep(true); + const result = await wakeUpRun(env, run.runId, { + correlationIds: [resourceId], + }); + if (result.stoppedCount > 0) { + toast.success('Run woken up', { + description: + 'The sleep call has been interrupted and the run woken up.', + }); + } else { + toast.info('Sleep already completed', { + description: 'This sleep call has already finished.', + }); + } + } catch (err) { + console.error('Failed to wake up run:', err); + toast.error('Failed to wake up run', { + description: + err instanceof Error ? err.message : 'An unknown error occurred', + }); + } finally { + setStoppingSleep(false); + } + }; + if (!selected || !resource || !resourceId) { return null; } @@ -82,6 +127,31 @@ export function WorkflowDetailPanel({ return (
+ {/* Wake up button for pending sleep calls */} + {resource === 'sleep' && isSleepPending && ( +
+ +

+ Interrupt this sleep call and wake up the run. +

+
+ )} + {/* Content display */} ([]); const [isLive, setIsLive] = useState(true); const [error, setError] = useState(null); - const [hasMoreBelow, setHasMoreBelow] = useState(false); const scrollRef = useRef(null); const abortControllerRef = useRef(null); const chunkIdRef = useRef(0); - const checkScrollPosition = useCallback(() => { - if (scrollRef.current) { - const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; - const isAtBottom = scrollHeight - scrollTop - clientHeight < 10; - setHasMoreBelow(!isAtBottom && scrollHeight > clientHeight); - } - }, []); - - // biome-ignore lint/correctness/useExhaustiveDependencies: chunks.length triggers scroll on new chunks useEffect(() => { // Auto-scroll to bottom when new content arrives if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - // Check scroll position after content changes - checkScrollPosition(); - }, [chunks.length, checkScrollPosition]); + }, [chunks.length]); useEffect(() => { let mounted = true; @@ -112,7 +101,7 @@ export function StreamViewer({ env, streamId }: StreamViewerProps) { }, [env, streamId]); return ( -
+
-
-
- {error ? ( -
-
Error reading stream:
-
{error}
-
- ) : chunks.length === 0 ? ( -
+ {error ? ( +
+
Error reading stream:
+
{error}
+
+ ) : chunks.length === 0 ? ( +
+ {isLive ? 'Waiting for stream data...' : 'Stream is empty'} +
+ ) : ( + chunks.map((chunk, index) => ( +
-              {isLive ? 'Waiting for stream data...' : 'Stream is empty'}
-            
- ) : ( - chunks.map((chunk, index) => ( -
-                
-                  
-                    [{index}]
-                  
-                  {chunk.text}
-                
-              
- )) - )} -
- {hasMoreBelow && ( -
+ + + [{index}] + + {chunk.text} + + + )) )}
diff --git a/packages/web/src/components/display-utils/cancel-button.tsx b/packages/web/src/components/display-utils/cancel-button.tsx deleted file mode 100644 index 7fff29cec..000000000 --- a/packages/web/src/components/display-utils/cancel-button.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { XCircle } from 'lucide-react'; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { Button } from '../ui/button'; - -interface CancelButtonProps { - canCancel: boolean; - cancelling: boolean; - cancelDisabledReason: string | null; - onCancel: () => void; -} - -export function CancelButton({ - canCancel, - cancelling, - cancelDisabledReason, - onCancel, -}: CancelButtonProps) { - return ( - - - - - - - {cancelDisabledReason && ( - -

{cancelDisabledReason}

-
- )} -
- ); -} diff --git a/packages/web/src/components/display-utils/rerun-button.tsx b/packages/web/src/components/display-utils/rerun-button.tsx index 361bc1ee4..08b2d7e81 100644 --- a/packages/web/src/components/display-utils/rerun-button.tsx +++ b/packages/web/src/components/display-utils/rerun-button.tsx @@ -30,15 +30,21 @@ export function RerunButton({ disabled={!canRerun || rerunning} > - {rerunning ? 'Re-running...' : 'Re-run'} + {rerunning ? 'Replaying...' : 'Replay'} - {rerunDisabledReason && ( - + + {rerunDisabledReason ? (

{rerunDisabledReason}

-
- )} + ) : ( +

+ This will start a new copy of the current run using the same + deployment, environment, and inputs. It will not affect the current + run. +

+ )} +
); } diff --git a/packages/web/src/components/hooks-table.tsx b/packages/web/src/components/hooks-table.tsx index 2f3f60e55..d134105e0 100644 --- a/packages/web/src/components/hooks-table.tsx +++ b/packages/web/src/components/hooks-table.tsx @@ -212,7 +212,7 @@ export function HooksTable({ {displayText} - +
Showing first 100 invocations. There may be more.
@@ -365,7 +365,7 @@ export function HooksTable({ }} > - Re-run + Replay Run { diff --git a/packages/web/src/components/run-actions.tsx b/packages/web/src/components/run-actions.tsx new file mode 100644 index 000000000..2a2afebae --- /dev/null +++ b/packages/web/src/components/run-actions.tsx @@ -0,0 +1,572 @@ +'use client'; + +import { + analyzeEvents, + cancelRun, + type EnvMap, + type Event, + recreateRun, + reenqueueRun, + shouldShowReenqueueButton, + wakeUpRun, +} from '@workflow/web-shared'; +import type { WorkflowRunStatus } from '@workflow/world'; +import { Loader2, RotateCw, XCircle, Zap } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Button } from './ui/button'; + +// ============================================================================ +// Shared Props and Types +// ============================================================================ + +export interface RunActionCallbacks { + onSuccess?: () => void; + onNavigateToRun?: (runId: string) => void; +} + +export interface RunActionsBaseProps { + env: EnvMap; + runId: string; + runStatus: WorkflowRunStatus | undefined; + events?: Event[]; + eventsLoading?: boolean; + callbacks?: RunActionCallbacks; +} + +// ============================================================================ +// Shared Hook for Run Actions +// ============================================================================ + +interface UseRunActionsOptions { + env: EnvMap; + runId: string; + runStatus: WorkflowRunStatus | undefined; + events?: Event[]; + callbacks?: RunActionCallbacks; +} + +function useRunActions({ + env, + runId, + runStatus, + events, + callbacks, +}: UseRunActionsOptions) { + const [rerunning, setRerunning] = useState(false); + const [reenqueuing, setReenqueuing] = useState(false); + const [wakingUp, setWakingUp] = useState(false); + const [cancelling, setCancelling] = useState(false); + + const eventAnalysis = useMemo(() => analyzeEvents(events), [events]); + const hasPendingSleeps = eventAnalysis.hasPendingSleeps; + + const showReenqueueForStuckWorkflow = useMemo( + () => shouldShowReenqueueButton(events, runStatus), + [events, runStatus] + ); + + const handleReplay = useCallback(async () => { + if (rerunning) return null; + + try { + setRerunning(true); + const newRunId = await recreateRun(env, runId); + toast.success('New run started', { + description: `Run ID: ${newRunId}`, + }); + callbacks?.onSuccess?.(); + callbacks?.onNavigateToRun?.(newRunId); + return newRunId; + } catch (err) { + toast.error('Failed to re-run', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + return null; + } finally { + setRerunning(false); + } + }, [env, runId, rerunning, callbacks]); + + const handleReenqueue = useCallback(async () => { + if (reenqueuing) return; + + try { + setReenqueuing(true); + await reenqueueRun(env, runId); + toast.success('Run re-enqueued', { + description: 'The workflow orchestration layer has been re-enqueued.', + }); + callbacks?.onSuccess?.(); + } catch (err) { + toast.error('Failed to re-enqueue', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } finally { + setReenqueuing(false); + } + }, [env, runId, reenqueuing, callbacks]); + + const handleWakeUp = useCallback(async () => { + if (wakingUp) return; + + try { + setWakingUp(true); + const result = await wakeUpRun(env, runId); + if (result.stoppedCount > 0) { + toast.success('Run woken up', { + description: `Interrupted ${result.stoppedCount} pending sleep${result.stoppedCount > 1 ? 's' : ''} and woke up the run.`, + }); + } else { + toast.info('No pending sleeps', { + description: 'There were no pending sleep calls to interrupt.', + }); + } + callbacks?.onSuccess?.(); + } catch (err) { + toast.error('Failed to wake up', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } finally { + setWakingUp(false); + } + }, [env, runId, wakingUp, callbacks]); + + const handleCancel = useCallback(async () => { + if (cancelling) return; + + const isRunActive = runStatus === 'pending' || runStatus === 'running'; + if (!isRunActive) { + toast.error('Cannot cancel', { + description: 'Only active runs can be cancelled', + }); + return; + } + + try { + setCancelling(true); + await cancelRun(env, runId); + toast.success('Run cancelled'); + callbacks?.onSuccess?.(); + } catch (err) { + toast.error('Failed to cancel', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } finally { + setCancelling(false); + } + }, [env, runId, runStatus, cancelling, callbacks]); + + return { + // State + rerunning, + reenqueuing, + wakingUp, + cancelling, + hasPendingSleeps, + showReenqueueForStuckWorkflow, + // Handlers + handleReplay, + handleReenqueue, + handleWakeUp, + handleCancel, + }; +} + +// ============================================================================ +// Shared Tooltip Content +// ============================================================================ + +function WakeUpTooltipContent() { + return ( + <> + Interrupt any current calls to sleep and wake up the run. + + ); +} + +function ReenqueueTooltipContent({ isStuck }: { isStuck: boolean }) { + if (isStuck) { + return ( + <> + This workflow has no active steps or sleep calls, it maybe be stuck. + Re-enqueue the workflow orchestration layer to resume execution. + + ); + } + return ( + <> + Re-enqueue the workflow orchestration layer. This is a no-op, unless the + workflow got stuck due to an implementation issue in the World. This is + useful for debugging custom Worlds. + + ); +} + +// ============================================================================ +// Dropdown Menu Items (for runs-table) +// ============================================================================ + +export interface RunActionsDropdownItemsProps extends RunActionsBaseProps { + /** Stop click event propagation (useful in table rows) */ + stopPropagation?: boolean; + /** Show debug actions like Re-enqueue (requires debug=1 URL param) */ + showDebugActions?: boolean; +} + +export function RunActionsDropdownItems({ + env, + runId, + runStatus, + events, + eventsLoading, + callbacks, + stopPropagation = false, + showDebugActions = false, +}: RunActionsDropdownItemsProps) { + const { + rerunning, + reenqueuing, + wakingUp, + cancelling, + hasPendingSleeps, + showReenqueueForStuckWorkflow, + handleReplay, + handleReenqueue, + handleWakeUp, + handleCancel, + } = useRunActions({ env, runId, runStatus, events, callbacks }); + + const onReplay = (e: React.MouseEvent) => { + if (stopPropagation) e.stopPropagation(); + handleReplay(); + }; + + const onReenqueue = (e: React.MouseEvent) => { + if (stopPropagation) e.stopPropagation(); + handleReenqueue(); + }; + + const onWakeUp = (e: React.MouseEvent) => { + if (stopPropagation) e.stopPropagation(); + handleWakeUp(); + }; + + const onCancel = (e: React.MouseEvent) => { + if (stopPropagation) e.stopPropagation(); + handleCancel(); + }; + + const isRunActive = runStatus === 'pending' || runStatus === 'running'; + + // Determine which button to show: Wake up, Re-enqueue, or disabled Wake up + const showReenqueue = + !eventsLoading && (showDebugActions || showReenqueueForStuckWorkflow); + + return ( + <> + + + {rerunning ? 'Replaying...' : 'Replay Run'} + + + {/* Wake up / Re-enqueue button - mutually exclusive */} + {eventsLoading ? ( + // Loading state: show Wake up button with spinner + + + Wake up + + ) : showReenqueue ? ( + // Re-enqueue: shown when debug flag or stuck workflow detected + + + + + {reenqueuing ? 'Re-enqueuing...' : 'Re-enqueue'} + + + + + + + ) : ( + // Wake up: enabled if pending sleeps, disabled otherwise + + + + + {wakingUp ? 'Waking up...' : 'Wake up'} + + + + {hasPendingSleeps ? ( + + ) : ( + <>No pending sleep calls to interrupt. + )} + + + )} + + + + {cancelling ? 'Cancelling...' : 'Cancel'} + + + ); +} + +// ============================================================================ +// Buttons (for run-detail-view) +// ============================================================================ + +export interface RunActionsButtonsProps extends RunActionsBaseProps { + loading?: boolean; + /** Called when cancel button is clicked - typically shows a confirmation dialog */ + onCancelClick?: () => void; + /** Called when rerun button is clicked - typically shows a confirmation dialog */ + onRerunClick?: () => void; + /** Show debug actions like Re-enqueue (requires debug=1 URL param) */ + showDebugActions?: boolean; +} + +export function RunActionsButtons({ + env, + runId, + runStatus, + events, + eventsLoading, + loading, + callbacks, + onCancelClick, + onRerunClick, + showDebugActions = false, +}: RunActionsButtonsProps) { + const { + reenqueuing, + wakingUp, + hasPendingSleeps, + showReenqueueForStuckWorkflow, + handleReenqueue, + handleWakeUp, + } = useRunActions({ env, runId, runStatus, events, callbacks }); + + const isRunActive = runStatus === 'pending' || runStatus === 'running'; + const canCancel = isRunActive; + + // Rerun button logic + const canRerun = !loading && !isRunActive; + const rerunDisabledReason = loading + ? 'Loading run data...' + : isRunActive + ? 'Cannot re-run while workflow is still running' + : ''; + + // Re-enqueue button logic + const canReenqueue = !loading && !reenqueuing; + const reenqueueDisabledReason = reenqueuing + ? 'Re-enqueuing workflow...' + : loading + ? 'Loading run data...' + : ''; + + // Wake up button logic + const canWakeUp = !loading && !wakingUp && hasPendingSleeps; + const wakeUpDisabledReason = wakingUp + ? 'Waking up workflow...' + : loading + ? 'Loading run data...' + : !hasPendingSleeps + ? 'No pending sleep calls to interrupt' + : ''; + + // Cancel button logic + const cancelDisabledReason = + runStatus === 'completed' + ? 'Run has already completed' + : runStatus === 'failed' + ? 'Run has already failed' + : runStatus === 'cancelled' + ? 'Run has already been cancelled' + : ''; + + // Determine which button to show: Wake up, Re-enqueue, or disabled Wake up + const showReenqueue = + !eventsLoading && (showDebugActions || showReenqueueForStuckWorkflow); + + return ( + <> + {/* Rerun Button */} + + + + + + + + {rerunDisabledReason ? ( +

{rerunDisabledReason}

+ ) : ( +

+ This will start a new copy of the current run using the same + deployment, environment, and inputs. It will not affect the + current run. +

+ )} +
+
+ + {/* Wake up / Re-enqueue Button - mutually exclusive */} + {eventsLoading ? ( + // Loading state: show Wake up button with spinner + + ) : showReenqueue ? ( + // Re-enqueue: shown when debug flag or stuck workflow detected + + + + + + + + {reenqueueDisabledReason ? ( +

{reenqueueDisabledReason}

+ ) : ( +

+ +

+ )} +
+
+ ) : ( + // Wake up: enabled if pending sleeps, disabled otherwise + + + + + + + + {wakeUpDisabledReason ? ( +

{wakeUpDisabledReason}

+ ) : ( +

+ +

+ )} +
+
+ )} + + {/* Cancel Button */} + + + + + + + + {cancelDisabledReason ? ( +

{cancelDisabledReason}

+ ) : ( +

Cancel the workflow run

+ )} +
+
+ + ); +} + +// ============================================================================ +// Hook for lazy loading events (alternative approach) +// ============================================================================ + +export function useLazyEvents( + fetchEvents: () => Promise, + enabled: boolean +) { + const [events, setEvents] = useState(undefined); + const [loading, setLoading] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + + useEffect(() => { + if (!enabled || hasFetched) return; + + let cancelled = false; + setLoading(true); + + fetchEvents() + .then((result) => { + if (!cancelled) { + setEvents(result); + setHasFetched(true); + } + }) + .catch((err) => { + console.error('Failed to fetch events:', err); + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [enabled, hasFetched, fetchEvents]); + + return { events, loading }; +} diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index 12a690895..c3d2e34b4 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -42,12 +42,11 @@ import { } from '@/components/ui/tooltip'; import { buildUrlWithConfig, worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; -import { CancelButton } from './display-utils/cancel-button'; import { CopyableText } from './display-utils/copyable-text'; import { LiveStatus } from './display-utils/live-status'; import { RelativeTime } from './display-utils/relative-time'; -import { RerunButton } from './display-utils/rerun-button'; import { StatusBadge } from './display-utils/status-badge'; +import { RunActionsButtons } from './run-actions'; import { Skeleton } from './ui/skeleton'; interface RunDetailViewProps { @@ -73,6 +72,7 @@ export function RunDetailView({ // Read tab and streamId from URL search params const activeTab = (searchParams.get('tab') as 'trace' | 'streams') || 'trace'; const selectedStreamId = searchParams.get('streamId'); + const showDebugActions = searchParams.get('debug') === '1'; // Helper to update URL search params const updateSearchParams = useCallback( @@ -237,28 +237,6 @@ export function RunDetailView({ const hasError = false; const errorMessage = ''; - // Determine if cancel is allowed and why - const canCancel = run.status === 'pending' || run.status === 'running'; - const getCancelDisabledReason = () => { - if (cancelling) return 'Cancelling run...'; - if (run.status === 'completed') return 'Run has already completed'; - if (run.status === 'failed') return 'Run has already failed'; - if (run.status === 'cancelled') return 'Run has already been cancelled'; - return ''; - }; - const cancelDisabledReason = getCancelDisabledReason(); - - // Determine if re-run is allowed and why - const isRunActive = run.status === 'pending' || run.status === 'running'; - const canRerun = !loading && !isRunActive && !rerunning; - const getRerunDisabledReason = () => { - if (rerunning) return 'Re-running workflow...'; - if (loading) return 'Loading run data...'; - if (isRunActive) return 'Cannot re-run while workflow is still running'; - return ''; - }; - const rerunDisabledReason = getRerunDisabledReason(); - return ( <> {/* Cancel Confirmation Dialog */} @@ -284,20 +262,20 @@ export function RunDetailView({ - {/* Re-run Confirmation Dialog */} + {/* Replay Run Confirmation Dialog */} - Re-run Workflow? + Replay Run? This can potentially re-run code that is meant to only execute - once. Are you sure you want to re-run the workflow? + once. Are you sure you want to replay the workflow run? Cancel - Re-run Workflow + Replay Run @@ -338,17 +316,17 @@ export function RunDetailView({
{/* Right side controls */} - -
@@ -566,7 +544,11 @@ export function RunDetailView({ {/* Stream viewer */}
{selectedStreamId ? ( - + ) : (
void; + showDebugActions: boolean; +}) { + const [events, setEvents] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + const [run, setRun] = useState(undefined); + const status = run?.status || runStatus; + + useEffect(() => { + setIsLoading(true); + + Promise.all([ + fetchRun(env, runId, 'none'), + fetchEvents(env, runId, { limit: 1000, sortOrder: 'desc' }), + ]) + .then(([runResult, eventsResult]) => { + if (runResult.success) { + setRun(runResult.data); + } + if (eventsResult.success) { + setEvents(eventsResult.data.data); + } + }) + .catch((err: unknown) => { + console.error('Failed to fetch run or events:', err); + }) + .finally(() => { + setIsLoading(false); + }); + }, [env, runId]); + + return ( + + ); +} + +// Wrapper that only renders content when dropdown is open (lazy loading) +function LazyDropdownMenu({ + env, + runId, + runStatus, + onSuccess, + showDebugActions, +}: { + env: EnvMap; + runId: string; + runStatus: WorkflowRunStatus | undefined; + onSuccess: () => void; + showDebugActions: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + + {isOpen && ( + + + + )} + + ); +} interface RunsTableProps { config: WorldConfig; @@ -274,6 +371,7 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { ? (rawStatus as WorkflowRunStatus | 'all') : undefined; const workflowNameFilter = searchParams.get('workflow') as string | 'all'; + const showDebugActions = searchParams.get('debug') === '1'; const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); const [lastRefreshTime, setLastRefreshTime] = useState( () => new Date() @@ -433,73 +531,13 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { )} - - - - - - { - e.stopPropagation(); - try { - const newRunId = await recreateRun( - env, - run.runId - ); - toast.success('New run started', { - description: `Run ID: ${newRunId}`, - }); - reload(); - } catch (err) { - toast.error('Failed to re-run', { - description: - err instanceof Error - ? err.message - : 'Unknown error', - }); - } - }} - > - - Re-run - - { - e.stopPropagation(); - if (run.status !== 'pending') { - toast.error('Cannot cancel', { - description: - 'Only pending runs can be cancelled', - }); - return; - } - try { - await cancelRun(env, run.runId); - toast.success('Run cancelled'); - reload(); - } catch (err) { - toast.error('Failed to cancel', { - description: - err instanceof Error - ? err.message - : 'Unknown error', - }); - } - }} - disabled={run.status !== 'pending'} - > - - Cancel - - - + ))}