diff --git a/.changeset/common-mangos-bet.md b/.changeset/common-mangos-bet.md new file mode 100644 index 0000000000..f44db56b64 --- /dev/null +++ b/.changeset/common-mangos-bet.md @@ -0,0 +1,7 @@ +--- +"@workflow/web-shared": patch +"@workflow/core": patch +"@workflow/web": patch +--- + +Added subpatch exports for runtime modules to allow direct imports in core. Refactored web-shared to be a thin package that exported UI components and world-actions. Updated web package to consume the UI components and world-actions from web-shared. diff --git a/packages/core/package.json b/packages/core/package.json index fdda4e36bc..1a69894d31 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,7 +6,9 @@ "main": "dist/index.js", "files": [ "dist", - "docs/**/*" + "docs/**/*", + "runtime.js", + "runtime.d.ts" ], "directories": { "doc": "./docs" @@ -26,11 +28,38 @@ "workflow": "./dist/workflow/index.js", "default": "./dist/index.js" }, - "./runtime": "./dist/runtime.js", - "./private": "./dist/private.js", - "./class-serialization": "./dist/class-serialization.js", - "./builtins": "./dist/builtins.js", - "./serialization": "./dist/serialization.js", + "./runtime": { + "types": "./dist/runtime.d.ts", + "default": "./dist/runtime.js" + }, + "./runtime/start": { + "types": "./dist/runtime/start.d.ts", + "default": "./dist/runtime/start.js" + }, + "./runtime/helpers": { + "types": "./dist/runtime/helpers.d.ts", + "default": "./dist/runtime/helpers.js" + }, + "./runtime/resume-hook": { + "types": "./dist/runtime/resume-hook.d.ts", + "default": "./dist/runtime/resume-hook.js" + }, + "./private": { + "types": "./dist/private.d.ts", + "default": "./dist/private.js" + }, + "./class-serialization": { + "types": "./dist/class-serialization.d.ts", + "default": "./dist/class-serialization.js" + }, + "./builtins": { + "types": "./dist/builtins.d.ts", + "default": "./dist/builtins.js" + }, + "./serialization": { + "types": "./dist/serialization.d.ts", + "default": "./dist/serialization.js" + }, "./observability": { "types": "./dist/observability.d.ts", "node": "./dist/observability.js", diff --git a/packages/core/runtime.d.ts b/packages/core/runtime.d.ts new file mode 100644 index 0000000000..fdd8b07b2a --- /dev/null +++ b/packages/core/runtime.d.ts @@ -0,0 +1 @@ +export * from './dist/runtime.d.ts'; diff --git a/packages/core/runtime.js b/packages/core/runtime.js new file mode 100644 index 0000000000..e83d7acab5 --- /dev/null +++ b/packages/core/runtime.js @@ -0,0 +1 @@ +export * from './dist/runtime.js'; diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 1d28b5d8a9..99e63d0951 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -47,6 +47,18 @@ export { Run, type WorkflowReadableStreamOptions, } from './runtime/run.js'; +export { + cancelRun, + listStreams, + readStream, + recreateRunFromExisting, + reenqueueRun, + type ReadStreamOptions, + type RecreateRunOptions, + type StopSleepOptions, + type StopSleepResult, + wakeUpRun, +} from './runtime/runs.js'; export { type StartOptions, start } from './runtime/start.js'; export { stepEntrypoint } from './runtime/step-handler.js'; export { diff --git a/packages/core/src/runtime/helpers.ts b/packages/core/src/runtime/helpers.ts index 2493efe3e6..be6df57458 100644 --- a/packages/core/src/runtime/helpers.ts +++ b/packages/core/src/runtime/helpers.ts @@ -13,6 +13,26 @@ import { getWorld } from './world.js'; /** Default timeout for health checks in milliseconds */ const DEFAULT_HEALTH_CHECK_TIMEOUT = 30_000; +/** + * Pattern for safe workflow names. Only allows alphanumeric characters, + * underscores, hyphens, dots, and forward slashes (for namespaced workflows). + */ +const SAFE_WORKFLOW_NAME_PATTERN = /^[a-zA-Z0-9_\-.\/]+$/; + +/** + * Validates a workflow name and returns the corresponding queue name. + * Ensures the workflow name only contains safe characters before + * interpolating it into the queue name string. + */ +export function getWorkflowQueueName(workflowName: string): ValidQueueName { + if (!SAFE_WORKFLOW_NAME_PATTERN.test(workflowName)) { + throw new Error( + `Invalid workflow name "${workflowName}": must only contain alphanumeric characters, underscores, hyphens, dots, or forward slashes` + ); + } + return `__wkf_workflow_${workflowName}` as ValidQueueName; +} + const generateId = monotonicFactory(); /** diff --git a/packages/core/src/runtime/resume-hook.ts b/packages/core/src/runtime/resume-hook.ts index b94d511038..0a28de38a3 100644 --- a/packages/core/src/runtime/resume-hook.ts +++ b/packages/core/src/runtime/resume-hook.ts @@ -14,6 +14,7 @@ import { WEBHOOK_RESPONSE_WRITABLE } from '../symbols.js'; import * as Attribute from '../telemetry/semantic-conventions.js'; import { getSpanContextForTraceCarrier, trace } from '../telemetry.js'; import { waitedUntil } from '../util.js'; +import { getWorkflowQueueName } from './helpers.js'; import { getWorld } from './world.js'; /** @@ -130,7 +131,7 @@ export async function resumeHook( // Re-trigger the workflow against the deployment ID associated // with the workflow run that the hook belongs to await world.queue( - `__wkf_workflow_${workflowRun.workflowName}`, + getWorkflowQueueName(workflowRun.workflowName), { runId: hook.runId, // attach the trace carrier from the workflow run diff --git a/packages/core/src/runtime/runs.ts b/packages/core/src/runtime/runs.ts new file mode 100644 index 0000000000..63b0568c3b --- /dev/null +++ b/packages/core/src/runtime/runs.ts @@ -0,0 +1,255 @@ +import { hydrateWorkflowArguments } from '../serialization.js'; +import { + type Event, + isLegacySpecVersion, + SPEC_VERSION_LEGACY, + type World, +} from '@workflow/world'; +import { getWorkflowQueueName } from './helpers.js'; +import { start } from './start.js'; + +export interface RecreateRunOptions { + deploymentId?: string; + specVersion?: number; +} + +export interface StopSleepResult { + /** Number of pending sleeps that were stopped */ + stoppedCount: number; +} + +export interface ReadStreamOptions { + /** + * The index to start reading from. Defaults to 0. + */ + startIndex?: 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[]; +} + +const normalizeWorkflowArgs = (args: unknown): unknown[] => { + return Array.isArray(args) ? args : [args]; +}; + +/** + * Start a new workflow run based on an existing run. + */ +export async function recreateRunFromExisting( + world: World, + runId: string, + options: RecreateRunOptions = {} +): Promise { + try { + const run = await world.runs.get(runId, { resolveData: 'all' }); + const workflowArgs = normalizeWorkflowArgs( + hydrateWorkflowArguments(run.input, globalThis) + ); + const specVersion = + options.specVersion ?? run.specVersion ?? SPEC_VERSION_LEGACY; + const deploymentId = options.deploymentId ?? run.deploymentId; + + const newRun = await start( + { workflowId: run.workflowName }, + workflowArgs as unknown[], + { + deploymentId, + world, + specVersion, + } + ); + return newRun.runId; + } catch (err) { + throw new Error( + `Failed to recreate run from ${runId}: ${err instanceof Error ? err.message : String(err)}`, + { cause: err } + ); + } +} + +/** + * Cancel a workflow run. + */ +export async function cancelRun(world: World, runId: string): Promise { + try { + const run = await world.runs.get(runId, { resolveData: 'none' }); + const specVersion = run.specVersion ?? SPEC_VERSION_LEGACY; + const compatMode = isLegacySpecVersion(specVersion); + const eventData = { + eventType: 'run_cancelled' as const, + specVersion, + }; + await world.events.create(runId, eventData, { v1Compat: compatMode }); + } catch (err) { + throw new Error( + `Failed to cancel run ${runId}: ${err instanceof Error ? err.message : String(err)}`, + { cause: err } + ); + } +} + +/** + * Re-enqueue a workflow run. + */ +export async function reenqueueRun(world: World, runId: string): Promise { + try { + const run = await world.runs.get(runId, { resolveData: 'none' }); + await world.queue( + getWorkflowQueueName(run.workflowName), + { + runId, + }, + { + deploymentId: run.deploymentId, + } + ); + } catch (err) { + throw new Error( + `Failed to re-enqueue run ${runId}: ${err instanceof Error ? err.message : String(err)}`, + { cause: err } + ); + } +} + +/** + * Wake up a workflow run by interrupting pending sleep() calls. + */ +export async function wakeUpRun( + world: World, + runId: string, + options?: StopSleepOptions +): Promise { + try { + const run = await world.runs.get(runId, { resolveData: 'none' }); + const compatMode = isLegacySpecVersion(run.specVersion); + + // Paginate through all events to ensure we don't miss any sleeps + // in long-running workflows with more than 1000 events. + const allEvents: Event[] = []; + let cursor: string | null = null; + do { + const eventsResult = await world.events.list({ + runId, + pagination: { limit: 1000, ...(cursor ? { cursor } : {}) }, + resolveData: 'none', + }); + allEvents.push(...eventsResult.data); + cursor = eventsResult.hasMore ? eventsResult.cursor : null; + } while (cursor); + + const waitCreatedEvents = allEvents.filter( + (event: Event) => event.eventType === 'wait_created' + ); + const waitCompletedCorrelationIds = new Set( + allEvents + .filter((event: Event) => event.eventType === 'wait_completed') + .map((event: Event) => event.correlationId) + ); + + let pendingWaits = waitCreatedEvents.filter( + (event: Event) => !waitCompletedCorrelationIds.has(event.correlationId) + ); + + if (options?.correlationIds && options.correlationIds.length > 0) { + const targetCorrelationIds = new Set(options.correlationIds); + pendingWaits = pendingWaits.filter( + (event: Event) => + event.correlationId && targetCorrelationIds.has(event.correlationId) + ); + } + + const errors: Error[] = []; + let stoppedCount = 0; + + for (const waitEvent of pendingWaits) { + if (!waitEvent.correlationId) continue; + const eventData = compatMode + ? { + eventType: 'wait_completed' as const, + correlationId: waitEvent.correlationId, + } + : { + eventType: 'wait_completed' as const, + correlationId: waitEvent.correlationId, + specVersion: run.specVersion, + }; + try { + await world.events.create(runId, eventData, { v1Compat: compatMode }); + stoppedCount++; + } catch (err) { + errors.push(err instanceof Error ? err : new Error(String(err))); + } + } + + if (stoppedCount > 0) { + await world.queue( + getWorkflowQueueName(run.workflowName), + { + runId, + }, + { + deploymentId: run.deploymentId, + } + ); + } + + if (errors.length > 0) { + throw new AggregateError( + errors, + `Failed to complete ${errors.length}/${pendingWaits.length} pending wait(s) for run ${runId}` + ); + } + + return { stoppedCount }; + } catch (err) { + if (err instanceof AggregateError) { + throw err; + } + throw new Error( + `Failed to wake up run ${runId}: ${err instanceof Error ? err.message : String(err)}`, + { cause: err } + ); + } +} + +/** + * Read from a stream by stream ID. + * Returns a ReadableStream of Uint8Array chunks. + */ +export async function readStream( + world: World, + streamId: string, + options?: ReadStreamOptions +): Promise> { + try { + return await world.readFromStream(streamId, options?.startIndex); + } catch (err) { + throw new Error( + `Failed to read stream ${streamId}: ${err instanceof Error ? err.message : String(err)}`, + { cause: err } + ); + } +} + +/** + * List all stream IDs for a workflow run. + */ +export async function listStreams( + world: World, + runId: string +): Promise { + try { + return await world.listStreamsByRunId(runId); + } catch (err) { + throw new Error( + `Failed to list streams for run ${runId}: ${err instanceof Error ? err.message : String(err)}`, + { cause: err } + ); + } +} diff --git a/packages/core/src/runtime/start.ts b/packages/core/src/runtime/start.ts index 13e001b452..a51e34b93f 100644 --- a/packages/core/src/runtime/start.ts +++ b/packages/core/src/runtime/start.ts @@ -10,6 +10,7 @@ import * as Attribute from '../telemetry/semantic-conventions.js'; import { serializeTraceCarrier, trace } from '../telemetry.js'; import { waitedUntil } from '../util.js'; import { version as workflowCoreVersion } from '../version.js'; +import { getWorkflowQueueName } from './helpers.js'; import { getWorld } from './world.js'; export interface StartOptions { @@ -161,7 +162,7 @@ export async function start( }); await world.queue( - `__wkf_workflow_${workflowName}`, + getWorkflowQueueName(workflowName), { runId, traceCarrier, diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index 91c024c572..65daaa33c1 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -26,6 +26,7 @@ import { import { getErrorName, getErrorStack } from '../types.js'; import { getQueueOverhead, + getWorkflowQueueName, handleHealthCheckMessage, parseHealthCheckPayload, queueMessage, @@ -246,7 +247,7 @@ const stepHandler = getWorldHandlers().createQueueHandler( // Re-invoke the workflow to handle the failed step await queueMessage( world, - `__wkf_workflow_${workflowName}`, + getWorkflowQueueName(workflowName), { runId: workflowRunId, traceCarrier: await serializeTraceCarrier(), @@ -558,7 +559,7 @@ const stepHandler = getWorldHandlers().createQueueHandler( await queueMessage( world, - `__wkf_workflow_${workflowName}`, + getWorkflowQueueName(workflowName), { runId: workflowRunId, traceCarrier: await serializeTraceCarrier(), diff --git a/packages/web-shared/README.md b/packages/web-shared/README.md index a764fe26d0..7cecfc609c 100644 --- a/packages/web-shared/README.md +++ b/packages/web-shared/README.md @@ -1,67 +1,41 @@ # @workflow/web-shared -Workflow Observability tools for NextJS. See [Workflow DevKit](https://useworkflow.dev/docs/observability) for more information. +Workflow Observability UI primitives. See [Workflow DevKit](https://useworkflow.dev/docs/observability) for more information. ## Usage -This package contains client and server code to interact with the Workflow API, as well as some pre-styled components. -If you want to deploy a full observability experience with your NextJS app, take a look at [`@workflow/web`](../web/README.md) instead, which can be self-hosted. +This package contains: +- pre-styled, prop-driven UI components (no data fetching) -You can use the API to create your own display UI, like so: +If you want a full observability experience with server actions already wired, take a look at +[`@workflow/web`](../web/README.md) instead. -```tsx -import { useWorkflowRuns } from '@workflow/web-shared'; - -export default function MyRunsList() { - const { - data, - error, - nextPage, - previousPage, - hasNextPage, - hasPreviousPage, - reload, - pageInfo, - } = useWorkflowRuns(env, { - sortOrder, - workflowName: workflowNameFilter === 'all' ? undefined : workflowNameFilter, - status: status === 'all' ? undefined : status, - }); - - // Shows an interactive trace viewer for the given run - return
{runs.map((run) => ( -
- {run.workflowName} - {run.status} - {run.startedAt} - {run.completedAt} -
- ))}
; -} -``` - -It also comes with a pre-styled interactive trace viewer that you can use to display the trace for a given run: +It comes with pre-styled UI components that accept data + callbacks: ```tsx -import { RunTraceView } from '@workflow/web-shared'; - -export default function MyRunDetailView({ env, runId }: { env: EnvMap, runId: string }) { - // ... your other code - - // Shows an interactive trace viewer for the given run - return ; +import { WorkflowTraceViewer } from '@workflow/web-shared'; + +export default function MyRunDetailView({ + run, + steps, + hooks, + events, + onSpanSelect, +}) { + return ( + + ); } ``` -## Environment Variables - -For API calls to work, you'll need to pass the same environment variables that are used by the Workflow CLI. -See `npx workflow inspect --help` for more information. - -If you're deploying this as part of your Vercel NextJS app, setting `WORKFLOW_TARGET_WORLD` to `vercel` is enough -to infer your other project details from the Vercel environment variables. - -**Important:** When using the UI to inspect different worlds, all relevant environment variables should be passed via the `EnvMap` parameter to the hooks and components, rather than setting them directly on your Next.js instance via `process.env`. The server-side World caching is based on the `EnvMap` configuration, so setting environment variables directly on `process.env` may cause cached World instances to operate with incorrect environment configuration. +Server actions and data fetching are intentionally **not** part of `web-shared`. Implement those in your app +and pass data + callbacks into these components. If you need world run helpers, use `@workflow/core/runtime`. ## Styling diff --git a/packages/web-shared/package.json b/packages/web-shared/package.json index 892c007244..b6e425b9c3 100644 --- a/packages/web-shared/package.json +++ b/packages/web-shared/package.json @@ -5,7 +5,7 @@ "private": false, "files": [ "dist", - "server" + "src" ], "publishConfig": { "access": "public" @@ -18,9 +18,16 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./server": { - "types": "./dist/api/workflow-server-actions.d.ts", - "default": "./dist/api/workflow-server-actions.js" + "./components": { + "types": "./dist/components/index.d.ts", + "default": "./dist/components/index.js" + } + }, + "typesVersions": { + "*": { + "components": [ + "dist/components/index.d.ts" + ] } }, "repository": { @@ -29,7 +36,7 @@ "directory": "packages/web-shared" }, "scripts": { - "build": "tsc && cp -r src/trace-viewer/*.css dist/trace-viewer/", + "build": "tsc && cp -r src/components/trace-viewer/*.css dist/components/trace-viewer/", "dev": "tsc --watch", "clean": "tsc --build --clean && rm -r dist ||:", "typecheck": "tsc --noEmit", @@ -39,10 +46,8 @@ "dependencies": { "@tailwindcss/postcss": "4", "@workflow/core": "workspace:*", - "@workflow/errors": "workspace:*", "@workflow/utils": "workspace:*", "@workflow/world": "workspace:*", - "@workflow/world-vercel": "workspace:*", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "color-hash": "2.0.2", @@ -53,7 +58,6 @@ "shiki": "3.13.0", "sonner": "2.0.7", "streamdown": "1.6.11", - "swr": "2.3.6", "tailwind-merge": "2.5.5", "tailwindcss": "4" }, diff --git a/packages/web-shared/server/README.md b/packages/web-shared/server/README.md deleted file mode 100644 index 5c69312cdf..0000000000 --- a/packages/web-shared/server/README.md +++ /dev/null @@ -1 +0,0 @@ -Here for backwards compatibility with "moduleResolution": "node16" in tsconfig.json of older projects. diff --git a/packages/web-shared/server/package.json b/packages/web-shared/server/package.json deleted file mode 100644 index 04491e78d1..0000000000 --- a/packages/web-shared/server/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "main": "../dist/api/workflow-server-actions.js", - "types": "../dist/api/workflow-server-actions.d.ts" -} diff --git a/packages/web-shared/src/error-boundary.tsx b/packages/web-shared/src/components/error-boundary.tsx similarity index 97% rename from packages/web-shared/src/error-boundary.tsx rename to packages/web-shared/src/components/error-boundary.tsx index 9bf79a6d49..570a991079 100644 --- a/packages/web-shared/src/error-boundary.tsx +++ b/packages/web-shared/src/components/error-boundary.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { type ReactNode } from 'react'; -import { ErrorCard } from './components/ui/error-card'; +import { ErrorCard } from './ui/error-card'; interface ErrorBoundaryProps { children: ReactNode; diff --git a/packages/web-shared/src/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx similarity index 92% rename from packages/web-shared/src/event-list-view.tsx rename to packages/web-shared/src/components/event-list-view.tsx index c86adaec10..bf390546df 100644 --- a/packages/web-shared/src/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -3,8 +3,6 @@ import type { Event } from '@workflow/world'; import { ChevronRight, Loader2 } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; -import type { EnvMap } from './api/workflow-server-actions'; -import { fetchEventsByCorrelationId } from './api/workflow-server-actions'; import { getEventColor } from './workflow-traces/event-colors'; /** @@ -44,19 +42,25 @@ function formatEventDateTime(date: Date): string { function formatEventType(eventType: Event['eventType']): string { return eventType .split('_') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } interface EventsListProps { events: Event[] | null; - env: EnvMap; + onLoadEventData?: (event: Event) => Promise; } /** * Single event row component with expandable details */ -function EventRow({ event, env }: { event: Event; env: EnvMap }) { +function EventRow({ + event, + onLoadEventData, +}: { + event: Event; + onLoadEventData?: (event: Event) => Promise; +}) { const [isExpanded, setIsExpanded] = useState(false); const [isLoading, setIsLoading] = useState(false); const [loadedEventData, setLoadedEventData] = useState(null); @@ -83,27 +87,13 @@ function EventRow({ event, env }: { event: Event; env: EnvMap }) { setLoadError(null); try { - const result = await fetchEventsByCorrelationId( - env, - event.correlationId, - { - sortOrder: 'asc', - limit: 100, - withData: true, - } - ); - - if (!result.success) { - setLoadError(result.error?.message || 'Failed to load event details'); + if (!onLoadEventData) { + setLoadError('Event details unavailable'); return; } - - // Find our specific event in the results - const fullEvent = result.data.data.find( - (e) => e.eventId === event.eventId - ); - if (fullEvent && 'eventData' in fullEvent) { - setLoadedEventData(fullEvent.eventData); + const eventData = await onLoadEventData(event); + if (eventData !== null && eventData !== undefined) { + setLoadedEventData(eventData); } } catch (err) { setLoadError( @@ -113,11 +103,10 @@ function EventRow({ event, env }: { event: Event; env: EnvMap }) { setIsLoading(false); } }, [ - env, event.correlationId, - event.eventId, loadedEventData, hasExistingEventData, + onLoadEventData, ]); // Handle expand/collapse @@ -374,7 +363,7 @@ function AttributeRow({ * Displays a list of all events for a workflow run as colored cards in a pseudo-table. * Events are sorted by createdAt (oldest first). */ -export function EventListView({ events, env }: EventsListProps) { +export function EventListView({ events, onLoadEventData }: EventsListProps) { // Sort events by createdAt (oldest first) const sortedEvents = useMemo(() => { if (!events || events.length === 0) return []; @@ -417,7 +406,11 @@ export function EventListView({ events, env }: EventsListProps) { {/* Event rows */}
{sortedEvents.map((event) => ( - + ))}
diff --git a/packages/web-shared/src/hook-actions.tsx b/packages/web-shared/src/components/hook-actions.tsx similarity index 95% rename from packages/web-shared/src/hook-actions.tsx rename to packages/web-shared/src/components/hook-actions.tsx index f50a43fe32..f0430d049a 100644 --- a/packages/web-shared/src/hook-actions.tsx +++ b/packages/web-shared/src/components/hook-actions.tsx @@ -4,8 +4,6 @@ import type { Hook, WorkflowRunStatus } from '@workflow/world'; import { Send } from 'lucide-react'; import { useCallback, useState } from 'react'; import { toast } from 'sonner'; -import { resumeHook } from './api/workflow-api-client'; -import type { EnvMap } from './api/workflow-server-actions'; import { ResolveHookModal } from './sidebar/resolve-hook-modal'; // ============================================================================ @@ -18,7 +16,7 @@ export interface HookActionCallbacks { } export interface UseHookActionsOptions { - env: EnvMap; + onResolve: (hook: Hook, payload: unknown) => Promise; callbacks?: HookActionCallbacks; } @@ -44,7 +42,7 @@ export interface UseHookActionsReturn { * Use this to coordinate the resolve modal across components. */ export function useHookActions({ - env, + onResolve, callbacks, }: UseHookActionsOptions): UseHookActionsReturn { const [isResolving, setIsResolving] = useState(false); @@ -64,7 +62,7 @@ export function useHookActions({ try { setIsResolving(true); - await resumeHook(env, selectedHook.token, payload); + await onResolve(selectedHook, payload); toast.success('Hook resolved', { description: 'The payload has been sent and the hook resolved.', }); @@ -80,7 +78,7 @@ export function useHookActions({ setIsResolving(false); } }, - [env, selectedHook, isResolving, callbacks] + [onResolve, selectedHook, isResolving, callbacks] ); return { diff --git a/packages/web-shared/src/components/index.d.ts b/packages/web-shared/src/components/index.d.ts new file mode 100644 index 0000000000..ea465c2a34 --- /dev/null +++ b/packages/web-shared/src/components/index.d.ts @@ -0,0 +1 @@ +export * from './index'; diff --git a/packages/web-shared/src/components/index.ts b/packages/web-shared/src/components/index.ts new file mode 100644 index 0000000000..19d6550814 --- /dev/null +++ b/packages/web-shared/src/components/index.ts @@ -0,0 +1,23 @@ +export { ErrorBoundary } from './error-boundary'; +export { EventListView } from './event-list-view'; +export type { + HookActionCallbacks, + HookActionsDropdownItemProps, + HookResolveModalProps, + UseHookActionsOptions, + UseHookActionsReturn, +} from './hook-actions'; +export { + HookResolveModalWrapper, + ResolveHookDropdownItem, + ResolveHookModal, + useHookActions, +} from './hook-actions'; +export { RunTraceView } from './run-trace-view'; +export { ConversationView } from './sidebar/conversation-view'; +export { StreamViewer } from './stream-viewer'; +export type { Span, SpanEvent } from './trace-viewer/types'; +export { + WorkflowTraceViewer, + type SpanSelectionInfo, +} from './workflow-trace-view'; diff --git a/packages/web-shared/src/components/run-trace-view.tsx b/packages/web-shared/src/components/run-trace-view.tsx new file mode 100644 index 0000000000..7e99602128 --- /dev/null +++ b/packages/web-shared/src/components/run-trace-view.tsx @@ -0,0 +1,75 @@ +'use client'; + +import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; +import { AlertCircle } from 'lucide-react'; +import type { SpanSelectionInfo } from './sidebar/entity-detail-panel'; +import { WorkflowTraceViewer } from './workflow-trace-view'; + +interface RunTraceViewProps { + run: WorkflowRun; + steps: Step[]; + hooks: Hook[]; + events: Event[]; + isLoading?: boolean; + error?: Error | null; + spanDetailData?: WorkflowRun | Step | Hook | Event | null; + spanDetailLoading?: boolean; + spanDetailError?: Error | null; + onWakeUpSleep?: ( + runId: string, + correlationId: string + ) => Promise<{ stoppedCount: number }>; + onResolveHook?: ( + hookToken: string, + payload: unknown, + hook?: Hook + ) => Promise; + onStreamClick?: (streamId: string) => void; + onSpanSelect?: (info: SpanSelectionInfo) => void; +} + +export function RunTraceView({ + run, + steps, + hooks, + events, + isLoading, + error, + spanDetailData, + spanDetailLoading, + spanDetailError, + onWakeUpSleep, + onResolveHook, + onStreamClick, + onSpanSelect, +}: RunTraceViewProps) { + if (error && !run) { + return ( +
+ +

Error loading workflow run

+

{error.message}

+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/packages/web-shared/src/sidebar/attribute-panel.tsx b/packages/web-shared/src/components/sidebar/attribute-panel.tsx similarity index 99% rename from packages/web-shared/src/sidebar/attribute-panel.tsx rename to packages/web-shared/src/components/sidebar/attribute-panel.tsx index 0ddfb886b9..871bc76bc8 100644 --- a/packages/web-shared/src/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/components/sidebar/attribute-panel.tsx @@ -5,9 +5,9 @@ import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; import type { ModelMessage } from 'ai'; import type { ReactNode } from 'react'; import { createContext, useContext, useMemo, useState } from 'react'; -import { ErrorCard } from '../components/ui/error-card'; -import { useDarkMode } from '../hooks/use-dark-mode'; -import { extractConversation, isDoStreamStep } from '../lib/utils'; +import { ErrorCard } from '../ui/error-card'; +import { useDarkMode } from '../../hooks/use-dark-mode'; +import { extractConversation, isDoStreamStep } from '../../lib/utils'; import { ConversationView } from './conversation-view'; import { DetailCard } from './detail-card'; diff --git a/packages/web-shared/src/sidebar/conversation-view.tsx b/packages/web-shared/src/components/sidebar/conversation-view.tsx similarity index 100% rename from packages/web-shared/src/sidebar/conversation-view.tsx rename to packages/web-shared/src/components/sidebar/conversation-view.tsx diff --git a/packages/web-shared/src/sidebar/detail-card.tsx b/packages/web-shared/src/components/sidebar/detail-card.tsx similarity index 100% rename from packages/web-shared/src/sidebar/detail-card.tsx rename to packages/web-shared/src/components/sidebar/detail-card.tsx diff --git a/packages/web-shared/src/sidebar/entity-detail-panel.tsx b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx similarity index 69% rename from packages/web-shared/src/sidebar/entity-detail-panel.tsx rename to packages/web-shared/src/components/sidebar/entity-detail-panel.tsx index b5ebc23454..1c698dc701 100644 --- a/packages/web-shared/src/sidebar/entity-detail-panel.tsx +++ b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx @@ -3,60 +3,96 @@ import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; import clsx from 'clsx'; import { Send, Zap } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; -import { - resumeHook, - unwrapServerActionResult, - useWorkflowResourceData, - wakeUpRun, -} from '../api/workflow-api-client'; -import { fetchHook, type EnvMap } from '../api/workflow-server-actions'; import { useTraceViewer } from '../trace-viewer'; import { AttributePanel } from './attribute-panel'; import { EventsList } from './events-list'; import { ResolveHookModal } from './resolve-hook-modal'; +// Type guards for runtime validation of span attribute data +function isStep(data: unknown): data is Step { + return data !== null && typeof data === 'object' && 'stepId' in data; +} + +function isWorkflowRun(data: unknown): data is WorkflowRun { + return data !== null && typeof data === 'object' && 'runId' in data; +} + +function isHook(data: unknown): data is Hook { + return data !== null && typeof data === 'object' && 'hookId' in data; +} + +/** + * Info about the currently selected span + */ +export type SpanSelectionInfo = { + resource: 'run' | 'step' | 'hook' | 'sleep'; + resourceId: string; + runId?: string; +}; + /** * Custom panel component for workflow traces that displays entity details */ export function EntityDetailPanel({ - env, run, onStreamClick, + spanDetailData, + spanDetailError, + spanDetailLoading, + onSpanSelect, + onWakeUpSleep, + onResolveHook, }: { - env: EnvMap; run: WorkflowRun; /** Callback when a stream reference is clicked */ onStreamClick?: (streamId: string) => void; + /** Pre-fetched span detail data for the selected span. */ + spanDetailData: WorkflowRun | Step | Hook | Event | null; + /** Error from external span detail fetch. */ + spanDetailError?: Error | null; + /** Loading state from external span detail fetch. */ + spanDetailLoading?: boolean; + /** Callback when a span is selected. Use this to fetch data externally and pass via spanDetailData. */ + onSpanSelect: (info: SpanSelectionInfo) => void; + /** Callback to wake up a pending sleep call. */ + onWakeUpSleep?: ( + runId: string, + correlationId: string + ) => Promise<{ stoppedCount: number }>; + /** Callback to resolve a hook with a payload. */ + onResolveHook?: ( + hookToken: string, + payload: unknown, + hook?: Hook + ) => Promise; }): React.JSX.Element | null { const { state } = useTraceViewer(); const { selected } = state; const [stoppingSleep, setStoppingSleep] = useState(false); const [showResolveHookModal, setShowResolveHookModal] = useState(false); const [resolvingHook, setResolvingHook] = useState(false); - const [resolvedHookToken, setResolvedHookToken] = useState< - string | undefined - >(undefined); - const data = selected?.span.attributes?.data as - | Step - | WorkflowRun - | Hook - | Event; + const data = selected?.span.attributes?.data; + + // Stable ref for onSpanSelect to avoid re-render loops when parent + // doesn't memoize the callback with useCallback. + const onSpanSelectRef = useRef(onSpanSelect); + useEffect(() => { + onSpanSelectRef.current = onSpanSelect; + }); // Determine resource ID and runId (needed for steps) + // Uses type guards to validate the data shape matches the expected resource type const { resource, resourceId, runId } = useMemo(() => { const resource = selected?.span.attributes?.resource; - if (resource === 'step') { - const step = data as Step; - return { resource: 'step', resourceId: step.stepId, runId: step.runId }; - } else if (resource === 'run') { - const run = data as WorkflowRun; - return { resource: 'run', resourceId: run.runId, runId: undefined }; - } else if (resource === 'hook') { - const hook = data as Hook; - return { resource: 'hook', resourceId: hook.hookId, runId: undefined }; + if (resource === 'step' && isStep(data)) { + return { resource: 'step', resourceId: data.stepId, runId: data.runId }; + } else if (resource === 'run' && isWorkflowRun(data)) { + return { resource: 'run', resourceId: data.runId, runId: undefined }; + } else if (resource === 'hook' && isHook(data)) { + return { resource: 'hook', resourceId: data.hookId, runId: undefined }; } else if (resource === 'sleep') { return { resource: 'sleep', @@ -67,6 +103,21 @@ export function EntityDetailPanel({ return { resource: undefined, resourceId: undefined, runId: undefined }; }, [selected, data]); + // Notify parent when span selection changes + useEffect(() => { + if ( + resource && + resourceId && + ['run', 'step', 'hook', 'sleep'].includes(resource) + ) { + onSpanSelectRef.current({ + resource: resource as 'run' | 'step' | 'hook' | 'sleep', + resourceId, + runId, + }); + } + }, [resource, resourceId, runId]); + // Check if this sleep is still pending and can be woken up // Requirements: no wait_completed event, resumeAt is in the future, run is not terminal const spanEvents = selected?.span.events; @@ -114,52 +165,15 @@ export function EntityDetailPanel({ return true; }, [resource, spanEvents, spanEventsLength, run.status]); - // Fetch full resource data with events - const { - data: fetchedData, - error, - loading, - } = useWorkflowResourceData( - env, - resource as 'run' | 'step' | 'hook' | 'sleep', - resourceId ?? '', - { runId } - ); - - useEffect(() => { - if (resource !== 'hook' || !resourceId) { - setResolvedHookToken(undefined); - return; - } - - let isMounted = true; - - const fetchToken = async () => { - const { error, result } = await unwrapServerActionResult( - fetchHook(env, resourceId) - ); - if (!isMounted) return; - if (error) { - console.error('Failed to fetch hook token:', error); - return; - } - setResolvedHookToken(result.token); - }; - - fetchToken(); - - return () => { - isMounted = false; - }; - }, [env, resource, resourceId]); + const error = spanDetailError ?? undefined; + const loading = spanDetailLoading ?? false; // Get the hook token for resolving (prefer fetched data when available) const hookToken = useMemo(() => { if (resource !== 'hook') return undefined; - if (resolvedHookToken) return resolvedHookToken; - const hook = (fetchedData ?? data) as Hook | undefined; - return hook?.token; - }, [resource, resolvedHookToken, fetchedData, data]); + const candidate = spanDetailData ?? data; + return isHook(candidate) ? candidate.token : undefined; + }, [resource, spanDetailData, data]); useEffect(() => { if (error && selected && resource) { @@ -171,12 +185,16 @@ export function EntityDetailPanel({ const handleWakeUp = async () => { if (stoppingSleep || !resourceId) return; + if (!onWakeUpSleep) { + toast.error('Unable to wake up sleep', { + description: 'No wake-up handler provided.', + }); + return; + } try { setStoppingSleep(true); - const result = await wakeUpRun(env, run.runId, { - correlationIds: [resourceId], - }); + const result = await onWakeUpSleep(run.runId, resourceId); if (result.stoppedCount > 0) { toast.success('Run woken up', { description: @@ -201,6 +219,12 @@ export function EntityDetailPanel({ const handleResolveHook = useCallback( async (payload: unknown) => { if (resolvingHook) return; + if (!onResolveHook) { + toast.error('Unable to resolve hook', { + description: 'No resolve handler provided.', + }); + return; + } if (!hookToken) { toast.error('Unable to resolve hook', { description: @@ -211,7 +235,9 @@ export function EntityDetailPanel({ try { setResolvingHook(true); - await resumeHook(env, hookToken, payload); + const candidate = spanDetailData ?? data; + const hook = isHook(candidate) ? candidate : undefined; + await onResolveHook(hookToken, payload, hook); toast.success('Hook resolved', { description: 'The payload has been sent and the hook resolved.', }); @@ -226,14 +252,18 @@ export function EntityDetailPanel({ setResolvingHook(false); } }, - [env, hookToken, resolvingHook] + [onResolveHook, hookToken, resolvingHook, spanDetailData, data] ); if (!selected || !resource || !resourceId) { return null; } - const displayData = fetchedData || data; + const displayData = (spanDetailData ?? data) as + | WorkflowRun + | Step + | Hook + | Event; return (
@@ -302,14 +332,7 @@ export function EntityDetailPanel({ error={error ?? undefined} onStreamClick={onStreamClick} /> - {resource !== 'run' && ( - - )} + {resource !== 'run' && }
); } diff --git a/packages/web-shared/src/sidebar/events-list.tsx b/packages/web-shared/src/components/sidebar/events-list.tsx similarity index 60% rename from packages/web-shared/src/sidebar/events-list.tsx rename to packages/web-shared/src/components/sidebar/events-list.tsx index 18fb918689..9d26f28570 100644 --- a/packages/web-shared/src/sidebar/events-list.tsx +++ b/packages/web-shared/src/components/sidebar/events-list.tsx @@ -1,77 +1,47 @@ 'use client'; -import { useCallback } from 'react'; -import useSWR from 'swr'; -import { - type EnvMap, - fetchEventsByCorrelationId, -} from '../api/workflow-server-actions'; -import { ErrorCard } from '../components/ui/error-card'; -import type { SpanEvent } from '../trace-viewer/types'; -import { convertEventsToSpanEvents } from '../workflow-traces/trace-span-construction'; +import { useMemo } from 'react'; +import { ErrorCard } from '../ui/error-card'; +import type { SpanEvent } from '../trace-viewer/types.js'; import { AttributeBlock, localMillisecondTime } from './attribute-panel'; import { DetailCard } from './detail-card'; export function EventsList({ - correlationId, - env, events, - expiredAt, + fullEvents, + isLoading = false, + error, }: { - correlationId: string; - env: EnvMap; events: SpanEvent[]; - expiredAt?: string | Date; + fullEvents?: SpanEvent[] | null; + isLoading?: boolean; + error?: Error | null; }) { - const hasExpired = expiredAt != null && new Date(expiredAt) < new Date(); - const fetchEvents = useCallback(() => { - return fetchEventsByCorrelationId(env, correlationId, { - sortOrder: 'asc', - limit: 100, - withData: !hasExpired, - }).then((evts) => { - if (!evts.success) { - throw new Error(evts.error?.message || 'Failed to fetch events'); - } - return convertEventsToSpanEvents(evts.data.data || [], false); - }); - }, [env, correlationId, hasExpired]); - - const { - data, - error: eventError, - isLoading: eventsLoading, - } = useSWR( - ['workflow', 'events', correlationId], - fetchEvents, - { - fallbackData: events, - revalidateOnFocus: false, - } + const displayData = useMemo( + () => (fullEvents?.length ? fullEvents : events) || [], + [events, fullEvents] ); - const displayData = (data?.length ? data : events) || []; - return (

- Events {!eventsLoading && `(${displayData.length})`} + Events {!isLoading && `(${displayData.length})`}

- {eventError ? ( + {error ? ( ) : null} - {eventsLoading ?
Loading events...
: null} - {!eventsLoading && !eventError && displayData.length === 0 && ( + {isLoading ?
Loading events...
: null} + {!isLoading && !error && displayData.length === 0 && (
No events found
)} - {displayData.length > 0 && !eventError ? ( + {displayData.length > 0 && !error ? (
{displayData.map((event, index) => ( ))}
- {/* Event data section */} - {eventError && ( -
- Error loading event data -
- )} - {!eventError && !eventsLoading && event.attributes.eventData && ( + {error ? ( + + ) : null} + {!error && !isLoading && event.attributes.eventData != null && (
diff --git a/packages/web-shared/src/sidebar/resolve-hook-modal.tsx b/packages/web-shared/src/components/sidebar/resolve-hook-modal.tsx similarity index 100% rename from packages/web-shared/src/sidebar/resolve-hook-modal.tsx rename to packages/web-shared/src/components/sidebar/resolve-hook-modal.tsx diff --git a/packages/web-shared/src/stream-viewer.tsx b/packages/web-shared/src/components/stream-viewer.tsx similarity index 67% rename from packages/web-shared/src/stream-viewer.tsx rename to packages/web-shared/src/components/stream-viewer.tsx index 876f9006f9..687d7d0809 100644 --- a/packages/web-shared/src/stream-viewer.tsx +++ b/packages/web-shared/src/components/stream-viewer.tsx @@ -1,12 +1,12 @@ 'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { readStream } from './api/workflow-api-client'; -import type { EnvMap } from './api/workflow-server-actions'; interface StreamViewerProps { - env: EnvMap; streamId: string; + chunks: Chunk[]; + isLive: boolean; + error?: string | null; } interface Chunk { @@ -19,15 +19,15 @@ interface Chunk { * It connects to a stream and displays chunks as they arrive, * with auto-scroll functionality. */ -export function StreamViewer({ env, streamId }: StreamViewerProps) { - const [chunks, setChunks] = useState([]); - const [isLive, setIsLive] = useState(true); +export function StreamViewer({ + streamId, + chunks, + isLive, + error, +}: StreamViewerProps) { // TODO: Handle 410 error specifically (stream expired) - const [error, setError] = useState(null); const [hasMoreBelow, setHasMoreBelow] = useState(false); const scrollRef = useRef(null); - const abortControllerRef = useRef(null); - const chunkIdRef = useRef(0); const checkScrollPosition = useCallback(() => { if (scrollRef.current) { @@ -47,71 +47,6 @@ export function StreamViewer({ env, streamId }: StreamViewerProps) { checkScrollPosition(); }, [chunks.length, checkScrollPosition]); - useEffect(() => { - let mounted = true; - abortControllerRef.current = new AbortController(); - - const handleStreamEnd = () => { - if (mounted) { - setIsLive(false); - } - }; - - const handleStreamError = (err: unknown) => { - if (mounted) { - setError(err instanceof Error ? err.message : String(err)); - setIsLive(false); - } - }; - - const addChunk = (value: unknown) => { - if (mounted && value !== undefined && value !== null) { - const chunkId = chunkIdRef.current++; - const text = - typeof value === 'string' ? value : JSON.stringify(value, null, 2); - setChunks((prev) => [...prev, { id: chunkId, text }]); - } - }; - - const processStreamChunks = async ( - reader: ReadableStreamDefaultReader - ) => { - for (;;) { - if (abortControllerRef.current?.signal.aborted) { - break; - } - - const { value, done } = await reader.read(); - - if (done) { - handleStreamEnd(); - break; - } - - addChunk(value); - } - }; - - const readStreamData = async () => { - try { - const stream = await readStream(env, streamId); - const reader = stream.getReader(); - await processStreamChunks(reader); - } catch (err) { - handleStreamError(err); - } - }; - - void readStreamData(); - - return () => { - mounted = false; - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - }; - }, [env, streamId]); - return (
diff --git a/packages/web-shared/src/trace-viewer/components/map.tsx b/packages/web-shared/src/components/trace-viewer/components/map.tsx similarity index 100% rename from packages/web-shared/src/trace-viewer/components/map.tsx rename to packages/web-shared/src/components/trace-viewer/components/map.tsx diff --git a/packages/web-shared/src/trace-viewer/components/markers.tsx b/packages/web-shared/src/components/trace-viewer/components/markers.tsx similarity index 100% rename from packages/web-shared/src/trace-viewer/components/markers.tsx rename to packages/web-shared/src/components/trace-viewer/components/markers.tsx diff --git a/packages/web-shared/src/trace-viewer/components/node.tsx b/packages/web-shared/src/components/trace-viewer/components/node.tsx similarity index 100% rename from packages/web-shared/src/trace-viewer/components/node.tsx rename to packages/web-shared/src/components/trace-viewer/components/node.tsx diff --git a/packages/web-shared/src/trace-viewer/components/search-input.tsx b/packages/web-shared/src/components/trace-viewer/components/search-input.tsx similarity index 100% rename from packages/web-shared/src/trace-viewer/components/search-input.tsx rename to packages/web-shared/src/components/trace-viewer/components/search-input.tsx diff --git a/packages/web-shared/src/trace-viewer/components/search.tsx b/packages/web-shared/src/components/trace-viewer/components/search.tsx similarity index 100% rename from packages/web-shared/src/trace-viewer/components/search.tsx rename to packages/web-shared/src/components/trace-viewer/components/search.tsx diff --git a/packages/web-shared/src/trace-viewer/components/span-detail-panel.tsx b/packages/web-shared/src/components/trace-viewer/components/span-detail-panel.tsx similarity index 100% rename from packages/web-shared/src/trace-viewer/components/span-detail-panel.tsx rename to packages/web-shared/src/components/trace-viewer/components/span-detail-panel.tsx diff --git a/packages/web-shared/src/trace-viewer/components/ui.tsx b/packages/web-shared/src/components/trace-viewer/components/ui.tsx similarity index 100% rename from packages/web-shared/src/trace-viewer/components/ui.tsx rename to packages/web-shared/src/components/trace-viewer/components/ui.tsx diff --git a/packages/web-shared/src/trace-viewer/components/zoom-button.tsx b/packages/web-shared/src/components/trace-viewer/components/zoom-button.tsx similarity index 100% rename from packages/web-shared/src/trace-viewer/components/zoom-button.tsx rename to packages/web-shared/src/components/trace-viewer/components/zoom-button.tsx diff --git a/packages/web-shared/src/trace-viewer/components/zoom-icons.tsx b/packages/web-shared/src/components/trace-viewer/components/zoom-icons.tsx similarity index 100% rename from packages/web-shared/src/trace-viewer/components/zoom-icons.tsx rename to packages/web-shared/src/components/trace-viewer/components/zoom-icons.tsx diff --git a/packages/web-shared/src/trace-viewer/context.tsx b/packages/web-shared/src/components/trace-viewer/context.tsx similarity index 100% rename from packages/web-shared/src/trace-viewer/context.tsx rename to packages/web-shared/src/components/trace-viewer/context.tsx diff --git a/packages/web-shared/src/trace-viewer/index.tsx b/packages/web-shared/src/components/trace-viewer/index.tsx similarity index 100% rename from packages/web-shared/src/trace-viewer/index.tsx rename to packages/web-shared/src/components/trace-viewer/index.tsx diff --git a/packages/web-shared/src/trace-viewer/modules.d.ts b/packages/web-shared/src/components/trace-viewer/modules.d.ts similarity index 100% rename from packages/web-shared/src/trace-viewer/modules.d.ts rename to packages/web-shared/src/components/trace-viewer/modules.d.ts diff --git a/packages/web-shared/src/trace-viewer/trace-viewer.module.css b/packages/web-shared/src/components/trace-viewer/trace-viewer.module.css similarity index 100% rename from packages/web-shared/src/trace-viewer/trace-viewer.module.css rename to packages/web-shared/src/components/trace-viewer/trace-viewer.module.css diff --git a/packages/web-shared/src/trace-viewer/trace-viewer.tsx b/packages/web-shared/src/components/trace-viewer/trace-viewer.tsx similarity index 100% rename from packages/web-shared/src/trace-viewer/trace-viewer.tsx rename to packages/web-shared/src/components/trace-viewer/trace-viewer.tsx diff --git a/packages/web-shared/src/trace-viewer/types.ts b/packages/web-shared/src/components/trace-viewer/types.ts similarity index 100% rename from packages/web-shared/src/trace-viewer/types.ts rename to packages/web-shared/src/components/trace-viewer/types.ts diff --git a/packages/web-shared/src/trace-viewer/util/constants.ts b/packages/web-shared/src/components/trace-viewer/util/constants.ts similarity index 100% rename from packages/web-shared/src/trace-viewer/util/constants.ts rename to packages/web-shared/src/components/trace-viewer/util/constants.ts diff --git a/packages/web-shared/src/trace-viewer/util/scrollbar-width.ts b/packages/web-shared/src/components/trace-viewer/util/scrollbar-width.ts similarity index 100% rename from packages/web-shared/src/trace-viewer/util/scrollbar-width.ts rename to packages/web-shared/src/components/trace-viewer/util/scrollbar-width.ts diff --git a/packages/web-shared/src/trace-viewer/util/timing.ts b/packages/web-shared/src/components/trace-viewer/util/timing.ts similarity index 94% rename from packages/web-shared/src/trace-viewer/util/timing.ts rename to packages/web-shared/src/components/trace-viewer/util/timing.ts index a46b5ff0de..858a8dd922 100644 --- a/packages/web-shared/src/trace-viewer/util/timing.ts +++ b/packages/web-shared/src/components/trace-viewer/util/timing.ts @@ -1,4 +1,4 @@ -import { formatDuration } from '../../lib/utils'; +import { formatDuration } from '../../../lib/utils'; export { formatDuration }; diff --git a/packages/web-shared/src/trace-viewer/util/tree.ts b/packages/web-shared/src/components/trace-viewer/util/tree.ts similarity index 100% rename from packages/web-shared/src/trace-viewer/util/tree.ts rename to packages/web-shared/src/components/trace-viewer/util/tree.ts diff --git a/packages/web-shared/src/trace-viewer/util/use-immediate-style.ts b/packages/web-shared/src/components/trace-viewer/util/use-immediate-style.ts similarity index 100% rename from packages/web-shared/src/trace-viewer/util/use-immediate-style.ts rename to packages/web-shared/src/components/trace-viewer/util/use-immediate-style.ts diff --git a/packages/web-shared/src/trace-viewer/util/use-streaming-spans.ts b/packages/web-shared/src/components/trace-viewer/util/use-streaming-spans.ts similarity index 100% rename from packages/web-shared/src/trace-viewer/util/use-streaming-spans.ts rename to packages/web-shared/src/components/trace-viewer/util/use-streaming-spans.ts diff --git a/packages/web-shared/src/trace-viewer/util/use-trackpad-zoom.tsx b/packages/web-shared/src/components/trace-viewer/util/use-trackpad-zoom.tsx similarity index 100% rename from packages/web-shared/src/trace-viewer/util/use-trackpad-zoom.tsx rename to packages/web-shared/src/components/trace-viewer/util/use-trackpad-zoom.tsx diff --git a/packages/web-shared/src/trace-viewer/worker.ts b/packages/web-shared/src/components/trace-viewer/worker.ts similarity index 100% rename from packages/web-shared/src/trace-viewer/worker.ts rename to packages/web-shared/src/components/trace-viewer/worker.ts diff --git a/packages/web-shared/src/components/workflow-trace-view.tsx b/packages/web-shared/src/components/workflow-trace-view.tsx new file mode 100644 index 0000000000..ee53ca3804 --- /dev/null +++ b/packages/web-shared/src/components/workflow-trace-view.tsx @@ -0,0 +1,306 @@ +'use client'; + +import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { ErrorBoundary } from './error-boundary'; +import { + EntityDetailPanel, + type SpanSelectionInfo, +} from './sidebar/entity-detail-panel'; +import { + TraceViewerContextProvider, + TraceViewerTimeline, +} from './trace-viewer'; +import type { Span } from './trace-viewer/types'; +import { Skeleton } from './ui/skeleton'; +import { + getCustomSpanClassName, + getCustomSpanEventClassName, +} from './workflow-traces/trace-colors'; +import { + hookToSpan, + runToSpan, + stepToSpan, + WORKFLOW_LIBRARY, + waitToSpan, +} from './workflow-traces/trace-span-construction'; +import { otelTimeToMs } from './workflow-traces/trace-time-utils'; + +const RE_RENDER_INTERVAL_MS = 2000; + +type GroupedEvents = { + eventsByStepId: Map; + eventsByHookId: Map; + runLevelEvents: Event[]; + timerEvents: Map; + hookEvents: Map; +}; + +const isTimerEvent = (eventType: string) => + eventType === 'wait_created' || eventType === 'wait_completed'; + +const isHookLifecycleEvent = (eventType: string) => + eventType === 'hook_received' || + eventType === 'hook_created' || + eventType === 'hook_disposed'; + +const pushEvent = ( + map: Map, + correlationId: string, + event: Event +) => { + const existing = map.get(correlationId); + if (existing) { + existing.push(event); + return; + } + map.set(correlationId, [event]); +}; + +const groupEventsByCorrelation = ( + events: Event[], + steps: Step[], + hooks: Hook[] +): GroupedEvents => { + const eventsByStepId = new Map(); + const eventsByHookId = new Map(); + const runLevelEvents: Event[] = []; + const timerEvents = new Map(); + const hookEvents = new Map(); + const stepIds = new Set(steps.map((step) => step.stepId)); + const hookIds = new Set(hooks.map((hook) => hook.hookId)); + + for (const event of events) { + const correlationId = event.correlationId; + if (!correlationId) { + runLevelEvents.push(event); + continue; + } + + if (isTimerEvent(event.eventType)) { + pushEvent(timerEvents, correlationId, event); + continue; + } + + if (isHookLifecycleEvent(event.eventType)) { + pushEvent(hookEvents, correlationId, event); + continue; + } + + if (stepIds.has(correlationId)) { + pushEvent(eventsByStepId, correlationId, event); + continue; + } + + if (hookIds.has(correlationId)) { + pushEvent(eventsByHookId, correlationId, event); + continue; + } + + runLevelEvents.push(event); + } + + return { + eventsByStepId, + eventsByHookId, + runLevelEvents, + timerEvents, + hookEvents, + }; +}; + +const buildSpans = ( + run: WorkflowRun, + steps: Step[], + groupedEvents: GroupedEvents, + now: Date +) => { + const stepSpans = steps.map((step) => { + const stepEvents = groupedEvents.eventsByStepId.get(step.stepId) || []; + return stepToSpan(step, stepEvents, now); + }); + + const hookSpans = Array.from(groupedEvents.hookEvents.values()) + .map((events) => hookToSpan(events, run, now)) + .filter((span): span is Span => span !== null); + + const waitSpans = Array.from(groupedEvents.timerEvents.values()) + .map((events) => waitToSpan(events, run, now)) + .filter((span): span is Span => span !== null); + + return { + runSpan: runToSpan(run, groupedEvents.runLevelEvents, now), + spans: [...stepSpans, ...hookSpans, ...waitSpans], + }; +}; + +const cascadeSpans = (runSpan: Span, spans: Span[]) => { + const sortedSpans = [ + runSpan, + ...spans.slice().sort((a, b) => { + const aStart = otelTimeToMs(a.startTime); + const bStart = otelTimeToMs(b.startTime); + return aStart - bStart; + }), + ]; + + return sortedSpans.map((span, index) => { + const parentSpanId = + index === 0 ? undefined : String(sortedSpans[index - 1].spanId); + return { + ...span, + parentSpanId, + }; + }); +}; + +const buildTrace = ( + run: WorkflowRun, + steps: Step[], + hooks: Hook[], + events: Event[], + now: Date +) => { + const groupedEvents = groupEventsByCorrelation(events, steps, hooks); + const { runSpan, spans } = buildSpans(run, steps, groupedEvents, now); + const sortedCascadingSpans = cascadeSpans(runSpan, spans); + + return { + traceId: run.runId, + rootSpanId: run.runId, + spans: sortedCascadingSpans, + resources: [ + { + name: 'workflow', + attributes: { + 'service.name': WORKFLOW_LIBRARY.name, + }, + }, + ], + }; +}; + +/** Re-export SpanSelectionInfo for consumers */ +export type { SpanSelectionInfo }; + +export const WorkflowTraceViewer = ({ + run, + steps, + hooks, + events, + isLoading, + error, + spanDetailData, + spanDetailLoading, + spanDetailError, + onWakeUpSleep, + onResolveHook, + onStreamClick, + onSpanSelect, +}: { + run: WorkflowRun; + steps: Step[]; + hooks: Hook[]; + events: Event[]; + isLoading?: boolean; + error?: Error | null; + spanDetailData?: WorkflowRun | Step | Hook | Event | null; + spanDetailLoading?: boolean; + spanDetailError?: Error | null; + onWakeUpSleep?: ( + runId: string, + correlationId: string + ) => Promise<{ stoppedCount: number }>; + onResolveHook?: ( + hookToken: string, + payload: unknown, + hook?: Hook + ) => Promise; + /** Callback when a stream reference is clicked in the detail panel */ + onStreamClick?: (streamId: string) => void; + /** Callback when a span is selected. */ + onSpanSelect?: (info: SpanSelectionInfo) => void; +}) => { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + if (!run?.completedAt) { + const interval = setInterval(() => { + setNow(new Date()); + }, RE_RENDER_INTERVAL_MS); + return () => clearInterval(interval); + } + return undefined; + }, [run?.completedAt]); + + const trace = useMemo(() => { + if (!run) { + return undefined; + } + return buildTrace(run, steps, hooks, events, now); + }, [run, steps, hooks, events, now]); + + useEffect(() => { + if (error && !isLoading) { + console.error(error); + toast.error('Error loading workflow trace data', { + description: error.message, + }); + } + }, [error, isLoading]); + + const DetailPanel = () => { + const handleSpanSelect = useCallback( + (info: SpanSelectionInfo) => { + onSpanSelect?.(info); + }, + [onSpanSelect] + ); + + return ( + + ); + }; + + if (isLoading || !trace) { + return ( +
+
+ +
+ + + + +
+
+ ); + } + + return ( +
+ + + + } + > + + +
+ ); +}; diff --git a/packages/web-shared/src/workflow-traces/event-colors.ts b/packages/web-shared/src/components/workflow-traces/event-colors.ts similarity index 100% rename from packages/web-shared/src/workflow-traces/event-colors.ts rename to packages/web-shared/src/components/workflow-traces/event-colors.ts diff --git a/packages/web-shared/src/workflow-traces/trace-colors.ts b/packages/web-shared/src/components/workflow-traces/trace-colors.ts similarity index 100% rename from packages/web-shared/src/workflow-traces/trace-colors.ts rename to packages/web-shared/src/components/workflow-traces/trace-colors.ts diff --git a/packages/web-shared/src/workflow-traces/trace-span-construction.ts b/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts similarity index 100% rename from packages/web-shared/src/workflow-traces/trace-span-construction.ts rename to packages/web-shared/src/components/workflow-traces/trace-span-construction.ts diff --git a/packages/web-shared/src/workflow-traces/trace-time-utils.ts b/packages/web-shared/src/components/workflow-traces/trace-time-utils.ts similarity index 100% rename from packages/web-shared/src/workflow-traces/trace-time-utils.ts rename to packages/web-shared/src/components/workflow-traces/trace-time-utils.ts diff --git a/packages/web-shared/src/index.d.ts b/packages/web-shared/src/index.d.ts new file mode 100644 index 0000000000..ea465c2a34 --- /dev/null +++ b/packages/web-shared/src/index.d.ts @@ -0,0 +1 @@ +export * from './index'; diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index adc6a96385..7a5084f10b 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -4,32 +4,6 @@ export { } from '@workflow/utils/parse-name'; export type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; -export * from './api/workflow-api-client'; -export type { - EnvMap, - HealthCheckResultWithLatency, - PublicServerConfig, -} from './api/workflow-server-actions'; -export { runHealthCheck } from './api/workflow-server-actions'; -export type { - HealthCheckEndpoint, - HealthCheckResult, -} from '@workflow/core/runtime'; -export { ErrorBoundary } from './error-boundary'; -export { EventListView } from './event-list-view'; -export type { - HookActionCallbacks, - HookActionsDropdownItemProps, - HookResolveModalProps, - UseHookActionsOptions, - UseHookActionsReturn, -} from './hook-actions'; -export { - HookResolveModalWrapper, - ResolveHookDropdownItem, - ResolveHookModal, - useHookActions, -} from './hook-actions'; export type { EventAnalysis } from './lib/event-analysis'; export { analyzeEvents, @@ -45,8 +19,8 @@ export { identifyStreamSteps, isDoStreamStep, } from './lib/utils'; -export { RunTraceView } from './run-trace-view'; -export { ConversationView } from './sidebar/conversation-view'; -export { StreamViewer } from './stream-viewer'; -export type { Span, SpanEvent } from './trace-viewer/types'; -export { WorkflowTraceViewer } from './workflow-trace-view'; +export * from './components'; +export { + hookEventsToHookEntity, + waitEventsToWaitEntity, +} from './components/workflow-traces/trace-span-construction'; diff --git a/packages/web-shared/src/run-trace-view.tsx b/packages/web-shared/src/run-trace-view.tsx deleted file mode 100644 index f5b8776784..0000000000 --- a/packages/web-shared/src/run-trace-view.tsx +++ /dev/null @@ -1,49 +0,0 @@ -'use client'; - -import type { WorkflowRun } from '@workflow/world'; -import { AlertCircle } from 'lucide-react'; -import { useWorkflowTraceViewerData } from './api/workflow-api-client'; -import type { EnvMap } from './api/workflow-server-actions'; -import { WorkflowTraceViewer } from './workflow-trace-view'; - -interface RunTraceViewProps { - env: EnvMap; - runId: string; -} - -export function RunTraceView({ env, runId }: RunTraceViewProps) { - // Fetch all run data with live updates - const { - run: runData, - steps: allSteps, - hooks: allHooks, - events: allEvents, - loading, - error, - } = useWorkflowTraceViewerData(env, runId, { live: true }); - const run = runData ?? ({} as WorkflowRun); - - if (error && !runData) { - return ( -
- -

Error loading workflow run

-

{error.message}

-
- ); - } - - return ( -
- -
- ); -} diff --git a/packages/web-shared/src/trace-viewer/README.md b/packages/web-shared/src/trace-viewer/README.md deleted file mode 100644 index 5d4d3029bb..0000000000 --- a/packages/web-shared/src/trace-viewer/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Trace Viewer - -This is a generic trace viewer component for OpenTelemetry-style traces, -agnostic to use-case. - -## Usage - -```tsx -import { TraceViewer } from './trace-viewer'; - - -``` - -See [types.ts](./types.ts) for details on props and types. - diff --git a/packages/web-shared/src/workflow-trace-view.tsx b/packages/web-shared/src/workflow-trace-view.tsx deleted file mode 100644 index bc79657046..0000000000 --- a/packages/web-shared/src/workflow-trace-view.tsx +++ /dev/null @@ -1,215 +0,0 @@ -'use client'; - -import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; -import { useEffect, useMemo, useState } from 'react'; -import { toast } from 'sonner'; -import type { EnvMap } from './api/workflow-server-actions'; -import { Skeleton } from './components/ui/skeleton'; -import { ErrorBoundary } from './error-boundary'; -import { EntityDetailPanel } from './sidebar/entity-detail-panel'; -import { - TraceViewerContextProvider, - TraceViewerTimeline, -} from './trace-viewer'; -import { - getCustomSpanClassName, - getCustomSpanEventClassName, -} from './workflow-traces/trace-colors'; -import { - hookToSpan, - runToSpan, - stepToSpan, - WORKFLOW_LIBRARY, - waitToSpan, -} from './workflow-traces/trace-span-construction'; -import { otelTimeToMs } from './workflow-traces/trace-time-utils'; - -const RE_RENDER_INTERVAL_MS = 2000; - -export const WorkflowTraceViewer = ({ - run, - steps, - hooks, - events, - env, - isLoading, - error, - onStreamClick, -}: { - run: WorkflowRun; - steps: Step[]; - hooks: Hook[]; - events: Event[]; - env: EnvMap; - isLoading?: boolean; - error?: Error | null; - /** Callback when a stream reference is clicked in the detail panel */ - onStreamClick?: (streamId: string) => void; -}) => { - const [now, setNow] = useState(() => new Date()); - - useEffect(() => { - if (!run?.completedAt) { - const interval = setInterval(() => { - setNow(new Date()); - }, RE_RENDER_INTERVAL_MS); - return () => clearInterval(interval); - } - return undefined; - }, [run?.completedAt]); - - const trace = useMemo(() => { - if (!run) { - return undefined; - } - // Group events by their correlation ID to associate with steps/hooks - const eventsByStepId = new Map(); - const eventsByHookId = new Map(); - const runLevelEvents: Event[] = []; - const timerEvents = new Map(); - const hookEvents = new Map(); - - for (const event of events) { - if ( - event.eventType === 'wait_created' || - event.eventType === 'wait_completed' - ) { - const existing = timerEvents.get(event.correlationId) || []; - existing.push(event); - timerEvents.set(event.correlationId, existing); - continue; - } - - if ( - event.eventType === 'hook_received' || - event.eventType === 'hook_created' || - event.eventType === 'hook_disposed' - ) { - const existing = hookEvents.get(event.correlationId) || []; - existing.push(event); - hookEvents.set(event.correlationId, existing); - continue; - } - // Try to associate event with a step or hook via correlationId - // For now, all other events are collected at run level - const correlationId = event.correlationId; - if (correlationId) { - // Check if correlation ID matches a step or hook - const matchingStep = steps.find((s) => s.stepId === correlationId); - const matchingHook = hooks.find((h) => h.hookId === correlationId); - - if (matchingStep) { - const existing = eventsByStepId.get(correlationId) || []; - existing.push(event); - eventsByStepId.set(correlationId, existing); - } else if (matchingHook) { - const existing = eventsByHookId.get(correlationId) || []; - existing.push(event); - eventsByHookId.set(correlationId, existing); - } else { - runLevelEvents.push(event); - } - } else { - runLevelEvents.push(event); - } - } - - // Chain steps together so each one appears on its own row - // First step is child of root, each subsequent step is child of previous - const stepSpans = steps.map((step) => { - const stepEvents = eventsByStepId.get(step.stepId) || []; - return stepToSpan(step, stepEvents, now); - }); - - const hookSpans = Array.from(hookEvents.entries()) - .map(([_, events]) => { - return hookToSpan(events, run, now); - }) - .filter((span) => span !== null); - - const waitSpans = Array.from(timerEvents.entries()) - .map(([_, events]) => { - return waitToSpan(events, run, now); - }) - .filter((span) => span !== null); - - const runSpan = runToSpan(run, runLevelEvents, now); - const spans = [...stepSpans, ...hookSpans, ...waitSpans]; - const sortedSpans = [ - runSpan, - ...spans.slice().sort((a, b) => { - const aStart = otelTimeToMs(a.startTime); - const bStart = otelTimeToMs(b.startTime); - return aStart - bStart; - }), - ]; - - const sortedCascadingSpans = sortedSpans.map((span, index) => { - const parentSpanId = - index === 0 ? undefined : String(sortedSpans[index - 1].spanId); - return { - ...span, - parentSpanId, - }; - }); - - return { - traceId: run.runId, - rootSpanId: run.runId, - spans: sortedCascadingSpans, - resources: [ - { - name: 'workflow', - attributes: { - 'service.name': WORKFLOW_LIBRARY.name, - }, - }, - ], - }; - }, [run, steps, hooks, events, now]); - - useEffect(() => { - if (error && !isLoading) { - console.error(error); - toast.error('Error loading workflow trace data', { - description: error.message, - }); - } - }, [error, isLoading]); - - if (isLoading || !trace) { - return ( -
-
- -
- - - - -
-
- ); - } - - return ( -
- - - - } - > - - -
- ); -}; diff --git a/packages/web/package.json b/packages/web/package.json index ffccf32348..16dab824cf 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -46,9 +46,11 @@ "@types/react": "19", "@types/react-dom": "19", "@workflow/core": "workspace:*", + "@workflow/errors": "4.1.0-beta.14", "@workflow/utils": "workspace:*", "@workflow/web-shared": "workspace:*", "@workflow/world": "workspace:*", + "@workflow/world-vercel": "4.1.0-beta.29", "@xyflow/react": "12.9.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", diff --git a/packages/web/src/app/layout-client.tsx b/packages/web/src/app/layout-client.tsx index b163c828d9..ad4ba47841 100644 --- a/packages/web/src/app/layout-client.tsx +++ b/packages/web/src/app/layout-client.tsx @@ -1,7 +1,7 @@ 'use client'; import { TooltipProvider } from '@radix-ui/react-tooltip'; -import type { PublicServerConfig } from '@workflow/web-shared/server'; +import type { PublicServerConfig } from '@/server/workflow-server-actions'; import Link from 'next/link'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { ThemeProvider, useTheme } from 'next-themes'; diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index a993249242..837fc72cac 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import './globals.css'; -import { getPublicServerConfig } from '@workflow/web-shared/server'; +import { getPublicServerConfig } from '@/server/workflow-server-actions'; import { connection } from 'next/server'; import { NuqsAdapter } from 'nuqs/adapters/next/app'; import { LayoutClient } from './layout-client'; diff --git a/packages/web/src/components/display-utils/health-check-button.tsx b/packages/web/src/components/display-utils/health-check-button.tsx index a3fc5bb826..59cbaef441 100644 --- a/packages/web/src/components/display-utils/health-check-button.tsx +++ b/packages/web/src/components/display-utils/health-check-button.tsx @@ -5,7 +5,7 @@ import { type HealthCheckEndpoint, type HealthCheckResultWithLatency, runHealthCheck, -} from '@workflow/web-shared'; +} from '@/server/workflow-server-actions'; import { Activity, Loader2 } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; diff --git a/packages/web/src/components/flow-graph/workflow-graph-execution-viewer.tsx b/packages/web/src/components/flow-graph/workflow-graph-execution-viewer.tsx index 2793edf314..794d9f026e 100644 --- a/packages/web/src/components/flow-graph/workflow-graph-execution-viewer.tsx +++ b/packages/web/src/components/flow-graph/workflow-graph-execution-viewer.tsx @@ -18,11 +18,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import '@xyflow/react/dist/style.css'; import { GitBranch, Loader2, X } from 'lucide-react'; import './workflow-graph-viewer.css'; -import { - type EnvMap, - formatDuration, - useWorkflowResourceData, -} from '@workflow/web-shared'; +import { formatDuration } from '@workflow/web-shared'; +import type { EnvMap } from '@/server/workflow-server-actions'; +import { useWorkflowResourceData } from '@/lib/workflow-api-client'; import { StatusBadge } from '@/components/display-utils/status-badge'; import { Badge } from '@/components/ui/badge'; import type { diff --git a/packages/web/src/components/hooks-table.tsx b/packages/web/src/components/hooks-table.tsx index 50547725d6..1abe1f3049 100644 --- a/packages/web/src/components/hooks-table.tsx +++ b/packages/web/src/components/hooks-table.tsx @@ -1,14 +1,10 @@ 'use client'; import { - type EnvMap, - getErrorMessage, HookResolveModalWrapper, ResolveHookDropdownItem, useHookActions, - useWorkflowHooks, } from '@workflow/web-shared'; -import { fetchEventsByCorrelationId } from '@workflow/web-shared/server'; import type { Event, Hook } from '@workflow/world'; import { AlertCircle, @@ -44,6 +40,13 @@ import { import { CopyableText } from './display-utils/copyable-text'; import { RelativeTime } from './display-utils/relative-time'; import { TableSkeleton } from './display-utils/table-skeleton'; +import { + getErrorMessage, + resumeHook, + useWorkflowHooks, +} from '@/lib/workflow-api-client'; +import type { EnvMap } from '@/server/workflow-server-actions'; +import { fetchEventsByCorrelationId } from '@/server/workflow-server-actions'; interface HooksTableProps { runId?: string; @@ -92,7 +95,9 @@ export function HooksTable({ // Hook actions for resolve functionality const hookActions = useHookActions({ - env, + onResolve: async (hook, payload) => { + await resumeHook(env, hook.token, payload); + }, callbacks: { onSuccess: refresh, }, diff --git a/packages/web/src/components/run-actions.tsx b/packages/web/src/components/run-actions.tsx index f3bf271db2..e0cbed3150 100644 --- a/packages/web/src/components/run-actions.tsx +++ b/packages/web/src/components/run-actions.tsx @@ -1,18 +1,9 @@ 'use client'; -import { - analyzeEvents, - cancelRun, - type EnvMap, - type Event, - recreateRun, - reenqueueRun, - wakeUpRun, -} from '@workflow/web-shared'; -import type { WorkflowRunStatus } from '@workflow/world'; +import { analyzeEvents } from '@workflow/web-shared'; +import type { Event, WorkflowRunStatus } from '@workflow/world'; import { AlarmClockOff, - Loader2, MoreHorizontal, RotateCw, XCircle, @@ -31,6 +22,13 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; +import { + cancelRun, + recreateRun, + reenqueueRun, + wakeUpRun, +} from '@/lib/workflow-api-client'; +import type { EnvMap } from '@/server/workflow-server-actions'; import { Button } from './ui/button'; // ============================================================================ diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index 915f5f8da5..28b1a2804b 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -2,19 +2,13 @@ import { parseWorkflowName } from '@workflow/utils/parse-name'; import { - cancelRun, - type EnvMap, ErrorBoundary, - type Event, EventListView, - recreateRun, - type Step, StreamViewer, - useWorkflowStreams, - useWorkflowTraceViewerData, - type WorkflowRun, WorkflowTraceViewer, } from '@workflow/web-shared'; +import type { SpanSelectionInfo } from '@workflow/web-shared'; +import type { Event, Step, WorkflowRun } from '@workflow/world'; import { AlertCircle, GitBranch, @@ -53,6 +47,19 @@ import { } from '@/components/ui/tooltip'; import { mapRunToExecution } from '@/lib/flow-graph/graph-execution-mapper'; import { useWorkflowGraphManifest } from '@/lib/flow-graph/use-workflow-graph'; +import { + cancelRun, + recreateRun, + resumeHook, + unwrapServerActionResult, + useWorkflowResourceData, + useWorkflowStreams, + useWorkflowTraceViewerData, + wakeUpRun, +} from '@/lib/workflow-api-client'; +import type { EnvMap } from '@/server/workflow-server-actions'; +import { fetchEventsByCorrelationId } from '@/server/workflow-server-actions'; +import { useStreamReader } from '@/lib/hooks/use-stream-reader'; import { useServerConfig } from '@/lib/world-config-context'; import { CopyableText } from './display-utils/copyable-text'; @@ -219,6 +226,44 @@ export function RunDetailView({ [updateSearchParams] ); + const handleWakeUpSleep = useCallback( + async (runId: string, correlationId: string) => { + return wakeUpRun(env, runId, { correlationIds: [correlationId] }); + }, + [env] + ); + + const handleResolveHook = useCallback( + async (hookToken: string, payload: unknown) => { + await resumeHook(env, hookToken, payload); + }, + [env] + ); + + const handleLoadEventData = useCallback( + async (event: Event) => { + if (!event.correlationId) { + return null; + } + const { error, result } = await unwrapServerActionResult( + fetchEventsByCorrelationId(env, event.correlationId, { + sortOrder: 'asc', + limit: 100, + withData: true, + }) + ); + if (error) { + throw error; + } + const fullEvent = result.data.find((e) => e.eventId === event.eventId); + if (fullEvent && 'eventData' in fullEvent) { + return fullEvent.eventData; + } + return null; + }, + [env] + ); + // Only show graph tab for local backend const isLocalBackend = serverConfig.backendId === 'local' || @@ -237,6 +282,27 @@ export function RunDetailView({ } = useWorkflowTraceViewerData(env, runId, { live: true }); const run = runData ?? ({} as WorkflowRun); + const [spanSelection, setSpanSelection] = useState( + null + ); + const { + data: spanDetailData, + loading: spanDetailLoading, + error: spanDetailError, + } = useWorkflowResourceData( + env, + spanSelection?.resource ?? 'run', + spanSelection?.resourceId ?? '', + { + runId: spanSelection?.runId, + enabled: Boolean(spanSelection?.resource && spanSelection?.resourceId), + } + ); + + const handleSpanSelect = useCallback((info: SpanSelectionInfo) => { + setSpanSelection(info); + }, []); + // Fetch streams for this run const { streams, @@ -244,6 +310,12 @@ export function RunDetailView({ error: streamsError, } = useWorkflowStreams(env, runId); + const { + chunks: streamChunks, + isLive: streamIsLive, + error: streamError, + } = useStreamReader(env, selectedStreamId); + const handleCancelClick = () => { setShowCancelDialog(true); }; @@ -559,10 +631,15 @@ export function RunDetailView({ steps={allSteps} events={allEvents} hooks={allHooks} - env={env} run={run} isLoading={loading} + spanDetailData={spanDetailData} + spanDetailLoading={spanDetailLoading} + spanDetailError={spanDetailError} + onSpanSelect={handleSpanSelect} onStreamClick={handleStreamClick} + onWakeUpSleep={handleWakeUpSleep} + onResolveHook={handleResolveHook} />
@@ -571,7 +648,10 @@ export function RunDetailView({
- +
@@ -638,7 +718,12 @@ export function RunDetailView({ {/* Stream viewer */}
{selectedStreamId ? ( - + ) : (
([]); + const [isLive, setIsLive] = useState(false); + const [error, setError] = useState(null); + const abortControllerRef = useRef(null); + const chunkIdRef = useRef(0); + + useEffect(() => { + setChunks([]); + setError(null); + chunkIdRef.current = 0; + + if (!streamId) { + setIsLive(false); + return; + } + + let mounted = true; + abortControllerRef.current = new AbortController(); + setIsLive(true); + + const handleStreamEnd = () => { + if (mounted) { + setIsLive(false); + } + }; + + const handleStreamError = (err: unknown) => { + if (mounted) { + setError(err instanceof Error ? err.message : String(err)); + setIsLive(false); + } + }; + + const addChunk = (value: unknown) => { + if (mounted && value !== undefined && value !== null) { + const chunkId = chunkIdRef.current++; + const text = + typeof value === 'string' ? value : JSON.stringify(value, null, 2); + setChunks((prev) => [...prev, { id: chunkId, text }]); + } + }; + + const processStreamChunks = async ( + reader: ReadableStreamDefaultReader + ) => { + for (;;) { + if (abortControllerRef.current?.signal.aborted) { + break; + } + + const { value, done } = await reader.read(); + + if (done) { + handleStreamEnd(); + break; + } + + addChunk(value); + } + }; + + const readStreamData = async () => { + try { + const stream = await readStream(env, streamId); + const reader = stream.getReader(); + await processStreamChunks(reader); + } catch (err) { + handleStreamError(err); + } + }; + + void readStreamData(); + + return () => { + mounted = false; + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, [env, streamId]); + + return { + chunks, + isLive, + error, + }; +} diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web/src/lib/workflow-api-client.ts similarity index 96% rename from packages/web-shared/src/api/workflow-api-client.ts rename to packages/web/src/lib/workflow-api-client.ts index cb81df0baf..52ea1b3464 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web/src/lib/workflow-api-client.ts @@ -9,12 +9,12 @@ import type { WorkflowRunStatus, } from '@workflow/world'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { getPaginationDisplay } from '../lib/utils'; -import { - hookEventsToHookEntity, - waitEventsToWaitEntity, -} from '../workflow-traces/trace-span-construction'; -import type { EnvMap, ServerActionError } from './workflow-server-actions'; +import { getPaginationDisplay } from './utils'; +import { waitEventsToWaitEntity } from '@workflow/web-shared'; +import type { + EnvMap, + ServerActionError, +} from '@/server/workflow-server-actions'; import { cancelRun as cancelRunServerAction, fetchEvents, @@ -34,7 +34,7 @@ import { type StopSleepOptions, type StopSleepResult, wakeUpRun as wakeUpRunServerAction, -} from './workflow-server-actions'; +} from '@/server/workflow-server-actions'; const MAX_ITEMS = 1000; const LIVE_POLL_LIMIT = 10; @@ -962,7 +962,10 @@ async function fetchResourceWithCorrelationId( env: EnvMap, resource: 'run' | 'step' | 'hook', resourceId: string, - options: { runId?: string; resolveData?: 'none' | 'all' } = {} + options: { + runId?: string; + resolveData?: 'none' | 'all'; + } = {} ): Promise<{ data: WorkflowRun | Step | Hook; correlationId: string; @@ -1021,21 +1024,41 @@ export function useWorkflowResourceData( env: EnvMap, resource: 'run' | 'step' | 'hook' | 'sleep', resourceId: string, - options: { refreshInterval?: number; runId?: string } = {} + options: { + refreshInterval?: number; + runId?: string; + /** If false, skip fetching (useful when data is provided externally) */ + enabled?: boolean; + } = {} ) { - const { refreshInterval = 0, runId } = options; + const { refreshInterval = 0, runId, enabled = true } = options; const [data, setData] = useState( null ); // const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(enabled); const [error, setError] = useState(null); const fetchData = useCallback(async () => { + if (!enabled) { + setLoading(false); + return; + } setData(null); setError(null); - if (resource === 'hook' || resource === 'sleep') { + if (resource === 'hook') { + const { error, result } = await unwrapServerActionResult( + fetchHook(env, resourceId, 'all') + ); + if (error) { + setError(error); + return; + } + setData(result); + return; + } + if (resource === 'sleep') { const { error, result } = await unwrapServerActionResult( fetchEventsByCorrelationId(env, resourceId, { sortOrder: 'asc', @@ -1048,10 +1071,7 @@ export function useWorkflowResourceData( return; } const events = result.data as unknown as Event[]; - const data = - resource === 'hook' - ? hookEventsToHookEntity(events) - : waitEventsToWaitEntity(events); + const data = waitEventsToWaitEntity(events); if (data === null) { setError( new Error( @@ -1070,9 +1090,7 @@ export function useWorkflowResourceData( env, resource, resourceId, - { - runId, - } + { runId } ); setData(resourceData); } catch (error: unknown) { @@ -1085,14 +1103,7 @@ export function useWorkflowResourceData( } finally { setLoading(false); } - - // // Fetch events by correlation ID - // const eventsData = await fetchAllEventsByCorrelationId( - // env, - // correlationId - // ); - // setEvents(eventsData); - }, [env, resource, resourceId, runId]); + }, [env, resource, resourceId, runId, enabled]); // Initial load useEffect(() => { diff --git a/packages/web/src/lib/world-config-context.tsx b/packages/web/src/lib/world-config-context.tsx index 56373982b7..1c1921689b 100644 --- a/packages/web/src/lib/world-config-context.tsx +++ b/packages/web/src/lib/world-config-context.tsx @@ -1,10 +1,10 @@ 'use client'; -import type { PublicServerConfig } from '@workflow/web-shared/server'; +import type { PublicServerConfig } from '@/server/workflow-server-actions'; import { createContext, type ReactNode, useContext } from 'react'; // Re-export PublicServerConfig for convenience -export type { PublicServerConfig } from '@workflow/web-shared/server'; +export type { PublicServerConfig } from '@/server/workflow-server-actions'; /** * Context value providing server configuration info to the UI. @@ -35,7 +35,7 @@ interface ServerConfigProviderProps { * Provider component that makes server configuration available to child components. * * The serverConfig should be fetched during server-side rendering using - * getPublicServerConfig() from @workflow/web-shared/server. + * getPublicServerConfig() from @/server/workflow-server-actions. */ export function ServerConfigProvider({ children, diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web/src/server/workflow-server-actions.ts similarity index 90% rename from packages/web-shared/src/api/workflow-server-actions.ts rename to packages/web/src/server/workflow-server-actions.ts index 3122cb4217..bc525bda83 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web/src/server/workflow-server-actions.ts @@ -3,14 +3,14 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { hydrateResourceIO } from '@workflow/core/observability'; +import { createWorld } from '@workflow/core/runtime'; +import * as workflowRunHelpers from '@workflow/core/runtime'; import { - createWorld, type HealthCheckEndpoint, type HealthCheckResult, healthCheck, - resumeHook as resumeHookRuntime, - start, -} from '@workflow/core/runtime'; +} from '@workflow/core/runtime/helpers'; +import { resumeHook as resumeHookRuntime } from '@workflow/core/runtime/resume-hook'; import { getDeserializeStream, getExternalRevivers, @@ -20,14 +20,17 @@ import { findWorkflowDataDir } from '@workflow/utils/check-data-dir'; import { type Event, type Hook, - isLegacySpecVersion, - SPEC_VERSION_LEGACY, type Step, type WorkflowRun, type WorkflowRunStatus, type World, } from '@workflow/world'; -import { createVercelWorld } from '@workflow/world-vercel'; +import { + type APIConfig, + createQueue, + createStorage, + createStreamer, +} from '@workflow/world-vercel'; /** * Environment variable map for world configuration. @@ -40,6 +43,14 @@ import { createVercelWorld } from '@workflow/world-vercel'; */ export type EnvMap = Record; +function createVercelWorld(config?: APIConfig): World { + return { + ...createQueue(config), + ...createStorage(config), + ...createStreamer(config), + }; +} + /** * Public configuration info that is safe to send to the client. * @@ -817,13 +828,7 @@ export async function cancelRun( ): Promise> { try { const world = await getWorldFromEnv(worldEnv); - const run = await world.runs.get(runId, { resolveData: 'none' }); - const compatMode = isLegacySpecVersion(run.specVersion); - const eventData = { - eventType: 'run_cancelled' as const, - specVersion: run.specVersion || 1, - }; - await world.events.create(runId, eventData, { v1Compat: compatMode }); + await workflowRunHelpers.cancelRun(world, runId); return createResponse(undefined); } catch (error) { return createServerActionError(error, 'world.events.create', { @@ -844,21 +849,14 @@ export async function recreateRun( ): Promise> { try { const world = await getWorldFromEnv({ ...worldEnv }); - const run = await world.runs.get(runId); - // Get original input/output - const hydratedRun = hydrate(run as WorkflowRun); - - // Preserve original specVersion - if undefined (legacy v1), use SPEC_VERSION_LEGACY - const newRun = await start( - { workflowId: run.workflowName }, - hydratedRun.input as unknown as unknown[], + const newRunId = await workflowRunHelpers.recreateRunFromExisting( + world, + runId, { - deploymentId: deploymentId ?? run.deploymentId, - world, - specVersion: run.specVersion ?? SPEC_VERSION_LEGACY, + deploymentId, } ); - return createResponse(newRun.runId); + return createResponse(newRunId); } catch (error) { return createServerActionError(error, 'recreateRun', { runId }); } @@ -876,19 +874,7 @@ export async function reenqueueRun( ): Promise> { try { const world = await getWorldFromEnv({ ...worldEnv }); - const run = await world.runs.get(runId); - const deploymentId = run.deploymentId; - - await world.queue( - `__wkf_workflow_${run.workflowName}`, - { - runId, - }, - { - deploymentId, - } - ); - + await workflowRunHelpers.reenqueueRun(world, runId); return createResponse(undefined); } catch (error) { return createServerActionError(error, 'reenqueueRun', { runId }); @@ -926,71 +912,8 @@ export async function wakeUpRun( ): Promise> { try { const world = await getWorldFromEnv({ ...worldEnv }); - const run = await world.runs.get(runId); - const deploymentId = run.deploymentId; - const compatMode = isLegacySpecVersion(run.specVersion); - - // Fetch all events for the run - const eventsResult = await world.events.list({ - runId, - pagination: { limit: 1000 }, - resolveData: 'none', - }); - - // Find wait_created events without matching wait_completed events - const waitCreatedEvents = eventsResult.data.filter( - (e) => e.eventType === 'wait_created' - ); - const waitCompletedCorrelationIds = new Set( - eventsResult.data - .filter((e) => e.eventType === 'wait_completed') - .map((e) => e.correlationId) - ); - - let pendingWaits = waitCreatedEvents.filter( - (e) => !waitCompletedCorrelationIds.has(e.correlationId) - ); - - // If specific correlation IDs are provided, filter to only those - if (options?.correlationIds && options.correlationIds.length > 0) { - const targetCorrelationIds = new Set(options.correlationIds); - pendingWaits = pendingWaits.filter( - (e) => e.correlationId && targetCorrelationIds.has(e.correlationId) - ); - } - - // Create wait_completed events for each pending wait - for (const waitEvent of pendingWaits) { - if (waitEvent.correlationId) { - // For v2, include specVersion in event data; for v1Compat, it's not needed - const eventData = compatMode - ? { - eventType: 'wait_completed' as const, - correlationId: waitEvent.correlationId, - } - : { - eventType: 'wait_completed' as const, - correlationId: waitEvent.correlationId, - specVersion: run.specVersion, - }; - await world.events.create(runId, eventData, { v1Compat: compatMode }); - } - } - - // Re-enqueue the run to wake it up - if (pendingWaits.length > 0) { - await world.queue( - `__wkf_workflow_${run.workflowName}`, - { - runId, - }, - { - deploymentId, - } - ); - } - - return createResponse({ stoppedCount: pendingWaits.length }); + const result = await workflowRunHelpers.wakeUpRun(world, runId, options); + return createResponse(result); } catch (error) { return createServerActionError(error, 'wakeUpRun', { runId, @@ -1159,6 +1082,8 @@ export interface HealthCheckResultWithLatency extends HealthCheckResult { latencyMs: number; } +export type { HealthCheckEndpoint, HealthCheckResult }; + /** * Run a queue-based health check on a workflow endpoint. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed77517b01..5b94e20d49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -995,6 +995,9 @@ importers: '@workflow/core': specifier: workspace:* version: link:../core + '@workflow/errors': + specifier: 4.1.0-beta.14 + version: 4.1.0-beta.14 '@workflow/utils': specifier: workspace:* version: link:../utils @@ -1004,6 +1007,9 @@ importers: '@workflow/world': specifier: workspace:* version: link:../world + '@workflow/world-vercel': + specifier: 4.1.0-beta.29 + version: 4.1.0-beta.29 '@xyflow/react': specifier: 12.9.3 version: 12.9.3(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1058,18 +1064,12 @@ importers: '@workflow/core': specifier: workspace:* version: link:../core - '@workflow/errors': - specifier: workspace:* - version: link:../errors '@workflow/utils': specifier: workspace:* version: link:../utils '@workflow/world': specifier: workspace:* version: link:../world - '@workflow/world-vercel': - specifier: workspace:* - version: link:../world-vercel class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -1100,9 +1100,6 @@ importers: streamdown: specifier: 1.6.11 version: 1.6.11(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.1.0) - swr: - specifier: 2.3.6 - version: 2.3.6(react@19.1.0) tailwind-merge: specifier: 2.5.5 version: 2.5.5 @@ -8022,6 +8019,10 @@ packages: '@opentelemetry/sdk-metrics': '>=1.19.0 <2.0.0' '@opentelemetry/sdk-trace-base': '>=1.19.0 <2.0.0' + '@vercel/queue@0.0.0-alpha.34': + resolution: {integrity: sha512-xy5MNbsAoN9W1gtjNkKEg8SHEsnoEj3KbQQH7EaAtqJ0ZfdPo13XLOdqvAR5IO+4X5F0nyPENMVFilzzaSAYiA==} + engines: {node: '>=20.0.0'} + '@vercel/queue@0.0.0-alpha.36': resolution: {integrity: sha512-+0RWV/ljyK0lXH7LYUbTJ02UJLhPfZIvzMOjhMdD6tEm8o+VzJGJY9KwIljohtdfeep78cFUGuWvNmT+bi29Wg==} engines: {node: '>=20.0.0'} @@ -8263,6 +8264,20 @@ packages: resolution: {integrity: sha512-ueFCcIPaMgtuYDS9u0qlUoEvj6GiSsKrwnOLPp9SshqjtcRaR1IEHRjoReq3sXNydsF5i0ZnmuYgXq9dV53t0g==} engines: {node: '>=18.0.0'} + '@workflow/errors@4.1.0-beta.14': + resolution: {integrity: sha512-vc01pVxxhRZt9lpGkTuW2X+/KHYMOP4Gc42BiIrf/x6bz9oWPInTbfFW+YAPvstE4SF1gpyxMpb5r4yjg6LznA==} + + '@workflow/utils@4.1.0-beta.11': + resolution: {integrity: sha512-4fIstKn3jSN7pyJzp8RZ4Rbrohpxa+mc3sKji7wDGnqzD9GnSbm3+WOhGAduvYZubsAHN7HmXrfZ96EPLXtu6g==} + + '@workflow/world-vercel@4.1.0-beta.29': + resolution: {integrity: sha512-5BYyFRgYYJMxoRDkRnp2YeP0X3+nkMD3/yWg3LkaIHaF7TTMYpXlxIOTEwZXccI3UEZJhNBLn5QW70+g09ieBQ==} + + '@workflow/world@4.1.0-beta.1': + resolution: {integrity: sha512-DM7dI8IHRHeqP9EnCe+z70TGX/TX4losuLc2uBPm8BPk3VNNI/AI7DSybPCD5WJREM4QNgdOeHUsJop1xQ5SiA==} + peerDependencies: + zod: 4.1.11 + '@xhmikosr/archive-type@7.1.0': resolution: {integrity: sha512-xZEpnGplg1sNPyEgFh0zbHxqlw5dtYg6viplmWSxUj12+QjU9SKu3U/2G73a15pEjLaOqTefNSZ1fOPUOT4Xgg==} engines: {node: '>=18'} @@ -8687,11 +8702,6 @@ packages: brotli@1.3.3: resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} - browserslist@4.27.0: - resolution: {integrity: sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -9831,9 +9841,6 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-to-chromium@1.5.238: - resolution: {integrity: sha512-khBdc+w/Gv+cS8e/Pbnaw/FXcBUeKrRVik9IxfXtgREOWyJhR4tj43n3amkVogJ/yeQUqzkrZcFhtIxIdqmmcQ==} - electron-to-chromium@1.5.279: resolution: {integrity: sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==} @@ -12250,9 +12257,6 @@ packages: node-mock-http@1.0.3: resolution: {integrity: sha512-jN8dK25fsfnMrVsEhluUTPkBFY+6ybu7jSB1n+ri/vOGjJxU8J9CZhpSGkHXSkFjtUhbmoncG/YG9ta5Ludqog==} - node-releases@2.0.26: - resolution: {integrity: sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==} - node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -14165,6 +14169,7 @@ packages: tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -14816,12 +14821,6 @@ packages: unwasm@0.3.11: resolution: {integrity: sha512-Vhp5gb1tusSQw5of/g3Q697srYgMXvwMgXMjcG4ZNga02fDX9coxJ9fAb0Ci38hM2Hv/U1FXRPGgjP2BYqhNoQ==} - update-browserslist-db@1.1.4: - resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -16132,7 +16131,7 @@ snapshots: dependencies: '@babel/compat-data': 7.28.4 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.27.0 + browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 @@ -22495,6 +22494,11 @@ snapshots: '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@vercel/queue@0.0.0-alpha.34': + dependencies: + '@vercel/oidc': 3.0.5 + mixpart: 0.0.5-alpha.1 + '@vercel/queue@0.0.0-alpha.36': dependencies: '@vercel/oidc': 3.0.5 @@ -22872,6 +22876,28 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 + '@workflow/errors@4.1.0-beta.14': + dependencies: + '@workflow/utils': 4.1.0-beta.11 + ms: 2.1.3 + + '@workflow/utils@4.1.0-beta.11': + dependencies: + ms: 2.1.3 + + '@workflow/world-vercel@4.1.0-beta.29': + dependencies: + '@vercel/oidc': 3.0.5 + '@vercel/queue': 0.0.0-alpha.34 + '@workflow/errors': 4.1.0-beta.14 + '@workflow/world': 4.1.0-beta.1(zod@4.1.11) + cbor-x: 1.6.0 + zod: 4.1.11 + + '@workflow/world@4.1.0-beta.1(zod@4.1.11)': + dependencies: + zod: 4.1.11 + '@xhmikosr/archive-type@7.1.0': dependencies: file-type: 20.5.0 @@ -23584,14 +23610,6 @@ snapshots: dependencies: base64-js: 1.5.1 - browserslist@4.27.0: - dependencies: - baseline-browser-mapping: 2.9.18 - caniuse-lite: 1.0.30001766 - electron-to-chromium: 1.5.238 - node-releases: 2.0.26 - update-browserslist-db: 1.1.4(browserslist@4.27.0) - browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.18 @@ -23698,7 +23716,7 @@ snapshots: caniuse-api@3.0.0: dependencies: - browserslist: 4.27.0 + browserslist: 4.28.1 caniuse-lite: 1.0.30001766 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 @@ -24120,7 +24138,7 @@ snapshots: cssnano-preset-default@7.0.9(postcss@8.5.6): dependencies: - browserslist: 4.27.0 + browserslist: 4.28.1 css-declaration-sorter: 7.3.0(postcss@8.5.6) cssnano-utils: 5.0.1(postcss@8.5.6) postcss: 8.5.6 @@ -24657,8 +24675,6 @@ snapshots: dependencies: jake: 10.9.2 - electron-to-chromium@1.5.238: {} - electron-to-chromium@1.5.279: {} embla-carousel-react@8.5.1(react@19.2.4): @@ -27931,8 +27947,6 @@ snapshots: node-mock-http@1.0.3: {} - node-releases@2.0.26: {} - node-releases@2.0.27: {} node-source-walk@7.0.1: @@ -28834,7 +28848,7 @@ snapshots: postcss-colormin@7.0.4(postcss@8.5.6): dependencies: - browserslist: 4.27.0 + browserslist: 4.28.1 caniuse-api: 3.0.0 colord: 2.9.3 postcss: 8.5.6 @@ -28842,7 +28856,7 @@ snapshots: postcss-convert-values@7.0.7(postcss@8.5.6): dependencies: - browserslist: 4.27.0 + browserslist: 4.28.1 postcss: 8.5.6 postcss-value-parser: 4.2.0 @@ -28871,7 +28885,7 @@ snapshots: postcss-merge-rules@7.0.6(postcss@8.5.6): dependencies: - browserslist: 4.27.0 + browserslist: 4.28.1 caniuse-api: 3.0.0 cssnano-utils: 5.0.1(postcss@8.5.6) postcss: 8.5.6 @@ -28891,7 +28905,7 @@ snapshots: postcss-minify-params@7.0.4(postcss@8.5.6): dependencies: - browserslist: 4.27.0 + browserslist: 4.28.1 cssnano-utils: 5.0.1(postcss@8.5.6) postcss: 8.5.6 postcss-value-parser: 4.2.0 @@ -28938,7 +28952,7 @@ snapshots: postcss-normalize-unicode@7.0.4(postcss@8.5.6): dependencies: - browserslist: 4.27.0 + browserslist: 4.28.1 postcss: 8.5.6 postcss-value-parser: 4.2.0 @@ -28960,7 +28974,7 @@ snapshots: postcss-reduce-initial@7.0.4(postcss@8.5.6): dependencies: - browserslist: 4.27.0 + browserslist: 4.28.1 caniuse-api: 3.0.0 postcss: 8.5.6 @@ -30438,7 +30452,7 @@ snapshots: stylehacks@7.0.6(postcss@8.5.6): dependencies: - browserslist: 4.27.0 + browserslist: 4.28.1 postcss: 8.5.6 postcss-selector-parser: 7.1.0 @@ -31174,12 +31188,6 @@ snapshots: pkg-types: 2.3.0 unplugin: 2.3.10 - update-browserslist-db@1.1.4(browserslist@4.27.0): - dependencies: - browserslist: 4.27.0 - escalade: 3.2.0 - picocolors: 1.1.1 - update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1