From 72874863ba4d4077b185b1fa822ef5df311835be Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 22 Dec 2025 14:18:05 -0800 Subject: [PATCH] Allow starting up web when workflow data directory is not present and auto-detect workflow data directory in web UI after a workflow is run Signed-off-by: Karthik Kalyanaraman --- .changeset/crazy-schools-safety.md | 11 +++ packages/cli/src/lib/inspect/env.ts | 8 +-- packages/cli/src/lib/inspect/web.ts | 11 +++ .../src/api/workflow-server-actions.ts | 67 +++++++++++++++++++ packages/web/src/app/page.tsx | 45 ++++++++++++- packages/web/src/components/hooks-table.tsx | 38 ++++++++++- packages/web/src/components/runs-table.tsx | 37 +++++++++- packages/web/src/lib/config-world.ts | 2 + packages/web/src/lib/config.ts | 1 + 9 files changed, 206 insertions(+), 14 deletions(-) create mode 100644 .changeset/crazy-schools-safety.md diff --git a/.changeset/crazy-schools-safety.md b/.changeset/crazy-schools-safety.md new file mode 100644 index 000000000..ac78caea2 --- /dev/null +++ b/.changeset/crazy-schools-safety.md @@ -0,0 +1,11 @@ +--- +"@workflow/cli": patch +"@workflow/web": patch +"@workflow/web-shared": patch +--- + +Allow starting up web when workflow data directory is not present and auto-detect workflow data directory in web UI after a workflow is run + +- Web UI now automatically detects data directories on load and on refresh +- CLI passes searchDir to web UI for proper directory resolution +- Improved error handling: missing data directory is now a warning instead of an error diff --git a/packages/cli/src/lib/inspect/env.ts b/packages/cli/src/lib/inspect/env.ts index ce3e9c5b7..aeedefd02 100644 --- a/packages/cli/src/lib/inspect/env.ts +++ b/packages/cli/src/lib/inspect/env.ts @@ -110,13 +110,11 @@ export const inferLocalWorldEnvVars = async () => { } } - logger.error( - 'No workflow data directory found. Have you run any workflows yet?' - ); + // No data directory found yet - this is okay, the web UI will check again on refresh + logger.warn('No workflow data directory found yet in your project.'); logger.warn( - `\nCheck whether your data is in any of:\n${possibleWorkflowDataPaths.map((p) => ` ${cwd}/${p}${repoRoot && repoRoot !== cwd ? `\n ${repoRoot}/${p}` : ''}`).join('\n')}\n` + 'Run a workflow to generate data, then refresh the web UI to see it.' ); - throw new Error('No workflow data directory found'); } }; diff --git a/packages/cli/src/lib/inspect/web.ts b/packages/cli/src/lib/inspect/web.ts index 037d86c05..08766e264 100644 --- a/packages/cli/src/lib/inspect/web.ts +++ b/packages/cli/src/lib/inspect/web.ts @@ -6,8 +6,10 @@ import { fileURLToPath } from 'node:url'; import chalk from 'chalk'; import open from 'open'; import { logger } from '../config/log.js'; +import { getWorkflowConfig } from '../config/workflow-config.js'; import { getEnvVars } from './env.js'; import { getVercelDashboardUrl } from './vercel-api.js'; +import { findRepoRoot } from './vercel-link.js'; export const WEB_PACKAGE_NAME = '@workflow/web'; export const getHostUrl = (webPort: number) => `http://localhost:${webPort}`; @@ -296,6 +298,15 @@ export async function launchWebUI( // Fall back to local web UI // Build URL with query params const queryParams = envToQueryParams(resource, id, flags, envVars); + + // Add searchDir for data directory detection (when dataDir is not set) + if (!envVars.WORKFLOW_LOCAL_DATA_DIR) { + const cwd = getWorkflowConfig().workingDir; + const repoRoot = await findRepoRoot(cwd, cwd); + const searchDir = repoRoot && repoRoot !== cwd ? `${cwd}:${repoRoot}` : cwd; + queryParams.set('searchDir', searchDir); + } + const webPort = flags.webPort ?? 3456; const hostUrl = getHostUrl(webPort); const url = `${hostUrl}?${queryParams.toString()}`; diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index ae6814cd1..69522f96e 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -1,5 +1,6 @@ 'use server'; +import { existsSync } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { hydrateResourceIO } from '@workflow/core/observability'; @@ -821,3 +822,69 @@ export async function fetchWorkflowsManifest( workflows: {}, }); } + +/** + * Possible workflow data directory paths to check, in order of preference. + * These match the paths that world-local and the CLI check. + */ +const POSSIBLE_DATA_PATHS = [ + '.next/workflow-data', + '.workflow-data', + 'workflow-data', +]; + +/** + * Result of detecting a workflow data directory + */ +export interface DetectDataDirResult { + /** The found data directory path, or null if not found */ + dataDir: string | null; + /** List of paths that were checked */ + checkedPaths: string[]; +} + +/** + * Detect if a workflow data directory exists. + * + * This is called by the web UI on refresh when no dataDir is configured, + * allowing the UI to automatically pick up data directories that were created + * after the initial startup. + * + * @param searchDir - Colon-separated list of directories to search (from URL param, set by CLI). + * Falls back to process.cwd() if not provided. + * @returns The detected data directory path, or null if not found + */ +export async function detectWorkflowDataDir( + searchDir?: string +): Promise> { + // Parse search directories (colon-separated, from URL param set by CLI) + const dirsToSearch = searchDir + ? searchDir.split(':').filter(Boolean) + : [process.cwd()]; + + const checkedPaths: string[] = []; + + for (const baseDir of dirsToSearch) { + for (const relativePath of POSSIBLE_DATA_PATHS) { + const fullPath = path.join(baseDir, relativePath); + checkedPaths.push(fullPath); + + if (existsSync(fullPath)) { + // Verify it looks like a valid workflow data directory by checking for 'runs' subdirectory + const runsPath = path.join(fullPath, 'runs'); + if (existsSync(runsPath)) { + console.log( + `[detectWorkflowDataDir] Found valid data directory: ${fullPath}` + ); + return createResponse({ dataDir: fullPath, checkedPaths }); + } + } + } + } + + console.log( + '[detectWorkflowDataDir] No data directory found, checked:', + checkedPaths + ); + return createResponse({ dataDir: null, checkedPaths }); +} diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 8ea44cf7c..95b823436 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,31 +1,70 @@ 'use client'; +import { detectWorkflowDataDir } from '@workflow/web-shared/server'; import { AlertCircle } from 'lucide-react'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect } from 'react'; import { ErrorBoundary } from '@/components/error-boundary'; import { HooksTable } from '@/components/hooks-table'; import { RunsTable } from '@/components/runs-table'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { WorkflowsList } from '@/components/workflows-list'; -import { buildUrlWithConfig, useQueryParamConfig } from '@/lib/config'; +import { + buildUrlWithConfig, + useQueryParamConfig, + useUpdateConfigQueryParams, +} from '@/lib/config'; +import { useWorkflowGraphManifest } from '@/lib/flow-graph/use-workflow-graph'; import { useHookIdState, useSidebarState, useTabState, useWorkflowIdState, } from '@/lib/url-state'; -import { useWorkflowGraphManifest } from '@/lib/flow-graph/use-workflow-graph'; export default function Home() { const router = useRouter(); + const searchParams = useSearchParams(); const config = useQueryParamConfig(); + const updateConfig = useUpdateConfigQueryParams(); const [sidebar] = useSidebarState(); const [hookId] = useHookIdState(); const [tab, setTab] = useTabState(); const selectedHookId = sidebar === 'hook' && hookId ? hookId : undefined; + // Check if dataDir was explicitly set via URL params (not using default) + const dataDirFromUrl = searchParams.get('dataDir'); + const isLocalBackend = + !config.backend || + config.backend === 'local' || + config.backend === '@workflow/world-local'; + + // On initial load, try to detect the data directory if not explicitly set + useEffect(() => { + if (!isLocalBackend || dataDirFromUrl) { + return; + } + + const detectDataDir = async () => { + try { + // Pass searchDir from URL params (set by CLI) to search in the right directories + const result = await detectWorkflowDataDir(config.searchDir); + if (result.success && result.data.dataDir) { + // Found a data directory! Update the config + updateConfig({ ...config, dataDir: result.data.dataDir }); + } + } catch (e) { + // Ignore detection errors on initial load + console.debug('Initial data dir detection failed:', e); + } + }; + + detectDataDir(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLocalBackend, dataDirFromUrl]); + // TODO(Karthik): Uncomment after https://github.com/vercel/workflow/pull/455 is merged // Fetch workflow graph manifest // const { diff --git a/packages/web/src/components/hooks-table.tsx b/packages/web/src/components/hooks-table.tsx index 6d4aaf7af..8dd4160cb 100644 --- a/packages/web/src/components/hooks-table.tsx +++ b/packages/web/src/components/hooks-table.tsx @@ -9,7 +9,10 @@ import { useHookActions, useWorkflowHooks, } from '@workflow/web-shared'; -import { fetchEventsByCorrelationId } from '@workflow/web-shared/server'; +import { + detectWorkflowDataDir, + fetchEventsByCorrelationId, +} from '@workflow/web-shared/server'; import type { Event, Hook } from '@workflow/world'; import { AlertCircle, @@ -20,6 +23,7 @@ import { RotateCw, XCircle, } from 'lucide-react'; +import { useSearchParams } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -45,7 +49,7 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; -import { worldConfigToEnvMap } from '@/lib/config'; +import { useUpdateConfigQueryParams, worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; import { CopyableText } from './display-utils/copyable-text'; import { RelativeTime } from './display-utils/relative-time'; @@ -75,11 +79,20 @@ export function HooksTable({ onHookClick, selectedHookId, }: HooksTableProps) { + const searchParams = useSearchParams(); + const updateConfig = useUpdateConfigQueryParams(); const [lastRefreshTime, setLastRefreshTime] = useState( () => new Date() ); const env = useMemo(() => worldConfigToEnvMap(config), [config]); + // Check if dataDir was explicitly set via URL params (not using default) + const dataDirFromUrl = searchParams.get('dataDir'); + const isLocalBackend = + !config.backend || + config.backend === 'local' || + config.backend === '@workflow/world-local'; + const { data, error, @@ -105,8 +118,27 @@ export function HooksTable({ const loading = data.isLoading; const hooks = data.data ?? []; - const onReload = () => { + const onReload = async () => { setLastRefreshTime(() => new Date()); + + // If backend is local and dataDir wasn't explicitly set via URL, + // try to detect the data directory (in case user just ran a workflow) + if (isLocalBackend && !dataDirFromUrl) { + try { + // Pass searchDir from URL params (set by CLI) to search in the right directories + const result = await detectWorkflowDataDir(config.searchDir); + if (result.success && result.data.dataDir) { + // Found a data directory! Update the config and reload + updateConfig({ ...config, dataDir: result.data.dataDir }); + // The config update will trigger a re-render and re-fetch + return; + } + } catch (e) { + // Ignore detection errors, just proceed with normal reload + console.debug('Data dir detection failed:', e); + } + } + reload(); }; diff --git a/packages/web/src/components/runs-table.tsx b/packages/web/src/components/runs-table.tsx index ea4614fdd..2a4ec32df 100644 --- a/packages/web/src/components/runs-table.tsx +++ b/packages/web/src/components/runs-table.tsx @@ -7,7 +7,11 @@ import { getErrorMessage, useWorkflowRuns, } from '@workflow/web-shared'; -import { fetchEvents, fetchRun } from '@workflow/web-shared/server'; +import { + detectWorkflowDataDir, + fetchEvents, + fetchRun, +} from '@workflow/web-shared/server'; import type { WorkflowRun, WorkflowRunStatus } from '@workflow/world'; import { AlertCircle, @@ -49,7 +53,7 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; -import { worldConfigToEnvMap } from '@/lib/config'; +import { useUpdateConfigQueryParams, worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; import { CopyableText } from './display-utils/copyable-text'; import { RelativeTime } from './display-utils/relative-time'; @@ -361,6 +365,7 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { const searchParams = useSearchParams(); const handleWorkflowFilter = useWorkflowFilter(); const handleStatusFilter = useStatusFilter(); + const updateConfig = useUpdateConfigQueryParams(); // Validate status parameter - only allow known valid statuses or 'all' const rawStatus = searchParams.get('status'); @@ -378,6 +383,13 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { ); const env = useMemo(() => worldConfigToEnvMap(config), [config]); + // Check if dataDir was explicitly set via URL params (not using default) + const dataDirFromUrl = searchParams.get('dataDir'); + const isLocalBackend = + !config.backend || + config.backend === 'local' || + config.backend === '@workflow/world-local'; + // TODO: World-vercel doesn't support filtering by status without a workflow name filter const statusFilterRequiresWorkflowNameFilter = config.backend?.includes('vercel') || false; @@ -418,8 +430,27 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { const loading = data.isLoading; - const onReload = () => { + const onReload = async () => { setLastRefreshTime(() => new Date()); + + // If backend is local and dataDir wasn't explicitly set via URL, + // try to detect the data directory (in case user just ran a workflow) + if (isLocalBackend && !dataDirFromUrl) { + try { + // Pass searchDir from URL params (set by CLI) to search in the right directories + const result = await detectWorkflowDataDir(config.searchDir); + if (result.success && result.data.dataDir) { + // Found a data directory! Update the config and reload + updateConfig({ ...config, dataDir: result.data.dataDir }); + // The config update will trigger a re-render and re-fetch + return; + } + } catch (e) { + // Ignore detection errors, just proceed with normal reload + console.debug('Data dir detection failed:', e); + } + } + reload(); }; diff --git a/packages/web/src/lib/config-world.ts b/packages/web/src/lib/config-world.ts index 185ede73b..b8e075760 100644 --- a/packages/web/src/lib/config-world.ts +++ b/packages/web/src/lib/config-world.ts @@ -19,6 +19,8 @@ export interface WorldConfig { manifestPath?: string; // Postgres fields postgresUrl?: string; + // Directory paths to search for workflow data (colon-separated, used when dataDir not set) + searchDir?: string; } export interface ValidationError { diff --git a/packages/web/src/lib/config.ts b/packages/web/src/lib/config.ts index 16a6ef6ff..cbd64e243 100644 --- a/packages/web/src/lib/config.ts +++ b/packages/web/src/lib/config.ts @@ -24,6 +24,7 @@ const configParsers = { DEFAULT_CONFIG.dataDir || './.next/workflow-data' ), manifestPath: parseAsString, + searchDir: parseAsString, }; // Create a serializer for config params