Files
vercel__workflow/packages/web/app/lib/flow-graph/manifest-adapter.ts
Nathan Rajlich 7653e6bfdb Migrate @workflow/web from Next.js to React Router v7 (#1005)
## 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)
2026-02-12 16:30:51 -08:00

235 lines
6.4 KiB
TypeScript

/**
* Adapter to convert the new manifest format to the format expected by UI components
* The new manifest has a nested structure and doesn't include node positions,
* so this adapter transforms the data and calculates layout positions.
*/
import type {
GraphEdge,
GraphNode,
RawGraphNode,
RawWorkflowsManifest,
WorkflowGraph,
WorkflowGraphManifest,
} from './workflow-graph-types';
/**
* Layout constants for auto-positioning nodes
*/
const LAYOUT = {
NODE_WIDTH: 220,
NODE_HEIGHT: 100,
HORIZONTAL_SPACING: 280,
VERTICAL_SPACING: 320,
START_X: 250,
START_Y: 50,
};
/**
* Calculates initial positions for nodes using a topological layout
* Nodes are arranged vertically by depth, with parallel nodes spread horizontally
*/
function calculateNodePositions(
rawNodes: RawGraphNode[],
edges: GraphEdge[]
): GraphNode[] {
if (rawNodes.length === 0) return [];
// Build adjacency maps
const outgoing = new Map<string, string[]>();
const incoming = new Map<string, string[]>();
for (const edge of edges) {
const out = outgoing.get(edge.source) || [];
out.push(edge.target);
outgoing.set(edge.source, out);
const inc = incoming.get(edge.target) || [];
inc.push(edge.source);
incoming.set(edge.target, inc);
}
// Find start node
const startNode = rawNodes.find((n) => n.data.nodeKind === 'workflow_start');
if (!startNode) {
// Fallback: just stack nodes vertically
return rawNodes.map((node, idx) => ({
id: node.id,
type: node.type,
data: {
label: node.data.label,
nodeKind: node.data.nodeKind as
| 'workflow_start'
| 'workflow_end'
| 'step'
| 'primitive'
| 'agent'
| 'tool',
stepId: node.data.stepId,
},
metadata: node.metadata,
position: {
x: LAYOUT.START_X,
y: LAYOUT.START_Y + idx * LAYOUT.VERTICAL_SPACING,
},
}));
}
// BFS to assign layers (y-positions based on depth from start)
// Skip back-edges (loops) to avoid infinite loops in the algorithm
const layers = new Map<string, number>();
const visited = new Set<string>();
const queue: string[] = [startNode.id];
layers.set(startNode.id, 0);
while (queue.length > 0) {
const nodeId = queue.shift()!;
// Skip if already fully processed
if (visited.has(nodeId)) continue;
visited.add(nodeId);
const currentLayer = layers.get(nodeId)!;
const targets = outgoing.get(nodeId) || [];
for (const target of targets) {
// Skip self-loops and back-edges to already visited nodes
if (target === nodeId || visited.has(target)) continue;
if (!layers.has(target)) {
layers.set(target, currentLayer + 1);
queue.push(target);
} else {
// Ensure we use the maximum layer for nodes with multiple incoming edges
// This handles join points after parallel execution
const existingLayer = layers.get(target)!;
if (currentLayer + 1 > existingLayer) {
layers.set(target, currentLayer + 1);
// Only re-add if not already in queue
if (!queue.includes(target)) {
queue.push(target);
}
}
}
}
}
// Handle any nodes not reached by BFS (disconnected nodes)
let maxLayer = Math.max(...Array.from(layers.values()), 0);
for (const node of rawNodes) {
if (!layers.has(node.id)) {
maxLayer++;
layers.set(node.id, maxLayer);
}
}
// Group nodes by layer
const nodesByLayer = new Map<number, RawGraphNode[]>();
for (const node of rawNodes) {
const layer = layers.get(node.id) ?? 0;
const layerNodes = nodesByLayer.get(layer) || [];
layerNodes.push(node);
nodesByLayer.set(layer, layerNodes);
}
// Assign positions
const positioned: GraphNode[] = [];
for (const node of rawNodes) {
const layer = layers.get(node.id) ?? 0;
const layerNodes = nodesByLayer.get(layer) || [node];
const indexInLayer = layerNodes.findIndex((n) => n.id === node.id);
const layerWidth = layerNodes.length;
// Center nodes horizontally within their layer
const totalWidth = (layerWidth - 1) * LAYOUT.HORIZONTAL_SPACING;
const startX = LAYOUT.START_X - totalWidth / 2;
positioned.push({
id: node.id,
type: node.type,
data: {
label: node.data.label,
nodeKind: node.data.nodeKind as
| 'workflow_start'
| 'workflow_end'
| 'step'
| 'primitive'
| 'agent'
| 'tool',
stepId: node.data.stepId,
},
metadata: node.metadata,
position: {
x: startX + indexInLayer * LAYOUT.HORIZONTAL_SPACING,
y: LAYOUT.START_Y + layer * LAYOUT.VERTICAL_SPACING,
},
});
}
return positioned;
}
/**
* Converts the new manifest format to the format expected by UI components
*
* New format:
* {
* version: "1.0.0",
* steps: { [filePath]: { [stepName]: { stepId } } },
* workflows: { [filePath]: { [workflowName]: { workflowId, graph: { nodes, edges } } } }
* }
*
* Expected format:
* {
* version: "1.0.0",
* workflows: { [workflowId]: { workflowId, workflowName, filePath, nodes, edges } }
* }
*/
export function adaptManifest(
raw: RawWorkflowsManifest
): WorkflowGraphManifest {
const workflows: Record<string, WorkflowGraph> = {};
console.log('[adaptManifest] Raw manifest version:', raw?.version);
console.log(
'[adaptManifest] Raw workflows keys:',
Object.keys(raw?.workflows || {})
);
if (!raw?.workflows) {
console.log('[adaptManifest] No workflows in manifest, returning empty');
return { version: raw?.version || '1.0.0', workflows: {} };
}
for (const [filePath, workflowsInFile] of Object.entries(raw.workflows)) {
for (const [workflowName, entry] of Object.entries(workflowsInFile)) {
// Calculate positions for nodes since they're not provided
const positionedNodes = calculateNodePositions(
entry.graph.nodes,
entry.graph.edges
);
const workflowGraph: WorkflowGraph = {
workflowId: entry.workflowId,
workflowName: workflowName,
filePath: filePath,
nodes: positionedNodes,
edges: entry.graph.edges,
};
// Use workflowId as the key for lookup
workflows[entry.workflowId] = workflowGraph;
}
}
console.log(
'[adaptManifest] Adapted workflows count:',
Object.keys(workflows).length
);
return {
version: raw.version,
workflows,
};
}