Files
vercel__workflow/packages/web/app/server/workflow-server-actions.server.ts
Karthik Kalyan ad5c068d7f [web-shared][web] Fix events tab search (#2107)
* Add server-backed exact ID search to the Events tab.

Replace client-side substring filtering with API lookups for full correlation and event IDs so searches work beyond the first loaded page.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix exact ID search dimming and support wrun_ correlation IDs.

Disable group dimming for server search results and accept run IDs in the exact ID parser so run-level correlation search works.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix dimmed row when searching by event ID for run-level events.

Map selectedGroupKey to __run__ for run-level search results so the matched row is treated as related instead of dimmed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove run ID search from Events tab exact ID lookup.

Workflow-server only accepts step, wait, and hook correlation IDs — not wrun_. Update the search placeholder and validation toast accordingly.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden exact ID search UX and correlation fetch limits.

Normalize lowercase ULIDs, scope Enter toasts to ID-like input, abort stale searches, disable search when unavailable, expand parser tests, and cap correlation pagination in workflow web.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix search clear race and surface truncated correlation results.

Guard successful exact-ID search against aborted requests, invalidate in-flight work when the input clears, and return truncation metadata from correlation pagination.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Differentiate exact ID search errors from not-found results.

Return a discriminated union from onExactIdSearch and show search errors in the Events tab instead of mislabeling them as missing IDs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Apply suggestion from @VaguelySerious

Signed-off-by: Peter Wielander <mittgfu@gmail.com>

---------

Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-05-29 11:00:53 -07:00

1244 lines
36 KiB
TypeScript

/**
* Server-only module for workflow data access.
*
* The `.server.ts` suffix ensures Vite excludes this from client bundles.
* These functions are called from React Router route loaders/actions.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import * as workflowRunHelpers from '@workflow/core/runtime';
import { createWorld } from '@workflow/core/runtime';
import {
type HealthCheckEndpoint,
type HealthCheckResult,
healthCheck,
} from '@workflow/core/runtime/helpers';
import { resumeHook as resumeHookRuntime } from '@workflow/core/runtime/resume-hook';
import { WorkflowWorldError, WorkflowRunNotFoundError } from '@workflow/errors';
import { findWorkflowDataDir } from '@workflow/utils/check-data-dir';
import type {
Event,
Hook,
Step,
WorkflowRun,
WorkflowRunStatus,
World,
} from '@workflow/world';
import { type APIConfig, createVercelWorld } from '@workflow/world-vercel';
/**
* Environment variable map for world configuration.
*
* NOTE: This type is still exported for potential future use cases where
* dynamic world configuration at runtime may be needed. Currently, the
* @workflow/web package uses server-side environment variables exclusively
* and does not pass EnvMap from the client. The server actions still accept
* this parameter for backwards compatibility and future extensibility.
*/
export type EnvMap = Record<string, string | undefined>;
/**
* Public configuration info that is safe to send to the client.
*
* IMPORTANT:
* - The web UI must not be able to read arbitrary server env vars.
* - The only env-derived data we expose is from a strict per-world allowlist.
*/
export interface PublicServerConfig {
/** Human-readable backend name for display (e.g., "PostgreSQL", "Local", "Vercel") */
backendDisplayName: string;
/** The raw backend identifier (e.g., "@workflow/world-postgres", "local", "vercel") */
backendId: string;
/**
* Safe, whitelisted, env-derived values.
*
* Keys MUST match the canonical environment variable names (e.g. "WORKFLOW_VERCEL_PROJECT").
* This keeps configuration naming consistent across CLI + web + docs.
*/
publicEnv: Record<string, string>;
/**
* Keys for env vars that are allowed/known but considered sensitive.
* The server will NOT return their values; UIs should display `*****`.
*/
sensitiveEnvKeys: string[];
/**
* Additional safe, derived info for display (never contains secrets).
* These keys are not env var names; they are UI-friendly derived fields.
*/
displayInfo?: Record<string, string>;
}
/**
* Map from WORKFLOW_TARGET_WORLD value to human-readable display name
*/
function getBackendDisplayName(targetWorld: string | undefined): string {
if (!targetWorld) return 'Local';
switch (targetWorld) {
case 'local':
return 'Local';
case 'vercel':
return 'Vercel';
case '@workflow/world-postgres':
case 'postgres':
return 'PostgreSQL';
default:
// For custom worlds, try to make a readable name
if (targetWorld.startsWith('@')) {
// Extract package name without scope for display
const parts = targetWorld.split('/');
return parts[parts.length - 1] || targetWorld;
}
return targetWorld;
}
}
function getEffectiveBackendId(): string {
const targetWorld = process.env.WORKFLOW_TARGET_WORLD;
if (targetWorld) {
return targetWorld;
}
// Match @workflow/core/runtime defaulting: vercel if VERCEL_DEPLOYMENT_ID is set, else local.
return process.env.VERCEL_DEPLOYMENT_ID ? 'vercel' : 'local';
}
function getObservabilityCwd(): string {
const raw = process.env.WORKFLOW_OBSERVABILITY_CWD;
if (!raw) {
return process.cwd();
}
return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
}
/**
* Ensure local-world env is derived consistently when running `packages/web` directly.
*
* Without this, the UI may *display* a dataDir detected from WORKFLOW_OBSERVABILITY_CWD,
* while the actual World reads from `WORKFLOW_LOCAL_DATA_DIR` (defaulting to `.workflow-data`
* under the web package cwd), resulting in "no runs" even though data exists.
*/
async function ensureLocalWorldDataDirEnv(): Promise<void> {
if (process.env.WORKFLOW_LOCAL_DATA_DIR) return;
const cwd = getObservabilityCwd();
const info = await findWorkflowDataDir(cwd);
// Prefer a discovered workflow-data directory (e.g. `.next/workflow-data`).
if (info.dataDir) {
process.env.WORKFLOW_LOCAL_DATA_DIR = info.dataDir;
return;
}
// Fall back to a canonical location under the target project directory.
process.env.WORKFLOW_LOCAL_DATA_DIR = path.resolve(cwd, '.workflow-data');
}
/**
* Extract hostname from a database URL without exposing credentials.
*/
function extractHostnameFromUrl(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
const parsed = new URL(url);
return parsed.hostname || undefined;
} catch {
return undefined;
}
}
/**
* Extract database name from a URL where pathname is like "/dbname".
* (Works for postgres/mongodb-style URLs; returns undefined when not applicable.)
*/
function extractDatabaseFromUrl(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
const parsed = new URL(url);
const dbName = parsed.pathname?.slice(1);
return dbName || undefined;
} catch {
return undefined;
}
}
// Keep this list in sync with `worlds-manifest.json` env + credentialsNote.
const WORLD_ENV_ALLOWLIST_BY_TARGET_WORLD: Record<string, string[]> = {
// Official
local: [
'WORKFLOW_TARGET_WORLD',
'WORKFLOW_LOCAL_DATA_DIR',
'WORKFLOW_MANIFEST_PATH',
'WORKFLOW_OBSERVABILITY_CWD',
'PORT',
],
'@workflow/world-local': [
'WORKFLOW_TARGET_WORLD',
'WORKFLOW_LOCAL_DATA_DIR',
'WORKFLOW_MANIFEST_PATH',
'WORKFLOW_OBSERVABILITY_CWD',
'PORT',
],
postgres: ['WORKFLOW_TARGET_WORLD', 'WORKFLOW_POSTGRES_URL'],
'@workflow/world-postgres': [
'WORKFLOW_TARGET_WORLD',
'WORKFLOW_POSTGRES_URL',
],
vercel: [
'WORKFLOW_TARGET_WORLD',
'WORKFLOW_VERCEL_ENV',
'WORKFLOW_VERCEL_TEAM',
'WORKFLOW_VERCEL_PROJECT',
'WORKFLOW_VERCEL_PROJECT_NAME',
'WORKFLOW_VERCEL_AUTH_TOKEN',
],
'@workflow/world-vercel': [
'WORKFLOW_TARGET_WORLD',
'WORKFLOW_VERCEL_ENV',
'WORKFLOW_VERCEL_TEAM',
'WORKFLOW_VERCEL_PROJECT',
'WORKFLOW_VERCEL_PROJECT_NAME',
'WORKFLOW_VERCEL_AUTH_TOKEN',
],
// Community (from worlds-manifest.json)
'@workflow-worlds/starter': ['WORKFLOW_TARGET_WORLD'],
'@workflow-worlds/turso': [
'WORKFLOW_TARGET_WORLD',
'WORKFLOW_TURSO_DATABASE_URL',
],
'@workflow-worlds/mongodb': [
'WORKFLOW_TARGET_WORLD',
'WORKFLOW_MONGODB_URI',
'WORKFLOW_MONGODB_DATABASE_NAME',
],
'@workflow-worlds/redis': ['WORKFLOW_TARGET_WORLD', 'WORKFLOW_REDIS_URI'],
'workflow-world-jazz': [
'WORKFLOW_TARGET_WORLD',
// credentialsNote:
'JAZZ_API_KEY',
'JAZZ_WORKER_ACCOUNT',
'JAZZ_WORKER_SECRET',
],
};
function getAllowedEnvKeysForBackend(backendId: string): string[] {
return (
WORLD_ENV_ALLOWLIST_BY_TARGET_WORLD[backendId] ?? ['WORKFLOW_TARGET_WORLD']
);
}
// Keep this list in sync with `worlds-manifest.json` env + credentialsNote.
//
// IMPORTANT: This is intentionally explicit (no heuristics). We only redact values for env
// vars that are known + whitelisted and that we *know* contain secrets/credentials.
const WORLD_SENSITIVE_ENV_KEYS = new Set<string>([
// Official
'WORKFLOW_POSTGRES_URL',
'WORKFLOW_VERCEL_AUTH_TOKEN',
// Community
'WORKFLOW_TURSO_DATABASE_URL',
'WORKFLOW_MONGODB_URI',
'WORKFLOW_REDIS_URI',
'JAZZ_API_KEY',
'JAZZ_WORKER_SECRET',
]);
function isSet(value: string | undefined): value is string {
return value !== undefined && value !== null && value !== '';
}
function deriveDbInfoForKey(
key: string,
value: string
): Record<string, string> | null {
// Only attempt for URL-like strings.
if (!value.includes(':')) return null;
try {
const parsed = new URL(value);
const protocol = (parsed.protocol || '').replace(':', '');
// file: URIs are not useful for hostname/db display
if (protocol === 'file') return null;
const hostname = extractHostnameFromUrl(value);
const database = extractDatabaseFromUrl(value);
const out: Record<string, string> = {};
if (hostname) out[`derived.${key}.hostname`] = hostname;
if (database) out[`derived.${key}.database`] = database;
if (protocol) out[`derived.${key}.protocol`] = protocol;
return Object.keys(out).length ? out : null;
} catch {
return null;
}
}
async function getLocalDisplayInfo(): Promise<Record<string, string>> {
const cwd = getObservabilityCwd();
const dataDirInfo = await findWorkflowDataDir(cwd);
const out: Record<string, string> = {
'local.shortName': dataDirInfo.shortName,
'local.projectDir': dataDirInfo.projectDir,
};
if (dataDirInfo.dataDir) {
out['local.dataDirPath'] = dataDirInfo.dataDir;
}
return out;
}
function collectAllowedEnv(allowedKeys: string[]): {
publicEnv: Record<string, string>;
sensitiveEnvKeys: string[];
derivedDisplayInfo: Record<string, string>;
} {
const publicEnv: Record<string, string> = {};
const sensitiveEnvKeys: string[] = [];
const derivedDisplayInfo: Record<string, string> = {};
for (const key of allowedKeys) {
const value = process.env[key];
if (!isSet(value)) continue;
if (WORLD_SENSITIVE_ENV_KEYS.has(key)) {
sensitiveEnvKeys.push(key);
const derived = deriveDbInfoForKey(key, value);
if (derived) Object.assign(derivedDisplayInfo, derived);
continue;
}
publicEnv[key] = value;
}
return {
publicEnv,
sensitiveEnvKeys: Array.from(new Set(sensitiveEnvKeys)).sort(),
derivedDisplayInfo,
};
}
/**
* Get public configuration info that is safe to send to the client.
*
* This is the ONLY server action that intentionally exposes env-derived data,
* and that data is strictly whitelisted per world backend.
*/
export async function getPublicServerConfig(): Promise<PublicServerConfig> {
const backendId = getEffectiveBackendId();
const backendDisplayName = getBackendDisplayName(backendId);
const allowedKeys = getAllowedEnvKeysForBackend(backendId);
const { publicEnv, sensitiveEnvKeys, derivedDisplayInfo } =
collectAllowedEnv(allowedKeys);
const displayInfo: Record<string, string> = { ...derivedDisplayInfo };
if (backendId === 'local' || backendId === '@workflow/world-local') {
Object.assign(displayInfo, await getLocalDisplayInfo());
}
const config: PublicServerConfig = {
backendDisplayName,
backendId,
publicEnv,
sensitiveEnvKeys,
displayInfo: Object.keys(displayInfo).length ? displayInfo : undefined,
};
// Provide defaults for commonly expected keys without revealing extra secrets.
if (
(backendId === 'vercel' || backendId === '@workflow/world-vercel') &&
!publicEnv.WORKFLOW_VERCEL_ENV
) {
config.publicEnv.WORKFLOW_VERCEL_ENV = 'production';
}
return config;
}
export interface PaginatedResult<T> {
data: T[];
cursor?: string;
hasMore: boolean;
}
/**
* Structured error information that can be sent to the client
*/
export interface ServerActionError {
message: string;
// "Server" if the error originates in this file, "API" if the error originates in the World interface
layer: 'server' | 'API';
cause?: string;
request?: {
operation: string;
params: Record<string, any>;
status?: number;
url?: string;
code?: string;
};
}
/**
* Result wrapper for server actions that can return either data or error
*/
export type ServerActionResult<T> =
| { success: true; data: T }
| { success: false; error: ServerActionError };
/**
* Cache for World instances.
*
* IMPORTANT:
* - We only cache non-vercel worlds.
* - Cache keys are derived from **server-side** WORKFLOW_* env vars only.
*/
const worldCache = new Map<string, World>();
/**
* Get or create a World instance based on configuration.
*
* The @workflow/web UI should always pass `{}` for envMap.
*/
async function getWorldFromEnv(userEnvMap: EnvMap): Promise<World> {
const backendId = getEffectiveBackendId();
const isVercelWorld = ['vercel', '@workflow/world-vercel'].includes(
backendId
);
// For the vercel world specifically, we do not cache the world,
// and allow user-provided env, as it can be a multi-tenant environment,
// and we instantiate the world per-user directly to avoid having to set
// process.env.
if (isVercelWorld) {
return createVercelWorld({
token:
userEnvMap.WORKFLOW_VERCEL_AUTH_TOKEN ||
process.env.WORKFLOW_VERCEL_AUTH_TOKEN,
projectConfig: {
environment:
userEnvMap.WORKFLOW_VERCEL_ENV || process.env.WORKFLOW_VERCEL_ENV,
projectId:
userEnvMap.WORKFLOW_VERCEL_PROJECT ||
process.env.WORKFLOW_VERCEL_PROJECT,
projectName:
userEnvMap.WORKFLOW_VERCEL_PROJECT_NAME ||
process.env.WORKFLOW_VERCEL_PROJECT_NAME,
teamId:
userEnvMap.WORKFLOW_VERCEL_TEAM || process.env.WORKFLOW_VERCEL_TEAM,
},
});
}
// For other worlds, we intentionally do not trust or apply client-provided env,
// to avoid potential security risks in self-hosted scenarios.
// Ensure local-world reads from the same project directory the UI is inspecting.
if (backendId === 'local' || backendId === '@workflow/world-local') {
await ensureLocalWorldDataDirEnv();
}
// Cache key derived ONLY from WORKFLOW_* env vars.
const workflowEnvEntries = Object.entries(process.env).filter(([key]) =>
key.startsWith('WORKFLOW_')
);
workflowEnvEntries.sort(([a], [b]) => a.localeCompare(b));
const cacheKey = JSON.stringify(Object.fromEntries(workflowEnvEntries));
const cachedWorld = worldCache.get(cacheKey);
if (cachedWorld) {
return cachedWorld;
}
const world = await createWorld();
worldCache.set(cacheKey, world);
return world;
}
/**
* Creates a structured error object from a caught error
*/
function createServerActionError<T>(
error: unknown,
operation: string,
requestParams?: Record<string, any>
): ServerActionResult<T> {
const err = error instanceof Error ? error : new Error(String(error));
let errorResponse: ServerActionError;
if (WorkflowWorldError.is(error)) {
// API-level errors (4xx/5xx from the world backend).
// 4xx errors are client-recoverable and shouldn't spam logs.
const status = error.status ?? 500;
const isClientError = status >= 400 && status < 500;
if (!isClientError) {
console.error(`[web-api] ${operation} error:`, err);
}
errorResponse = {
message: getUserFacingErrorMessage(err, error.status),
layer: 'API',
cause: err.stack || err.message,
request: {
operation,
params: requestParams ?? {},
status: error.status,
url: error.url,
code: error.code ?? undefined,
},
};
} else if (WorkflowRunNotFoundError.is(error)) {
// Run not found — expected during polling for recently created runs.
errorResponse = {
message: getUserFacingErrorMessage(error, 404),
layer: 'API',
cause: err.stack || err.message,
request: { operation, status: 404, params: requestParams ?? {} },
};
} else {
// Unrecognized errors (e.g., world backends throwing plain Error for 4xx).
// The error is returned to the caller — no server-side logging needed.
errorResponse = {
message: getUserFacingErrorMessage(err),
layer: 'server',
cause: err.stack || err.message,
request: { status: 500, operation, params: requestParams ?? {} },
};
}
return {
success: false,
error: errorResponse,
};
}
/**
* Converts an error into a user-facing message
*/
function getUserFacingErrorMessage(error: Error, status?: number): string {
if (!status) {
return `Error creating response: ${error.message}`;
}
// Check for common error patterns
if (status === 403 || status === 401) {
return 'Access denied. Please check your credentials and permissions.';
}
if (status === 404) {
return 'The requested resource was not found.';
}
if (status === 500) {
return 'Error connecting to World backend, please try again later.';
}
if (error.message?.includes('Network') || error.message?.includes('fetch')) {
return 'Network error. Please check your connection and try again.';
}
// Return the original message for other errors
return error.message || 'An unexpected error occurred';
}
/**
* Helper to create successful responses.
* Data is passed through as-is — CBOR transport preserves Uint8Array
* and other types. Hydration happens client-side.
*/
function createResponse<T>(data: T): ServerActionResult<T> {
return {
success: true,
data,
};
}
/**
* Fetch paginated list of workflow runs
*/
export async function fetchRuns(
worldEnv: EnvMap,
params: {
cursor?: string;
sortOrder?: 'asc' | 'desc';
limit?: number;
workflowName?: string;
status?: WorkflowRunStatus;
}
): Promise<ServerActionResult<PaginatedResult<WorkflowRun>>> {
const {
cursor,
sortOrder = 'desc',
limit = 10,
workflowName,
status,
} = params;
try {
const world = await getWorldFromEnv(worldEnv);
const result = await world.runs.list({
...(workflowName ? { workflowName } : {}),
...(status ? { status: status } : {}),
pagination: { cursor, limit, sortOrder },
resolveData: 'none',
});
return createResponse({
data: result.data as unknown as WorkflowRun[],
cursor: result.cursor ?? undefined,
hasMore: result.hasMore,
});
} catch (error) {
return createServerActionError<PaginatedResult<WorkflowRun>>(
error,
'world.runs.list',
params
);
}
}
/**
* Fetch a single workflow run with full data
*/
export async function fetchRun(
worldEnv: EnvMap,
runId: string,
resolveData: 'none' | 'all' = 'all'
): Promise<ServerActionResult<WorkflowRun>> {
try {
const world = await getWorldFromEnv(worldEnv);
const run = await world.runs.get(runId, { resolveData });
return createResponse(run as WorkflowRun);
} catch (error) {
return createServerActionError<WorkflowRun>(error, 'world.runs.get', {
runId,
resolveData,
});
}
}
/**
* Fetch paginated list of steps for a run
*/
export async function fetchSteps(
worldEnv: EnvMap,
runId: string,
params: {
cursor?: string;
sortOrder?: 'asc' | 'desc';
limit?: number;
}
): Promise<ServerActionResult<PaginatedResult<Step>>> {
const { cursor, sortOrder = 'asc', limit = 100 } = params;
try {
const world = await getWorldFromEnv(worldEnv);
const result = await world.steps.list({
runId,
pagination: { cursor, limit, sortOrder },
resolveData: 'none',
});
return createResponse({
// StepWithoutData has undefined input/output, but after hydration the structure is compatible
data: result.data as unknown as Step[],
cursor: result.cursor ?? undefined,
hasMore: result.hasMore,
});
} catch (error) {
return createServerActionError<PaginatedResult<Step>>(
error,
'world.steps.list',
{
runId,
...params,
}
);
}
}
/**
* Fetch a single step with full data
*/
export async function fetchStep(
worldEnv: EnvMap,
runId: string,
stepId: string,
resolveData: 'none' | 'all' = 'all'
): Promise<ServerActionResult<Step>> {
try {
const world = await getWorldFromEnv(worldEnv);
const step = await world.steps.get(runId, stepId, { resolveData });
return createResponse(step as Step);
} catch (error) {
return createServerActionError<Step>(error, 'world.steps.get', {
runId,
stepId,
resolveData,
});
}
}
/**
* Fetch paginated list of events for a run
*/
export async function fetchEvents(
worldEnv: EnvMap,
runId: string,
params: {
cursor?: string;
sortOrder?: 'asc' | 'desc';
limit?: number;
withData?: boolean;
}
): Promise<ServerActionResult<PaginatedResult<Event>>> {
const { cursor, sortOrder = 'asc', limit = 1000, withData = false } = params;
try {
const world = await getWorldFromEnv(worldEnv);
const result = await world.events.list({
runId,
pagination: { cursor, limit, sortOrder },
resolveData: withData ? 'all' : 'none',
});
return createResponse({
data: result.data as unknown as Event[],
cursor: result.cursor ?? undefined,
hasMore: result.hasMore,
});
} catch (error) {
return createServerActionError<PaginatedResult<Event>>(
error,
'world.events.list',
{
runId,
...params,
}
);
}
}
/**
* Fetch a single event by run ID and event ID
*/
export async function fetchEvent(
worldEnv: EnvMap,
runId: string,
eventId: string,
resolveData: 'none' | 'all' = 'all'
): Promise<ServerActionResult<Event>> {
try {
const world = await getWorldFromEnv(worldEnv);
const event = await world.events.get(runId, eventId, { resolveData });
return createResponse(event as Event);
} catch (error) {
return createServerActionError<Event>(error, 'world.events.get', {
runId,
eventId,
resolveData,
});
}
}
/**
* Fetch paginated events for a step correlation ID.
*/
export async function fetchEventsByCorrelationId(
worldEnv: EnvMap,
correlationId: string,
params: {
cursor?: string;
sortOrder?: 'asc' | 'desc';
limit?: number;
withData?: boolean;
}
): Promise<ServerActionResult<PaginatedResult<Event>>> {
const { cursor, sortOrder = 'asc', limit = 100, withData = false } = params;
try {
const world = await getWorldFromEnv(worldEnv);
const result = await world.events.listByCorrelationId({
correlationId,
pagination: { cursor, limit, sortOrder },
resolveData: withData ? 'all' : 'none',
});
return createResponse({
data: result.data as unknown as Event[],
cursor: result.cursor ?? undefined,
hasMore: result.hasMore,
});
} catch (error) {
return createServerActionError<PaginatedResult<Event>>(
error,
'world.events.listByCorrelationId',
{
correlationId,
...params,
}
);
}
}
/**
* Fetch paginated list of hooks
*/
export async function fetchHooks(
worldEnv: EnvMap,
params: {
runId?: string;
cursor?: string;
sortOrder?: 'asc' | 'desc';
limit?: number;
}
): Promise<ServerActionResult<PaginatedResult<Hook>>> {
const { runId, cursor, sortOrder = 'desc', limit = 10 } = params;
try {
const world = await getWorldFromEnv(worldEnv);
const result = await world.hooks.list({
...(runId ? { runId } : {}),
pagination: { cursor, limit, sortOrder },
resolveData: 'none',
});
return createResponse({
data: result.data as Hook[],
cursor: result.cursor ?? undefined,
hasMore: result.hasMore,
});
} catch (error) {
return createServerActionError<PaginatedResult<Hook>>(
error,
'world.hooks.list',
params
);
}
}
/**
* Fetch a single hook with full data
*/
export async function fetchHook(
worldEnv: EnvMap,
hookId: string,
resolveData: 'none' | 'all' = 'all'
): Promise<ServerActionResult<Hook>> {
try {
const world = await getWorldFromEnv(worldEnv);
const hook = await world.hooks.get(hookId, { resolveData });
return createResponse(hook as Hook);
} catch (error) {
return createServerActionError<Hook>(error, 'world.hooks.get', {
hookId,
resolveData,
});
}
}
/**
* Cancel a workflow run
*/
export async function cancelRun(
worldEnv: EnvMap,
runId: string
): Promise<ServerActionResult<void>> {
try {
const world = await getWorldFromEnv(worldEnv);
await workflowRunHelpers.cancelRun(world, runId);
return createResponse(undefined);
} catch (error) {
return createServerActionError<void>(error, 'world.events.create', {
runId,
});
}
}
/**
* Start a new workflow run.
*
* This requires the ID of an existing run of which to re-use the deployment ID of.
*/
export async function recreateRun(
worldEnv: EnvMap,
runId: string,
deploymentId?: string
): Promise<ServerActionResult<string>> {
try {
const world = await getWorldFromEnv({ ...worldEnv });
const newRunId = await workflowRunHelpers.recreateRunFromExisting(
world,
runId,
{
deploymentId,
}
);
return createResponse(newRunId);
} catch (error) {
return createServerActionError<string>(error, 'recreateRun', { runId });
}
}
/**
* Re-enqueue a workflow run.
*
* This re-enqueues the workflow orchestration layer. It's a no-op unless the workflow
* got stuck due to an implementation issue in the World. Useful for debugging custom Worlds.
*/
export async function reenqueueRun(
worldEnv: EnvMap,
runId: string
): Promise<ServerActionResult<void>> {
try {
const world = await getWorldFromEnv({ ...worldEnv });
await workflowRunHelpers.reenqueueRun(world, runId);
return createResponse(undefined);
} catch (error) {
return createServerActionError<void>(error, 'reenqueueRun', { runId });
}
}
export interface StopSleepResult {
/** Number of pending sleeps that were stopped */
stoppedCount: number;
}
export interface StopSleepOptions {
/**
* Optional list of specific correlation IDs to target.
* If provided, only these sleep calls will be interrupted.
* If not provided, all pending sleep calls will be interrupted.
*/
correlationIds?: string[];
}
/**
* Wake up a workflow run by interrupting pending sleep() calls.
*
* This finds wait_created events without matching wait_completed events,
* creates wait_completed events for them, and then re-enqueues the run.
*
* @param worldEnv - Environment configuration for the World
* @param runId - The run ID to wake up
* @param options - Optional settings to narrow down targeting (specific correlation IDs)
*/
export async function wakeUpRun(
worldEnv: EnvMap,
runId: string,
options?: StopSleepOptions
): Promise<ServerActionResult<StopSleepResult>> {
try {
const world = await getWorldFromEnv({ ...worldEnv });
const result = await workflowRunHelpers.wakeUpRun(world, runId, options);
return createResponse(result);
} catch (error) {
return createServerActionError<StopSleepResult>(error, 'wakeUpRun', {
runId,
correlationIds: options?.correlationIds,
});
}
}
export interface ResumeHookResult {
/** The hook ID that was resumed */
hookId: string;
/** The run ID associated with the hook */
runId: string;
}
/**
* Resume a hook by sending a payload.
*
* This sends a payload to a hook identified by its token, which resumes
* the associated workflow run. The payload will be available as the return
* value of the `createHook()` call in the workflow.
*
* @param worldEnv - Environment configuration for the World
* @param token - The hook token
* @param payload - The JSON payload to send to the hook
*/
export async function resumeHook(
worldEnv: EnvMap,
token: string,
payload: unknown
): Promise<ServerActionResult<ResumeHookResult>> {
try {
// Initialize the world so resumeHookRuntime can access it
await getWorldFromEnv({ ...worldEnv });
const hook = await resumeHookRuntime(token, payload);
return createResponse({
hookId: hook.hookId,
runId: hook.runId,
});
} catch (error) {
return createServerActionError<ResumeHookResult>(error, 'resumeHook', {
token,
});
}
}
export async function readStreamServerAction(
env: EnvMap,
streamId: string,
startIndex: number | undefined,
runId: string
): Promise<ReadableStream<Uint8Array> | ServerActionError> {
try {
const world = await getWorldFromEnv(env);
// Return the raw binary stream — deserialization and decryption
// happen entirely client-side.
return await world.streams.get(runId, streamId, startIndex);
} catch (error) {
const actionError = createServerActionError(error, 'world.streams.get', {
streamId,
startIndex,
});
if (!actionError.success) {
return actionError.error;
}
// Shouldn't happen, this is just a type guard
throw new Error();
}
}
const CHUNKS_PAGE_SIZE = 500;
export interface StreamChunksResult {
buffer: Uint8Array;
/** Cursor the client should send back on the next poll to resume, or null */
cursor: string | null;
/** Whether the stream is fully closed (no more chunks will ever be written) */
done: boolean;
}
/**
* Fetch stream chunks using paginated batch API.
*
* When `startCursor` is provided, only chunks after that position are
* returned — used for incremental polling so previously-seen data is
* never re-transferred. Returns a `cursor` the client passes back on
* the next request.
*/
export async function readStreamChunksServerAction(
env: EnvMap,
streamId: string,
runId: string,
startCursor?: string
): Promise<StreamChunksResult | ServerActionError> {
try {
const world = await getWorldFromEnv(env);
const allChunks: Uint8Array[] = [];
let pageCursor: string | undefined = startCursor;
let streamDone = false;
// Track the last non-null cursor so we can resume from the start of
// the final page on the next poll. When getChunks returns
// cursor=null we've exhausted all pages, but this saved cursor lets
// the client re-fetch only the last page + any new chunks.
let resumeCursor: string | null = startCursor ?? null;
do {
const result = await world.streams.getChunks(runId, streamId, {
limit: CHUNKS_PAGE_SIZE,
cursor: pageCursor,
});
for (const chunk of result.data) {
allChunks.push(chunk.data);
}
streamDone = result.done;
if (result.cursor) {
resumeCursor = result.cursor;
}
pageCursor = result.cursor ?? undefined;
} while (pageCursor);
let totalSize = 0;
for (const chunk of allChunks) {
totalSize += chunk.length;
}
const body = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of allChunks) {
body.set(chunk, offset);
offset += chunk.length;
}
return { buffer: body, cursor: resumeCursor, done: streamDone };
} catch (error) {
const actionError = createServerActionError(
error,
'world.streams.getChunks',
{ streamId, runId }
);
if (!actionError.success) {
return actionError.error;
}
throw new Error();
}
}
/**
* List all stream IDs for a run
*/
export async function fetchStreams(
env: EnvMap,
runId: string
): Promise<ServerActionResult<string[]>> {
try {
const world = await getWorldFromEnv(env);
const streams = await world.streams.list(runId);
return createResponse(streams);
} catch (error) {
return createServerActionError<string[]>(error, 'world.streams.list', {
runId,
});
}
}
/**
* Fetch the workflows manifest from the workflow route directory
* The manifest is generated at build time and contains static structure info about workflows
*
* Configuration priority:
* 1. WORKFLOW_MANIFEST_PATH - explicit path to the manifest file
* 2. WORKFLOW_LOCAL_DATA_DIR - local world data directory (manifest.json)
* 3. Standard Next.js app router locations (app/.well-known/workflow/v1/manifest.json)
* 4. WORKFLOW_EMBEDDED_DATA_DIR - legacy data directory
*/
export async function fetchWorkflowsManifest(
_worldEnv: EnvMap
): Promise<ServerActionResult<any>> {
// Ensure local-world data dir is derived when running packages/web directly.
await ensureLocalWorldDataDirEnv();
const cwd = getObservabilityCwd();
// Helper to resolve path (absolute or relative to cwd)
const resolvePath = (p: string) =>
path.isAbsolute(p) ? p : path.join(cwd, p);
// Build list of paths to try, in priority order
const manifestPaths: string[] = [];
// 1. Explicit manifest path configuration (highest priority)
if (process.env.WORKFLOW_MANIFEST_PATH) {
manifestPaths.push(resolvePath(process.env.WORKFLOW_MANIFEST_PATH));
}
// 2. Local world data directory manifest
if (process.env.WORKFLOW_LOCAL_DATA_DIR) {
const localDataDir = resolvePath(process.env.WORKFLOW_LOCAL_DATA_DIR);
manifestPaths.push(path.join(localDataDir, 'manifest.json'));
// When local data lives in `.next/workflow-data`, the manifest is typically
// generated under the project app router path, not inside the data dir.
const localInfo = await findWorkflowDataDir(localDataDir);
manifestPaths.push(
path.join(
localInfo.projectDir,
'app/.well-known/workflow/v1/manifest.json'
),
path.join(
localInfo.projectDir,
'src/app/.well-known/workflow/v1/manifest.json'
)
);
}
// 3. Standard Next.js app router locations
manifestPaths.push(
path.join(cwd, 'app/.well-known/workflow/v1/manifest.json'),
path.join(cwd, 'src/app/.well-known/workflow/v1/manifest.json')
);
// 4. Legacy data directory locations
if (process.env.WORKFLOW_EMBEDDED_DATA_DIR) {
manifestPaths.push(
path.join(
resolvePath(process.env.WORKFLOW_EMBEDDED_DATA_DIR),
'manifest.json'
)
);
}
// Try each path until we find the manifest
for (const manifestPath of manifestPaths) {
try {
const content = await fs.readFile(manifestPath, 'utf-8');
const manifest = JSON.parse(content);
return createResponse(manifest);
} catch (_err) {
// Continue to next path
}
}
// If no manifest found, return an empty manifest
// This allows the UI to work without workflows graph data
return createResponse({
version: '1.0.0',
steps: {},
workflows: {},
});
}
/**
* Run a queue-based health check on a workflow endpoint.
*
* This sends a health check message through the Queue infrastructure,
* bypassing Vercel Deployment Protection. The endpoint processes the
* message and writes a response to a stream, which we then read to
* verify the endpoint is healthy.
*
* @param worldEnv - Environment configuration for the World
* @param endpoint - Which endpoint to check: 'workflow' or 'step'
* @param options - Optional configuration (timeout in ms)
*/
export async function runHealthCheck(
worldEnv: EnvMap,
endpoint: HealthCheckEndpoint,
options?: { timeout?: number }
): Promise<ServerActionResult<HealthCheckResult>> {
try {
const world = await getWorldFromEnv(worldEnv);
const result = await healthCheck(world, endpoint, options);
return createResponse({
...result,
});
} catch (error) {
// For health check failures, we want to return success=true with healthy=false
// so the UI can display the error properly, rather than propagating the server
// action error. This allows the health check result to be parsed by the UI
// even when the endpoint is down or unreachable.
const errorMessage = error instanceof Error ? error.message : String(error);
return createResponse({
healthy: false,
error: errorMessage,
latencyMs: undefined,
});
}
}
// ---------------------------------------------------------------------------
// Encryption key retrieval for client-side decryption
// ---------------------------------------------------------------------------
/**
* Retrieve the encryption key for a specific run.
*
* The World resolves the per-run AES-256 key (including HKDF derivation
* and cross-deployment key retrieval). The raw key bytes are returned
* to the browser for client-side decryption via core's AES-GCM.
*/
export async function getEncryptionKeyForRun(
worldEnv: EnvMap,
runId: string
): Promise<ServerActionResult<Uint8Array | null>> {
try {
const world = await getWorldFromEnv(worldEnv);
if (!world.getEncryptionKeyForRun) {
return createResponse(null);
}
// Fetch the full run so the World can inspect deploymentId etc.
const run = await world.runs.get(runId);
const key = await world.getEncryptionKeyForRun(run);
return createResponse(key ?? null);
} catch (error) {
return createServerActionError<Uint8Array | null>(
error,
'getEncryptionKeyForRun',
{ runId }
);
}
}