mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
7653e6bfdb
## Summary
- Replace Next.js App Router with React Router v7.13.0 framework mode (Vite-based), eliminating the large `next` dependency from the web, CLI, and workflow metapackages
- Serve the web UI in-process from the CLI via Express instead of spawning `next start` as a child process
- Switch RPC transport from JSON to CBOR to preserve binary data types across the wire
- Replace `nuqs` URL state management with React Router's `useSearchParams`
- Replace Next.js server actions with an RPC resource route (`/api/rpc`) and a thin CBOR-based client
## Motivation
The `next` package is ~300MB installed and was the single largest dependency in the monorepo. It also required spawning a separate child process from the CLI to run the o11y web server, adding complexity around process lifecycle management, port readiness polling, and environment variable forwarding.
With React Router framework mode, the web package builds to a standard Express-compatible server bundle that the CLI can import and serve directly in its own process.
## What changed
**Framework swap (`@workflow/web`):**
- `next.config.ts` / `postcss.config.mjs` → `react-router.config.ts` / `vite.config.ts`
- `src/` directory → `app/` directory (React Router convention)
- `src/app/layout.tsx` + `layout-client.tsx` → `app/root.tsx`
- `src/app/page.tsx` → `app/routes/home.tsx`
- `src/app/run/[runId]/page.tsx` → `app/routes/run-detail.tsx`
- Path alias `@/` → `~/`
- Removed all `'use client'` / `'use server'` directives
**Data transport:**
- Server actions → RPC resource route at `/api/rpc` with CBOR encoding
- CBOR preserves `Uint8Array` and other binary types natively (no base64 overhead)
- Stream reading → dedicated `/api/stream/:streamId` resource route
**URL state:**
- `nuqs` (`useQueryState`) → `useSearchParams` from `react-router`
**Fonts:**
- `next/font/google` → Geist `.woff2` files referenced directly from `node_modules/geist` via `@font-face` in CSS
**CLI integration (`@workflow/cli`):**
- `import('@workflow/web/server').then(m => m.startServer(port))`
- No child process, no readiness polling, no cleanup handlers
**Radix UI compatibility:**
- `onSubmit` preventDefault on `AlertDialogContent` and `SheetContent` to prevent Radix's internal `<form method="dialog">` from triggering React Router route actions
- Catch-all action on root route for any stray POSTs
## Dependencies removed
- `next`, `swr`, `nuqs`, `@tailwindcss/postcss`
## Dependencies added
- `react-router` / `@react-router/dev` / `@react-router/node` / `@react-router/express` (all `7.13.0`)
- `express`, `vite`, `@tailwindcss/vite`, `cbor-x`, `isbot`, `cross-env`
- `geist` (devDep)
512 lines
14 KiB
TypeScript
512 lines
14 KiB
TypeScript
import { analyzeEvents } from '@workflow/web-shared';
|
|
import type { Event, WorkflowRunStatus } from '@workflow/world';
|
|
import {
|
|
AlarmClockOff,
|
|
MoreHorizontal,
|
|
RotateCw,
|
|
XCircle,
|
|
Zap,
|
|
} from 'lucide-react';
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '~/components/ui/dropdown-menu';
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from '~/components/ui/tooltip';
|
|
import {
|
|
cancelRun,
|
|
recreateRun,
|
|
reenqueueRun,
|
|
wakeUpRun,
|
|
} from '~/lib/workflow-api-client';
|
|
import type { EnvMap } from '~/lib/types';
|
|
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 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('Active sleeps cancelled', {
|
|
description: `Cancelled ${result.stoppedCount} active sleep${result.stoppedCount > 1 ? 's' : ''} and resumed the run.`,
|
|
});
|
|
} else {
|
|
toast.info('No active sleeps', {
|
|
description: 'There were no active sleep calls to cancel.',
|
|
});
|
|
}
|
|
callbacks?.onSuccess?.();
|
|
} catch (err) {
|
|
toast.error('Failed to cancel sleeps', {
|
|
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,
|
|
// Handlers
|
|
handleReplay,
|
|
handleReenqueue,
|
|
handleWakeUp,
|
|
handleCancel,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Shared Tooltip Content
|
|
// ============================================================================
|
|
|
|
function CancelSleepsTooltipContent({
|
|
hasPendingSleeps,
|
|
}: {
|
|
hasPendingSleeps: boolean;
|
|
}) {
|
|
if (!hasPendingSleeps) {
|
|
return <>No active sleep calls to cancel.</>;
|
|
}
|
|
return (
|
|
<>
|
|
Cancel any active <code>sleep</code> calls and resume the run immediately.
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ReenqueueTooltipContent() {
|
|
return (
|
|
<>
|
|
Re-enqueue the workflow orchestration layer. Use this if the workflow
|
|
appears stuck with no active steps. This is a no-op for healthy workflows.
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Dropdown Menu Items (for runs-table)
|
|
// ============================================================================
|
|
|
|
export interface RunActionsDropdownItemsProps extends RunActionsBaseProps {
|
|
/** Stop click event propagation (useful in table rows) */
|
|
stopPropagation?: boolean;
|
|
}
|
|
|
|
export function RunActionsDropdownItems({
|
|
env,
|
|
runId,
|
|
runStatus,
|
|
events,
|
|
eventsLoading,
|
|
callbacks,
|
|
stopPropagation = false,
|
|
}: RunActionsDropdownItemsProps) {
|
|
const {
|
|
rerunning,
|
|
reenqueuing,
|
|
wakingUp,
|
|
cancelling,
|
|
hasPendingSleeps,
|
|
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 onCancelSleeps = (e: React.MouseEvent) => {
|
|
if (stopPropagation) e.stopPropagation();
|
|
handleWakeUp();
|
|
};
|
|
|
|
const onCancel = (e: React.MouseEvent) => {
|
|
if (stopPropagation) e.stopPropagation();
|
|
handleCancel();
|
|
};
|
|
|
|
const isRunActive = runStatus === 'pending' || runStatus === 'running';
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenuItem onClick={onReplay} disabled={rerunning}>
|
|
<RotateCw className="h-4 w-4 mr-2" />
|
|
{rerunning ? 'Replaying...' : 'Replay Run'}
|
|
</DropdownMenuItem>
|
|
|
|
{/* Re-enqueue - always shown */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuItem
|
|
onClick={onReenqueue}
|
|
disabled={eventsLoading || reenqueuing}
|
|
>
|
|
<Zap className="h-4 w-4 mr-2" />
|
|
{reenqueuing ? 'Re-enqueuing...' : 'Re-enqueue'}
|
|
</DropdownMenuItem>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left" className="max-w-xs">
|
|
<ReenqueueTooltipContent />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{/* Cancel Active Sleeps - disabled if no active sleeps */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuItem
|
|
onClick={onCancelSleeps}
|
|
disabled={eventsLoading || !hasPendingSleeps || wakingUp}
|
|
>
|
|
<AlarmClockOff className="h-4 w-4 mr-2" />
|
|
{wakingUp ? 'Cancelling sleeps...' : 'Cancel Active Sleeps'}
|
|
</DropdownMenuItem>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left" className="max-w-xs">
|
|
<CancelSleepsTooltipContent hasPendingSleeps={hasPendingSleeps} />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<DropdownMenuItem
|
|
onClick={onCancel}
|
|
disabled={!isRunActive || cancelling}
|
|
>
|
|
<XCircle className="h-4 w-4 mr-2" />
|
|
{cancelling ? 'Cancelling...' : 'Cancel'}
|
|
</DropdownMenuItem>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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;
|
|
}
|
|
|
|
export function RunActionsButtons({
|
|
env,
|
|
runId,
|
|
runStatus,
|
|
events,
|
|
eventsLoading,
|
|
loading,
|
|
callbacks,
|
|
onCancelClick,
|
|
onRerunClick,
|
|
}: RunActionsButtonsProps) {
|
|
const {
|
|
reenqueuing,
|
|
wakingUp,
|
|
hasPendingSleeps,
|
|
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'
|
|
: '';
|
|
|
|
// Cancel button logic
|
|
const cancelDisabledReason =
|
|
runStatus === 'completed'
|
|
? 'Run has already completed'
|
|
: runStatus === 'failed'
|
|
? 'Run has already failed'
|
|
: runStatus === 'cancelled'
|
|
? 'Run has already been cancelled'
|
|
: '';
|
|
|
|
return (
|
|
<>
|
|
{/* Rerun Button */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onRerunClick}
|
|
disabled={!canRerun}
|
|
>
|
|
<RotateCw className="h-4 w-4" />
|
|
Replay
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{rerunDisabledReason ? (
|
|
<p>{rerunDisabledReason}</p>
|
|
) : (
|
|
<p>
|
|
This will start a new copy of the current run using the same
|
|
deployment, environment, and inputs. It will not affect the
|
|
current run.
|
|
</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{/* Cancel Button */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onCancelClick}
|
|
disabled={!canCancel}
|
|
>
|
|
<XCircle className="h-4 w-4" />
|
|
Cancel
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{cancelDisabledReason ? (
|
|
<p>{cancelDisabledReason}</p>
|
|
) : (
|
|
<p>Cancel the workflow run</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{/* More Actions Menu */}
|
|
<DropdownMenu>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent>More actions</TooltipContent>
|
|
</Tooltip>
|
|
<DropdownMenuContent align="end">
|
|
{/* Re-enqueue - always shown */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuItem
|
|
onClick={handleReenqueue}
|
|
disabled={loading || eventsLoading || reenqueuing}
|
|
>
|
|
<Zap className="h-4 w-4 mr-2" />
|
|
{reenqueuing ? 'Re-enqueuing...' : 'Re-enqueue'}
|
|
</DropdownMenuItem>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left" className="max-w-xs">
|
|
<ReenqueueTooltipContent />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{/* Cancel Active Sleeps - disabled if no active sleeps */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuItem
|
|
onClick={handleWakeUp}
|
|
disabled={
|
|
loading || eventsLoading || !hasPendingSleeps || wakingUp
|
|
}
|
|
>
|
|
<AlarmClockOff className="h-4 w-4 mr-2" />
|
|
{wakingUp ? 'Cancelling sleeps...' : 'Cancel Active Sleeps'}
|
|
</DropdownMenuItem>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left" className="max-w-xs">
|
|
<CancelSleepsTooltipContent hasPendingSleeps={hasPendingSleeps} />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Hook for lazy loading events (alternative approach)
|
|
// ============================================================================
|
|
|
|
export function useLazyEvents(
|
|
fetchEvents: () => Promise<Event[]>,
|
|
enabled: boolean
|
|
) {
|
|
const [events, setEvents] = useState<Event[] | undefined>(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 };
|
|
}
|