From 6264999f6ee4faf86f18e353d061044707e51280 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Tue, 3 Feb 2026 21:36:17 -0800 Subject: [PATCH 01/10] Refactor web-shared to separate UI components from server concerns - Move UI components into src/components/ subdirectory - Move server actions and API client to @workflow/web - Add world-actions/ module with shared helpers (recreateRun, cancelRun, etc.) - Add runtime subpath exports to @workflow/core - Components now accept callbacks as props for data fetching --- .changeset/common-mangos-bet.md | 7 + packages/core/package.json | 12 + packages/web-shared/README.md | 80 ++--- packages/web-shared/package.json | 34 +- packages/web-shared/server/README.md | 1 - packages/web-shared/server/package.json | 4 - .../src/{ => components}/error-boundary.tsx | 2 +- .../src/{ => components}/event-list-view.tsx | 49 ++- .../src/{ => components}/hook-actions.tsx | 10 +- packages/web-shared/src/components/index.d.ts | 1 + packages/web-shared/src/components/index.ts | 23 ++ .../src/components/run-trace-view.tsx | 75 +++++ .../sidebar/attribute-panel.tsx | 6 +- .../sidebar/conversation-view.tsx | 0 .../{ => components}/sidebar/detail-card.tsx | 0 .../sidebar/entity-detail-panel.tsx | 142 ++++---- .../{ => components}/sidebar/events-list.tsx | 83 ++--- .../sidebar/resolve-hook-modal.tsx | 0 .../src/{ => components}/stream-viewer.tsx | 83 +---- .../trace-viewer/components/map.tsx | 0 .../trace-viewer/components/markers.tsx | 0 .../trace-viewer/components/node.tsx | 0 .../trace-viewer/components/search-input.tsx | 0 .../trace-viewer/components/search.tsx | 0 .../components/span-detail-panel.tsx | 0 .../trace-viewer/components/ui.tsx | 0 .../trace-viewer/components/zoom-button.tsx | 0 .../trace-viewer/components/zoom-icons.tsx | 0 .../{ => components}/trace-viewer/context.tsx | 0 .../{ => components}/trace-viewer/index.tsx | 0 .../trace-viewer/modules.d.ts | 0 .../trace-viewer/trace-viewer.module.css | 0 .../trace-viewer/trace-viewer.tsx | 0 .../{ => components}/trace-viewer/types.ts | 0 .../trace-viewer/util/constants.ts | 0 .../trace-viewer/util/scrollbar-width.ts | 0 .../trace-viewer/util/timing.ts | 2 +- .../trace-viewer/util/tree.ts | 0 .../trace-viewer/util/use-immediate-style.ts | 0 .../trace-viewer/util/use-streaming-spans.ts | 0 .../trace-viewer/util/use-trackpad-zoom.tsx | 0 .../{ => components}/trace-viewer/worker.ts | 0 .../src/components/workflow-trace-view.tsx | 306 ++++++++++++++++++ .../workflow-traces/event-colors.ts | 0 .../workflow-traces/trace-colors.ts | 0 .../trace-span-construction.ts | 0 .../workflow-traces/trace-time-utils.ts | 0 packages/web-shared/src/index.d.ts | 1 + packages/web-shared/src/index.ts | 36 +-- packages/web-shared/src/run-trace-view.tsx | 49 --- .../web-shared/src/trace-viewer/README.md | 15 - .../web-shared/src/workflow-trace-view.tsx | 215 ------------ .../web-shared/src/world-actions/index.d.ts | 1 + .../web-shared/src/world-actions/index.ts | 1 + .../web-shared/src/world-actions/runs.d.ts | 1 + packages/web-shared/src/world-actions/runs.ts | 157 +++++++++ packages/web/package.json | 2 + packages/web/src/app/layout-client.tsx | 2 +- packages/web/src/app/layout.tsx | 2 +- .../display-utils/health-check-button.tsx | 2 +- .../workflow-graph-execution-viewer.tsx | 8 +- packages/web/src/components/hooks-table.tsx | 15 +- packages/web/src/components/run-actions.tsx | 20 +- .../web/src/components/run-detail-view.tsx | 112 ++++++- packages/web/src/components/runs-table.tsx | 19 +- .../src/lib/flow-graph/use-workflow-graph.ts | 6 +- .../web/src/lib/hooks/use-stream-reader.ts | 99 ++++++ .../src/lib}/workflow-api-client.ts | 65 ++-- packages/web/src/lib/world-config-context.tsx | 6 +- .../src/server}/workflow-server-actions.ts | 133 ++------ pnpm-lock.yaml | 52 ++- 71 files changed, 1142 insertions(+), 797 deletions(-) create mode 100644 .changeset/common-mangos-bet.md delete mode 100644 packages/web-shared/server/README.md delete mode 100644 packages/web-shared/server/package.json rename packages/web-shared/src/{ => components}/error-boundary.tsx (97%) rename packages/web-shared/src/{ => components}/event-list-view.tsx (92%) rename packages/web-shared/src/{ => components}/hook-actions.tsx (95%) create mode 100644 packages/web-shared/src/components/index.d.ts create mode 100644 packages/web-shared/src/components/index.ts create mode 100644 packages/web-shared/src/components/run-trace-view.tsx rename packages/web-shared/src/{ => components}/sidebar/attribute-panel.tsx (99%) rename packages/web-shared/src/{ => components}/sidebar/conversation-view.tsx (100%) rename packages/web-shared/src/{ => components}/sidebar/detail-card.tsx (100%) rename packages/web-shared/src/{ => components}/sidebar/entity-detail-panel.tsx (79%) rename packages/web-shared/src/{ => components}/sidebar/events-list.tsx (60%) rename packages/web-shared/src/{ => components}/sidebar/resolve-hook-modal.tsx (100%) rename packages/web-shared/src/{ => components}/stream-viewer.tsx (67%) rename packages/web-shared/src/{ => components}/trace-viewer/components/map.tsx (100%) rename packages/web-shared/src/{ => components}/trace-viewer/components/markers.tsx (100%) rename packages/web-shared/src/{ => components}/trace-viewer/components/node.tsx (100%) rename packages/web-shared/src/{ => components}/trace-viewer/components/search-input.tsx (100%) rename packages/web-shared/src/{ => components}/trace-viewer/components/search.tsx (100%) rename packages/web-shared/src/{ => components}/trace-viewer/components/span-detail-panel.tsx (100%) rename packages/web-shared/src/{ => components}/trace-viewer/components/ui.tsx (100%) rename packages/web-shared/src/{ => components}/trace-viewer/components/zoom-button.tsx (100%) rename packages/web-shared/src/{ => components}/trace-viewer/components/zoom-icons.tsx (100%) rename packages/web-shared/src/{ => components}/trace-viewer/context.tsx (100%) rename packages/web-shared/src/{ => components}/trace-viewer/index.tsx (100%) rename packages/web-shared/src/{ => components}/trace-viewer/modules.d.ts (100%) rename packages/web-shared/src/{ => components}/trace-viewer/trace-viewer.module.css (100%) rename packages/web-shared/src/{ => components}/trace-viewer/trace-viewer.tsx (100%) rename packages/web-shared/src/{ => components}/trace-viewer/types.ts (100%) rename packages/web-shared/src/{ => components}/trace-viewer/util/constants.ts (100%) rename packages/web-shared/src/{ => components}/trace-viewer/util/scrollbar-width.ts (100%) rename packages/web-shared/src/{ => components}/trace-viewer/util/timing.ts (94%) rename packages/web-shared/src/{ => components}/trace-viewer/util/tree.ts (100%) rename packages/web-shared/src/{ => components}/trace-viewer/util/use-immediate-style.ts (100%) rename packages/web-shared/src/{ => components}/trace-viewer/util/use-streaming-spans.ts (100%) rename packages/web-shared/src/{ => components}/trace-viewer/util/use-trackpad-zoom.tsx (100%) rename packages/web-shared/src/{ => components}/trace-viewer/worker.ts (100%) create mode 100644 packages/web-shared/src/components/workflow-trace-view.tsx rename packages/web-shared/src/{ => components}/workflow-traces/event-colors.ts (100%) rename packages/web-shared/src/{ => components}/workflow-traces/trace-colors.ts (100%) rename packages/web-shared/src/{ => components}/workflow-traces/trace-span-construction.ts (100%) rename packages/web-shared/src/{ => components}/workflow-traces/trace-time-utils.ts (100%) create mode 100644 packages/web-shared/src/index.d.ts delete mode 100644 packages/web-shared/src/run-trace-view.tsx delete mode 100644 packages/web-shared/src/trace-viewer/README.md delete mode 100644 packages/web-shared/src/workflow-trace-view.tsx create mode 100644 packages/web-shared/src/world-actions/index.d.ts create mode 100644 packages/web-shared/src/world-actions/index.ts create mode 100644 packages/web-shared/src/world-actions/runs.d.ts create mode 100644 packages/web-shared/src/world-actions/runs.ts create mode 100644 packages/web/src/lib/hooks/use-stream-reader.ts rename packages/{web-shared/src/api => web/src/lib}/workflow-api-client.ts (96%) rename packages/{web-shared/src/api => web/src/server}/workflow-server-actions.ts (90%) 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..e270f07b54 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,6 +27,18 @@ "default": "./dist/index.js" }, "./runtime": "./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": "./dist/private.js", "./class-serialization": "./dist/class-serialization.js", "./builtins": "./dist/builtins.js", diff --git a/packages/web-shared/README.md b/packages/web-shared/README.md index a764fe26d0..776aa629ad 100644 --- a/packages/web-shared/README.md +++ b/packages/web-shared/README.md @@ -1,67 +1,53 @@ # @workflow/web-shared -Workflow Observability tools for NextJS. See [Workflow DevKit](https://useworkflow.dev/docs/observability) for more information. +Workflow Observability UI primitives and World helpers. 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) +- helper functions that operate on a `World` instance -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'; +You can use the helpers to build your own data layer: -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, - }); +```tsx +import { cancelRun } from '@workflow/web-shared/world-actions/runs'; +import type { World } from '@workflow/world'; - // Shows an interactive trace viewer for the given run - return
{runs.map((run) => ( -
- {run.workflowName} - {run.status} - {run.startedAt} - {run.completedAt} -
- ))}
; +export async function cancelLatestRun(world: World, runId: string) { + await cancelRun(world, runId); } ``` -It also comes with a pre-styled interactive trace viewer that you can use to display the trace for a given run: +It also 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. ## Styling diff --git a/packages/web-shared/package.json b/packages/web-shared/package.json index 892c007244..42ab605a9c 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,30 @@ "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" + }, + "./world-actions": { + "types": "./dist/world-actions/index.d.ts", + "default": "./dist/world-actions/index.js" + }, + "./world-actions/runs": { + "types": "./dist/world-actions/runs.d.ts", + "default": "./dist/world-actions/runs.js" + } + }, + "typesVersions": { + "*": { + "world-actions": [ + "dist/world-actions/index.d.ts" + ], + "world-actions/runs": [ + "dist/world-actions/runs.d.ts" + ], + "components": [ + "dist/components/index.d.ts" + ] } }, "repository": { @@ -29,7 +50,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 +60,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 +72,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 79% 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..3e7b849860 100644 --- a/packages/web-shared/src/sidebar/entity-detail-panel.tsx +++ b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx @@ -5,39 +5,61 @@ import clsx from 'clsx'; import { Send, Zap } from 'lucide-react'; import { useCallback, useEffect, useMemo, 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'; +/** + * 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 @@ -67,6 +89,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) + ) { + onSpanSelect({ + resource: resource as 'run' | 'step' | 'hook' | 'sleep', + resourceId, + runId, + }); + } + }, [onSpanSelect, 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 +151,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; + const hook = (spanDetailData ?? data) as Hook | undefined; return hook?.token; - }, [resource, resolvedHookToken, fetchedData, data]); + }, [resource, spanDetailData, data]); useEffect(() => { if (error && selected && resource) { @@ -171,12 +171,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 +205,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 +221,8 @@ export function EntityDetailPanel({ try { setResolvingHook(true); - await resumeHook(env, hookToken, payload); + const hook = (spanDetailData ?? data) as Hook | undefined; + await onResolveHook(hookToken, payload, hook); toast.success('Hook resolved', { description: 'The payload has been sent and the hook resolved.', }); @@ -226,14 +237,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 +317,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..2ef1eb9a2f --- /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.js'; +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-shared/src/world-actions/index.d.ts b/packages/web-shared/src/world-actions/index.d.ts new file mode 100644 index 0000000000..ea465c2a34 --- /dev/null +++ b/packages/web-shared/src/world-actions/index.d.ts @@ -0,0 +1 @@ +export * from './index'; diff --git a/packages/web-shared/src/world-actions/index.ts b/packages/web-shared/src/world-actions/index.ts new file mode 100644 index 0000000000..38cdfb3ea8 --- /dev/null +++ b/packages/web-shared/src/world-actions/index.ts @@ -0,0 +1 @@ +export * from './runs'; diff --git a/packages/web-shared/src/world-actions/runs.d.ts b/packages/web-shared/src/world-actions/runs.d.ts new file mode 100644 index 0000000000..38cdfb3ea8 --- /dev/null +++ b/packages/web-shared/src/world-actions/runs.d.ts @@ -0,0 +1 @@ +export * from './runs'; diff --git a/packages/web-shared/src/world-actions/runs.ts b/packages/web-shared/src/world-actions/runs.ts new file mode 100644 index 0000000000..8e8557d75e --- /dev/null +++ b/packages/web-shared/src/world-actions/runs.ts @@ -0,0 +1,157 @@ +import { start } from '@workflow/core/runtime/start'; +import { hydrateWorkflowArguments } from '@workflow/core/serialization'; +import { + type Event, + isLegacySpecVersion, + SPEC_VERSION_LEGACY, + type World, +} from '@workflow/world'; + +export interface RecreateRunOptions { + deploymentId?: string; + specVersion?: number; +} + +export interface StopSleepResult { + /** Number of pending sleeps that were stopped */ + stoppedCount: number; +} + +export interface StopSleepOptions { + /** + * Optional list of specific correlation IDs to target. + * If provided, only these sleep calls will be interrupted. + * If not provided, all pending sleep calls will be interrupted. + */ + correlationIds?: string[]; +} + +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 { + 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; +} + +/** + * Cancel a workflow run. + */ +export async function cancelRun(world: World, runId: string): Promise { + 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 }); +} + +/** + * Re-enqueue a workflow run. + */ +export async function reenqueueRun(world: World, runId: string): Promise { + const run = await world.runs.get(runId, { resolveData: 'none' }); + await world.queue( + `__wkf_workflow_${run.workflowName}`, + { + runId, + }, + { + deploymentId: run.deploymentId, + } + ); +} + +/** + * Wake up a workflow run by interrupting pending sleep() calls. + */ +export async function wakeUpRun( + world: World, + runId: string, + options?: StopSleepOptions +): Promise { + const run = await world.runs.get(runId, { resolveData: 'none' }); + const compatMode = isLegacySpecVersion(run.specVersion); + + const eventsResult = await world.events.list({ + runId, + pagination: { limit: 1000 }, + resolveData: 'none', + }); + + const waitCreatedEvents = eventsResult.data.filter( + (event: Event) => event.eventType === 'wait_created' + ); + const waitCompletedCorrelationIds = new Set( + eventsResult.data + .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) + ); + } + + 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, + }; + await world.events.create(runId, eventData, { v1Compat: compatMode }); + } + + if (pendingWaits.length > 0) { + await world.queue( + `__wkf_workflow_${run.workflowName}`, + { + runId, + }, + { + deploymentId: run.deploymentId, + } + ); + } + + return { stoppedCount: pendingWaits.length }; +} 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..111160edd2 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,18 @@ 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, + 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 +225,50 @@ 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 result = await fetchEventsByCorrelationId( + env, + event.correlationId, + { + sortOrder: 'asc', + limit: 100, + withData: true, + } + ); + if (!result.success) { + throw new Error( + result.error?.message || 'Failed to load event details' + ); + } + const fullEvent = result.data.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 +287,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 +315,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 +636,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 +653,10 @@ export function RunDetailView({
- +
@@ -638,7 +723,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..78fde40ace 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web/src/server/workflow-server-actions.ts @@ -3,14 +3,13 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { hydrateResourceIO } from '@workflow/core/observability'; +import { createWorld } 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 +19,18 @@ 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'; +import * as workflowRunHelpers from '@workflow/web-shared/world-actions/runs'; /** * 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 79f320ca03..213989da20 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 @@ -7582,6 +7579,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'} @@ -13400,6 +13411,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==} @@ -21364,6 +21376,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 From f2bf157063f26cf14c560ddab962ccc85afc3f5b Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Wed, 4 Feb 2026 12:19:45 -0800 Subject: [PATCH 02/10] add support for streams --- packages/web-shared/src/world-actions/runs.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/web-shared/src/world-actions/runs.ts b/packages/web-shared/src/world-actions/runs.ts index 8e8557d75e..d0ef210b13 100644 --- a/packages/web-shared/src/world-actions/runs.ts +++ b/packages/web-shared/src/world-actions/runs.ts @@ -17,6 +17,13 @@ export interface StopSleepResult { 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. @@ -155,3 +162,25 @@ export async function wakeUpRun( return { stoppedCount: pendingWaits.length }; } + +/** + * Read from a stream by stream ID. + * Returns a ReadableStream of Uint8Array chunks. + */ +export async function readStream( + world: World, + streamId: string, + options?: ReadStreamOptions +): Promise> { + return world.readFromStream(streamId, options?.startIndex); +} + +/** + * List all stream IDs for a workflow run. + */ +export async function listStreams( + world: World, + runId: string +): Promise { + return world.listStreamsByRunId(runId); +} From 3c953df9ae63b747abdd3b56bf28f6097858cf9b Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Thu, 5 Feb 2026 08:56:47 -0800 Subject: [PATCH 03/10] update lock file --- pnpm-lock.yaml | 64 +++++++++++++++----------------------------------- 1 file changed, 19 insertions(+), 45 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76be4a4177..5b94e20d49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8019,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'} @@ -8698,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} @@ -9842,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==} @@ -12261,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==} @@ -14828,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 @@ -16144,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 @@ -22507,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 @@ -23618,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 @@ -23732,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 @@ -24154,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 @@ -24691,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): @@ -27965,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: @@ -28868,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 @@ -28876,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 @@ -28905,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 @@ -28925,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 @@ -28972,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 @@ -28994,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 @@ -30472,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 @@ -31208,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 From 0dd91f4eebece37c262ce81cf45e28594d72e271 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Thu, 5 Feb 2026 14:55:57 -0800 Subject: [PATCH 04/10] Migrate helpers from web-shared to core --- packages/core/src/runtime.ts | 12 ++++++++++++ .../world-actions => core/src/runtime}/runs.ts | 4 ++-- packages/web-shared/README.md | 18 +++--------------- packages/web-shared/package.json | 14 -------------- .../web-shared/src/world-actions/index.d.ts | 1 - packages/web-shared/src/world-actions/index.ts | 1 - .../web-shared/src/world-actions/runs.d.ts | 1 - .../web/src/server/workflow-server-actions.ts | 2 +- 8 files changed, 18 insertions(+), 35 deletions(-) rename packages/{web-shared/src/world-actions => core/src/runtime}/runs.ts (97%) delete mode 100644 packages/web-shared/src/world-actions/index.d.ts delete mode 100644 packages/web-shared/src/world-actions/index.ts delete mode 100644 packages/web-shared/src/world-actions/runs.d.ts diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 6e229a4971..713896f3e5 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -42,6 +42,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/web-shared/src/world-actions/runs.ts b/packages/core/src/runtime/runs.ts similarity index 97% rename from packages/web-shared/src/world-actions/runs.ts rename to packages/core/src/runtime/runs.ts index d0ef210b13..97fbed772c 100644 --- a/packages/web-shared/src/world-actions/runs.ts +++ b/packages/core/src/runtime/runs.ts @@ -1,11 +1,11 @@ -import { start } from '@workflow/core/runtime/start'; -import { hydrateWorkflowArguments } from '@workflow/core/serialization'; +import { hydrateWorkflowArguments } from '../serialization.js'; import { type Event, isLegacySpecVersion, SPEC_VERSION_LEGACY, type World, } from '@workflow/world'; +import { start } from './start.js'; export interface RecreateRunOptions { deploymentId?: string; diff --git a/packages/web-shared/README.md b/packages/web-shared/README.md index 776aa629ad..7cecfc609c 100644 --- a/packages/web-shared/README.md +++ b/packages/web-shared/README.md @@ -1,28 +1,16 @@ # @workflow/web-shared -Workflow Observability UI primitives and World helpers. 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: - pre-styled, prop-driven UI components (no data fetching) -- helper functions that operate on a `World` instance If you want a full observability experience with server actions already wired, take a look at [`@workflow/web`](../web/README.md) instead. -You can use the helpers to build your own data layer: - -```tsx -import { cancelRun } from '@workflow/web-shared/world-actions/runs'; -import type { World } from '@workflow/world'; - -export async function cancelLatestRun(world: World, runId: string) { - await cancelRun(world, runId); -} -``` - -It also comes with pre-styled UI components that accept data + callbacks: +It comes with pre-styled UI components that accept data + callbacks: ```tsx import { WorkflowTraceViewer } from '@workflow/web-shared'; @@ -47,7 +35,7 @@ export default function MyRunDetailView({ ``` Server actions and data fetching are intentionally **not** part of `web-shared`. Implement those in your app -and pass data + callbacks into these components. +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 42ab605a9c..b6e425b9c3 100644 --- a/packages/web-shared/package.json +++ b/packages/web-shared/package.json @@ -21,24 +21,10 @@ "./components": { "types": "./dist/components/index.d.ts", "default": "./dist/components/index.js" - }, - "./world-actions": { - "types": "./dist/world-actions/index.d.ts", - "default": "./dist/world-actions/index.js" - }, - "./world-actions/runs": { - "types": "./dist/world-actions/runs.d.ts", - "default": "./dist/world-actions/runs.js" } }, "typesVersions": { "*": { - "world-actions": [ - "dist/world-actions/index.d.ts" - ], - "world-actions/runs": [ - "dist/world-actions/runs.d.ts" - ], "components": [ "dist/components/index.d.ts" ] diff --git a/packages/web-shared/src/world-actions/index.d.ts b/packages/web-shared/src/world-actions/index.d.ts deleted file mode 100644 index ea465c2a34..0000000000 --- a/packages/web-shared/src/world-actions/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './index'; diff --git a/packages/web-shared/src/world-actions/index.ts b/packages/web-shared/src/world-actions/index.ts deleted file mode 100644 index 38cdfb3ea8..0000000000 --- a/packages/web-shared/src/world-actions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './runs'; diff --git a/packages/web-shared/src/world-actions/runs.d.ts b/packages/web-shared/src/world-actions/runs.d.ts deleted file mode 100644 index 38cdfb3ea8..0000000000 --- a/packages/web-shared/src/world-actions/runs.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './runs'; diff --git a/packages/web/src/server/workflow-server-actions.ts b/packages/web/src/server/workflow-server-actions.ts index 78fde40ace..bc525bda83 100644 --- a/packages/web/src/server/workflow-server-actions.ts +++ b/packages/web/src/server/workflow-server-actions.ts @@ -4,6 +4,7 @@ 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 { type HealthCheckEndpoint, type HealthCheckResult, @@ -30,7 +31,6 @@ import { createStorage, createStreamer, } from '@workflow/world-vercel'; -import * as workflowRunHelpers from '@workflow/web-shared/world-actions/runs'; /** * Environment variable map for world configuration. From d3c3036c57d99ce18269831666411eb49f37608e Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Thu, 5 Feb 2026 15:08:54 -0800 Subject: [PATCH 05/10] Migrate helpers from web-shared to core --- packages/core/package.json | 5 +++- .../web/src/server/workflow-server-actions.ts | 25 ++++++++++--------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index e270f07b54..d9ac9f8479 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,7 +26,10 @@ "workflow": "./dist/workflow/index.js", "default": "./dist/index.js" }, - "./runtime": "./dist/runtime.js", + "./runtime": { + "types": "./dist/runtime.d.ts", + "default": "./dist/runtime.js" + }, "./runtime/start": { "types": "./dist/runtime/start.d.ts", "default": "./dist/runtime/start.js" diff --git a/packages/web/src/server/workflow-server-actions.ts b/packages/web/src/server/workflow-server-actions.ts index bc525bda83..5832015a7d 100644 --- a/packages/web/src/server/workflow-server-actions.ts +++ b/packages/web/src/server/workflow-server-actions.ts @@ -3,8 +3,13 @@ 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 { + cancelRun as cancelRunHelper, + createWorld, + recreateRunFromExisting as recreateRunFromExistingHelper, + reenqueueRun as reenqueueRunHelper, + wakeUpRun as wakeUpRunHelper, +} from '@workflow/core/runtime'; import { type HealthCheckEndpoint, type HealthCheckResult, @@ -828,7 +833,7 @@ export async function cancelRun( ): Promise> { try { const world = await getWorldFromEnv(worldEnv); - await workflowRunHelpers.cancelRun(world, runId); + await cancelRunHelper(world, runId); return createResponse(undefined); } catch (error) { return createServerActionError(error, 'world.events.create', { @@ -849,13 +854,9 @@ export async function recreateRun( ): Promise> { try { const world = await getWorldFromEnv({ ...worldEnv }); - const newRunId = await workflowRunHelpers.recreateRunFromExisting( - world, - runId, - { - deploymentId, - } - ); + const newRunId = await recreateRunFromExistingHelper(world, runId, { + deploymentId, + }); return createResponse(newRunId); } catch (error) { return createServerActionError(error, 'recreateRun', { runId }); @@ -874,7 +875,7 @@ export async function reenqueueRun( ): Promise> { try { const world = await getWorldFromEnv({ ...worldEnv }); - await workflowRunHelpers.reenqueueRun(world, runId); + await reenqueueRunHelper(world, runId); return createResponse(undefined); } catch (error) { return createServerActionError(error, 'reenqueueRun', { runId }); @@ -912,7 +913,7 @@ export async function wakeUpRun( ): Promise> { try { const world = await getWorldFromEnv({ ...worldEnv }); - const result = await workflowRunHelpers.wakeUpRun(world, runId, options); + const result = await wakeUpRunHelper(world, runId, options); return createResponse(result); } catch (error) { return createServerActionError(error, 'wakeUpRun', { From 55732aaef280dde84ba754df79ef404f61e6bfc6 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Thu, 5 Feb 2026 15:09:42 -0800 Subject: [PATCH 06/10] Migrate helpers from web-shared to core --- .../web/src/server/workflow-server-actions.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/web/src/server/workflow-server-actions.ts b/packages/web/src/server/workflow-server-actions.ts index 5832015a7d..bc525bda83 100644 --- a/packages/web/src/server/workflow-server-actions.ts +++ b/packages/web/src/server/workflow-server-actions.ts @@ -3,13 +3,8 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { hydrateResourceIO } from '@workflow/core/observability'; -import { - cancelRun as cancelRunHelper, - createWorld, - recreateRunFromExisting as recreateRunFromExistingHelper, - reenqueueRun as reenqueueRunHelper, - wakeUpRun as wakeUpRunHelper, -} from '@workflow/core/runtime'; +import { createWorld } from '@workflow/core/runtime'; +import * as workflowRunHelpers from '@workflow/core/runtime'; import { type HealthCheckEndpoint, type HealthCheckResult, @@ -833,7 +828,7 @@ export async function cancelRun( ): Promise> { try { const world = await getWorldFromEnv(worldEnv); - await cancelRunHelper(world, runId); + await workflowRunHelpers.cancelRun(world, runId); return createResponse(undefined); } catch (error) { return createServerActionError(error, 'world.events.create', { @@ -854,9 +849,13 @@ export async function recreateRun( ): Promise> { try { const world = await getWorldFromEnv({ ...worldEnv }); - const newRunId = await recreateRunFromExistingHelper(world, runId, { - deploymentId, - }); + const newRunId = await workflowRunHelpers.recreateRunFromExisting( + world, + runId, + { + deploymentId, + } + ); return createResponse(newRunId); } catch (error) { return createServerActionError(error, 'recreateRun', { runId }); @@ -875,7 +874,7 @@ export async function reenqueueRun( ): Promise> { try { const world = await getWorldFromEnv({ ...worldEnv }); - await reenqueueRunHelper(world, runId); + await workflowRunHelpers.reenqueueRun(world, runId); return createResponse(undefined); } catch (error) { return createServerActionError(error, 'reenqueueRun', { runId }); @@ -913,7 +912,7 @@ export async function wakeUpRun( ): Promise> { try { const world = await getWorldFromEnv({ ...worldEnv }); - const result = await wakeUpRunHelper(world, runId, options); + const result = await workflowRunHelpers.wakeUpRun(world, runId, options); return createResponse(result); } catch (error) { return createServerActionError(error, 'wakeUpRun', { From 21778603b4fc0a25bc156806b1ac1186311359e9 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Thu, 5 Feb 2026 15:27:28 -0800 Subject: [PATCH 07/10] Migrate helpers from web-shared to core --- packages/core/package.json | 4 +++- packages/core/runtime.d.ts | 1 + packages/core/runtime.js | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 packages/core/runtime.d.ts create mode 100644 packages/core/runtime.js diff --git a/packages/core/package.json b/packages/core/package.json index d9ac9f8479..3e0f8333f3 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" 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'; From bdae69844c842526488ac8510cd81b32094f0cb7 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 6 Feb 2026 09:45:10 -0800 Subject: [PATCH 08/10] Fix claude's comments --- packages/core/package.json | 20 +- packages/core/src/runtime/helpers.ts | 20 ++ packages/core/src/runtime/resume-hook.ts | 3 +- packages/core/src/runtime/runs.ts | 249 +++++++++++------- packages/core/src/runtime/start.ts | 3 +- packages/core/src/runtime/step-handler.ts | 7 +- .../sidebar/entity-detail-panel.tsx | 55 ++-- 7 files changed, 238 insertions(+), 119 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 3e0f8333f3..1a69894d31 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,10 +44,22 @@ "types": "./dist/runtime/resume-hook.d.ts", "default": "./dist/runtime/resume-hook.js" }, - "./private": "./dist/private.js", - "./class-serialization": "./dist/class-serialization.js", - "./builtins": "./dist/builtins.js", - "./serialization": "./dist/serialization.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/src/runtime/helpers.ts b/packages/core/src/runtime/helpers.ts index 0c1e361330..86af07a42b 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 index 97fbed772c..63b0568c3b 100644 --- a/packages/core/src/runtime/runs.ts +++ b/packages/core/src/runtime/runs.ts @@ -5,6 +5,7 @@ import { SPEC_VERSION_LEGACY, type World, } from '@workflow/world'; +import { getWorkflowQueueName } from './helpers.js'; import { start } from './start.js'; export interface RecreateRunOptions { @@ -45,54 +46,75 @@ export async function recreateRunFromExisting( runId: string, options: RecreateRunOptions = {} ): Promise { - 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; + 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 { - 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 }); + 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 { - const run = await world.runs.get(runId, { resolveData: 'none' }); - await world.queue( - `__wkf_workflow_${run.workflowName}`, - { - runId, - }, - { - deploymentId: run.deploymentId, - } - ); + 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 } + ); + } } /** @@ -103,64 +125,97 @@ export async function wakeUpRun( runId: string, options?: StopSleepOptions ): Promise { - const run = await world.runs.get(runId, { resolveData: 'none' }); - const compatMode = isLegacySpecVersion(run.specVersion); - - const eventsResult = await world.events.list({ - runId, - pagination: { limit: 1000 }, - resolveData: 'none', - }); - - const waitCreatedEvents = eventsResult.data.filter( - (event: Event) => event.eventType === 'wait_created' - ); - const waitCompletedCorrelationIds = new Set( - eventsResult.data - .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) + 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) ); - } - 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, - }; - await world.events.create(runId, eventData, { v1Compat: compatMode }); - } + let pendingWaits = waitCreatedEvents.filter( + (event: Event) => !waitCompletedCorrelationIds.has(event.correlationId) + ); - if (pendingWaits.length > 0) { - await world.queue( - `__wkf_workflow_${run.workflowName}`, - { - runId, - }, - { - deploymentId: run.deploymentId, + 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 } ); } - - return { stoppedCount: pendingWaits.length }; } /** @@ -172,7 +227,14 @@ export async function readStream( streamId: string, options?: ReadStreamOptions ): Promise> { - return world.readFromStream(streamId, options?.startIndex); + 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 } + ); + } } /** @@ -182,5 +244,12 @@ export async function listStreams( world: World, runId: string ): Promise { - return world.listStreamsByRunId(runId); + 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 38a6652b8a..5f05bfa0d5 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, @@ -170,7 +171,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(), @@ -217,7 +218,7 @@ const stepHandler = getWorldHandlers().createQueueHandler( if (isTerminalStep) { await queueMessage( world, - `__wkf_workflow_${workflowName}`, + getWorkflowQueueName(workflowName), { runId: workflowRunId, traceCarrier: await serializeTraceCarrier(), @@ -488,7 +489,7 @@ const stepHandler = getWorldHandlers().createQueueHandler( await queueMessage( world, - `__wkf_workflow_${workflowName}`, + getWorkflowQueueName(workflowName), { runId: workflowRunId, traceCarrier: await serializeTraceCarrier(), diff --git a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx index 3e7b849860..1c698dc701 100644 --- a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx +++ b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx @@ -3,13 +3,26 @@ 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 { 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 */ @@ -61,24 +74,25 @@ export function EntityDetailPanel({ const [showResolveHookModal, setShowResolveHookModal] = useState(false); const [resolvingHook, setResolvingHook] = useState(false); - 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', @@ -96,13 +110,13 @@ export function EntityDetailPanel({ resourceId && ['run', 'step', 'hook', 'sleep'].includes(resource) ) { - onSpanSelect({ + onSpanSelectRef.current({ resource: resource as 'run' | 'step' | 'hook' | 'sleep', resourceId, runId, }); } - }, [onSpanSelect, resource, 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 @@ -157,8 +171,8 @@ export function EntityDetailPanel({ // Get the hook token for resolving (prefer fetched data when available) const hookToken = useMemo(() => { if (resource !== 'hook') return undefined; - const hook = (spanDetailData ?? data) as Hook | undefined; - return hook?.token; + const candidate = spanDetailData ?? data; + return isHook(candidate) ? candidate.token : undefined; }, [resource, spanDetailData, data]); useEffect(() => { @@ -221,7 +235,8 @@ export function EntityDetailPanel({ try { setResolvingHook(true); - const hook = (spanDetailData ?? data) as Hook | undefined; + 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.', From deddc66dbdd0428a4dfc725281c4a08ec25f00ab Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 6 Feb 2026 11:09:14 -0800 Subject: [PATCH 09/10] Fix server actions bug for web --- .../web/src/components/run-detail-view.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index 111160edd2..28b1a2804b 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -51,6 +51,7 @@ import { cancelRun, recreateRun, resumeHook, + unwrapServerActionResult, useWorkflowResourceData, useWorkflowStreams, useWorkflowTraceViewerData, @@ -244,23 +245,17 @@ export function RunDetailView({ if (!event.correlationId) { return null; } - const result = await fetchEventsByCorrelationId( - env, - event.correlationId, - { + const { error, result } = await unwrapServerActionResult( + fetchEventsByCorrelationId(env, event.correlationId, { sortOrder: 'asc', limit: 100, withData: true, - } + }) ); - if (!result.success) { - throw new Error( - result.error?.message || 'Failed to load event details' - ); + if (error) { + throw error; } - const fullEvent = result.data.data.find( - (e) => e.eventId === event.eventId - ); + const fullEvent = result.data.find((e) => e.eventId === event.eventId); if (fullEvent && 'eventData' in fullEvent) { return fullEvent.eventData; } From 7268d4897e88633419add498b4a7fcdd795cc795 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 6 Feb 2026 11:12:21 -0800 Subject: [PATCH 10/10] fix copilot bugs --- packages/web-shared/src/components/workflow-trace-view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-shared/src/components/workflow-trace-view.tsx b/packages/web-shared/src/components/workflow-trace-view.tsx index 2ef1eb9a2f..ee53ca3804 100644 --- a/packages/web-shared/src/components/workflow-trace-view.tsx +++ b/packages/web-shared/src/components/workflow-trace-view.tsx @@ -7,7 +7,7 @@ import { ErrorBoundary } from './error-boundary'; import { EntityDetailPanel, type SpanSelectionInfo, -} from './sidebar/entity-detail-panel.js'; +} from './sidebar/entity-detail-panel'; import { TraceViewerContextProvider, TraceViewerTimeline,