Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Allow starting up web when workflow data directory is not present by karthikscale3 · Pull Request #680 · vercel/workflow · GitHub
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Allow starting up web when workflow data directory is not present by karthikscale3 · Pull Request #680 · vercel/workflow · GitHub
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Allow starting up web when workflow data directory is not present by karthikscale3 · Pull Request #680 · vercel/workflow · GitHub
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Allow starting up web when workflow data directory is not present by karthikscale3 · Pull Request #680 · vercel/workflow · GitHub
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Allow starting up web when workflow data directory is not present by karthikscale3 · Pull Request #680 · vercel/workflow · GitHub
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Allow starting up web when workflow data directory is not present by karthikscale3 · Pull Request #680 · vercel/workflow · GitHub
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Allow starting up web when workflow data directory is not present by karthikscale3 · Pull Request #680 · vercel/workflow · GitHub
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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
Copy Markdown
ContributorAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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