Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/crazy-schools-safety.md
Original file line number Diff line number Diff line change
@@ -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
8 changes: 3 additions & 5 deletions packages/cli/src/lib/inspect/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
};

Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/lib/inspect/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is needed so the server action can constrain the search for data dir within the project root

queryParams.set('searchDir', searchDir);
}

const webPort = flags.webPort ?? 3456;
const hostUrl = getHostUrl(webPort);
const url = `${hostUrl}?${queryParams.toString()}`;
Expand Down
67 changes: 67 additions & 0 deletions packages/web-shared/src/api/workflow-server-actions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is passed down from the client

): Promise<ServerActionResult<DetectDataDirResult>> {
// 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 });
}
45 changes: 42 additions & 3 deletions packages/web/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this won't run if dataDir is already set or if its not a local backend

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 {
Expand Down
38 changes: 35 additions & 3 deletions packages/web/src/components/hooks-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -75,11 +79,20 @@ export function HooksTable({
onHookClick,
selectedHookId,
}: HooksTableProps) {
const searchParams = useSearchParams();
const updateConfig = useUpdateConfigQueryParams();
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(
() => 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,
Expand All @@ -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();
};

Expand Down
37 changes: 34 additions & 3 deletions packages/web/src/components/runs-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand All @@ -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;
Expand Down Expand Up @@ -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();
};

Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/lib/config-world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const configParsers = {
DEFAULT_CONFIG.dataDir || './.next/workflow-data'
),
manifestPath: parseAsString,
searchDir: parseAsString,
};

// Create a serializer for config params
Expand Down
Loading