From 366f81421b4b8f58c4e4523ca875d5713058bc2e Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 12:46:13 -0800 Subject: [PATCH 01/31] Add streams tab --- .../web/src/components/run-detail-view.tsx | 45 +++++++------------ 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index 19abf0542..05b7ab507 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -8,8 +8,7 @@ import { type WorkflowRun, WorkflowTraceViewer, } from '@workflow/web-shared'; -import { AlertCircle, HelpCircle, Loader2 } from 'lucide-react'; -// import { List, Network } from 'lucide-react'; +import { AlertCircle, HelpCircle, List, Loader2 } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useMemo, useState } from 'react'; @@ -33,8 +32,7 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb'; -// import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -// import { WorkflowGraphExecutionViewer } from '@/components/workflow-graph-execution-viewer'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, @@ -42,8 +40,6 @@ import { } from '@/components/ui/tooltip'; import { buildUrlWithConfig, worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; -// import { mapRunToExecution } from '@/lib/graph-execution-mapper'; -// import { useWorkflowGraphManifest } from '@/lib/use-workflow-graph'; import { CancelButton } from './display-utils/cancel-button'; import { CopyableText } from './display-utils/copyable-text'; import { LiveStatus } from './display-utils/live-status'; @@ -69,11 +65,10 @@ export function RunDetailView({ const [rerunning, setRerunning] = useState(false); const [showCancelDialog, setShowCancelDialog] = useState(false); const [showRerunDialog, setShowRerunDialog] = useState(false); - // const [activeTab, setActiveTab] = useState<'trace' | 'graph'>('trace'); + const [activeTab, setActiveTab] = useState<'trace' | 'graph'>('trace'); const env = useMemo(() => worldConfigToEnvMap(config), [config]); // Fetch workflow graph manifest - // TODO(Karthik): Uncomment after https://github.com/vercel/workflow/pull/455 is merged // const { // manifest: graphManifest, // loading: graphLoading, @@ -419,8 +414,7 @@ export function RunDetailView({
- {/* TODO(Karthik): Uncomment after https://github.com/vercel/workflow/pull/455 is merged */} - {/* setActiveTab(v as 'trace' | 'graph')} className="flex-1 flex flex-col min-h-0" @@ -430,10 +424,14 @@ export function RunDetailView({ Trace - + + + Streams + + {/* Graph - + */} @@ -450,7 +448,11 @@ export function RunDetailView({
- + +
+
+ + {/*
{graphLoading ? (
@@ -487,21 +489,8 @@ export function RunDetailView({ /> )}
- - */} - - {/* Default trace view */} -
- -
+ */} + {auxiliaryDataLoading && (
From f8dac39776583d063bd1d879a239b00c95a92920 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 13:05:56 -0800 Subject: [PATCH 02/31] [world] Add listStream endpoint --- packages/cli/src/lib/inspect/output.ts | 170 ++++-------------------- packages/world-local/src/streamer.ts | 34 +++++ packages/world-postgres/src/streamer.ts | 15 ++- packages/world-vercel/src/backend.ts | 1 + packages/world-vercel/src/streamer.ts | 8 ++ packages/world/src/interfaces.ts | 1 + 6 files changed, 81 insertions(+), 148 deletions(-) diff --git a/packages/cli/src/lib/inspect/output.ts b/packages/cli/src/lib/inspect/output.ts index 4dcfb500c..095e83d00 100644 --- a/packages/cli/src/lib/inspect/output.ts +++ b/packages/cli/src/lib/inspect/output.ts @@ -393,23 +393,6 @@ const showJson = (data: unknown) => { process.stdout.write(`${json}\n`); }; -const getCursorHint = ({ - hasMore, - cursor, -}: { - hasMore: boolean; - cursor: string | null; -}) => { - // Only show cursor hint in non-interactive mode (e.g., CI or when piped) - if (!isCI() && process.stdout.isTTY) { - return undefined; - } - - if (hasMore && cursor) { - return `More results available. Append\n--cursor "${cursor}"\nto this command to fetch the next page.`; - } -}; - /** * In tables, we want to show a shorter timestamp, YYYY-MM-DD HH:MM:SS */ @@ -794,151 +777,44 @@ export const listStreams = async ( 'Filtering by workflow-name is not supported for streams, ignoring filter.' ); } - const steps: Step[] = []; - const runs: WorkflowRun[] = []; if (opts.stepId) { - try { - const step = await world.steps.get(undefined, opts.stepId, { - resolveData: 'all', - }); - steps.push(step); - } catch (error) { - if (handleApiError(error, opts.backend)) { - process.exit(1); - } - throw error; - } - } else if (opts.runId) { - try { - const run = await world.runs.get(opts.runId, { resolveData: 'all' }); - runs.push(run); - const runsSteps = await world.steps.list({ - runId: opts.runId, - pagination: { - sortOrder: opts.sort || 'desc', - cursor: opts.cursor, - limit: opts.limit || DEFAULT_PAGE_SIZE, - }, - resolveData: 'all', // Need data to find stream IDs - }); - runsSteps.data.forEach((step: Step) => { - steps.push(step); - }); - logger.info(getCursorHint(runsSteps)); - } catch (error) { - if (handleApiError(error, opts.backend)) { - process.exit(1); - } - throw error; - } - } else { logger.warn( - 'No run-id or step-id provided. Listing streams for latest run instead.', - 'Use --run= or --step= to filter streams by run or step.' + 'Filtering by step-id is not supported for streams, ignoring filter.' + ); + } + + let runId = opts.runId; + if (!runId) { + logger.warn( + 'No run-id provided. Listing streams for latest run instead.', + 'Use --run= to filter streams by run.' ); const run = await getRecentRun(world, opts); if (!run) { logger.warn('No runs found.'); return; } - - try { - const fullRun = await world.runs.get(run.runId, { resolveData: 'all' }); - runs.push(fullRun); - const runsSteps = await world.steps.list({ - runId: runs[0].runId, - pagination: { - sortOrder: opts.sort || 'desc', - cursor: opts.cursor, - limit: opts.limit || DEFAULT_PAGE_SIZE, - }, - resolveData: 'all', // Need data to find stream IDs - }); - runsSteps.data.forEach((step: Step) => { - steps.push(step); - }); - logger.info(getCursorHint(runsSteps)); - } catch (error) { - if (handleApiError(error, opts.backend)) { - process.exit(1); - } - throw error; - } - } - - const runIds = runs.map((item) => item.runId); - const stepIds = steps.map((item) => item.stepId); - logger.debug(`Found IO for runs/steps: ${runIds.concat(stepIds).join(', ')}`); - - const runsWithHydratedIO = runs.map(hydrateResourceIO); - const stepsWithHydratedIO = steps.map(hydrateResourceIO); - - const matchingStreams = [ - ...runsWithHydratedIO, - ...stepsWithHydratedIO, - ].flatMap((item) => - findAllStreamIdsForObjectWithIO({ - input: item.input, - output: item.output, - runId: item.runId, - stepId: 'stepId' in item ? item.stepId : undefined, - }) - ); - - if (opts.json) { - showJson(matchingStreams); - return; + runId = run.runId; } - logger.log(showTable(matchingStreams, ['runId', 'stepId', 'streamId'])); -}; -const findAllStreamIdsForObjectWithIO = (obj: { - input: any; - output: any; - runId?: string; - stepId?: string; -}) => { - const matchingStreams: { - runId?: string; - stepId?: string; - streamId: string; - }[] = []; - const inputStreams = getStreamIdsFromHydratedObject(obj.input); - for (const streamId of inputStreams) { - matchingStreams.push({ - runId: obj.runId, - stepId: obj.stepId || '/', - streamId, - }); - } - const outputStreams = getStreamIdsFromHydratedObject(obj.output); - for (const streamId of outputStreams) { - matchingStreams.push({ - runId: obj.runId, - stepId: obj.stepId || '/', + try { + const streamIds = await world.listStreams(runId); + const matchingStreams = streamIds.map((streamId) => ({ + runId, streamId, - }); - } - return matchingStreams; -}; + })); -const getStreamIdsFromHydratedObject = (io: any): string[] => { - const streamIds: string[] = []; - const traverse = (obj: any): void => { - if (isStreamId(obj as string)) { - streamIds.push(obj as string); + if (opts.json) { + showJson(matchingStreams); return; } - if (!obj || typeof obj !== 'object') return; - if (Array.isArray(obj)) { - obj.forEach(traverse); - } else { - Object.values(obj).forEach(traverse); + logger.log(showTable(matchingStreams, ['runId', 'streamId'])); + } catch (error) { + if (handleApiError(error, opts.backend)) { + process.exit(1); } - }; - - traverse(io); - return streamIds; + throw error; + } }; export const listEvents = async ( diff --git a/packages/world-local/src/streamer.ts b/packages/world-local/src/streamer.ts index d416b5cb3..7de02ef2d 100644 --- a/packages/world-local/src/streamer.ts +++ b/packages/world-local/src/streamer.ts @@ -113,6 +113,40 @@ export function createStreamer(basedir: string): Streamer { streamEmitter.emit(`close:${name}` as const, { streamName: name }); }, + async listStreams(runId: string) { + const chunksDir = path.join(basedir, 'streams', 'chunks'); + const files = await listJSONFiles(chunksDir); + + // Convert runId (wrun_{ULID}) to stream prefix (strm_{ULID}_user) + const streamPrefix = runId.replace('wrun_', 'strm_') + '_user'; + + // Extract unique stream names that match the run's prefix + const streamNames = new Set(); + for (const file of files) { + // Files are named: {streamName}-{chunkId} + // Find the last occurrence of '-strm_' to split correctly + const lastDashIndex = file.lastIndexOf('-strm_'); + if (lastDashIndex === -1) { + // Try splitting at the last dash for legacy format + const parts = file.split('-'); + if (parts.length >= 2) { + parts.pop(); // Remove chunkId + const streamName = parts.join('-'); + if (streamName.startsWith(streamPrefix)) { + streamNames.add(streamName); + } + } + } else { + const streamName = file.substring(0, lastDashIndex); + if (streamName.startsWith(streamPrefix)) { + streamNames.add(streamName); + } + } + } + + return Array.from(streamNames); + }, + async readFromStream(name: string, startIndex = 0) { const chunksDir = path.join(basedir, 'streams', 'chunks'); let removeListeners = () => {}; diff --git a/packages/world-postgres/src/streamer.ts b/packages/world-postgres/src/streamer.ts index 957df885d..a50360711 100644 --- a/packages/world-postgres/src/streamer.ts +++ b/packages/world-postgres/src/streamer.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; import type { Streamer } from '@workflow/world'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, like } from 'drizzle-orm'; import type { Sql } from 'postgres'; import { monotonicFactory } from 'ulid'; import * as z from 'zod'; @@ -207,5 +207,18 @@ export function createStreamer(postgres: Sql, drizzle: Drizzle): Streamer { }, }); }, + + async listStreams(runId: string): Promise { + // Convert runId (wrun_{ULID}) to stream prefix (strm_{ULID}_user) + const streamPrefix = runId.replace('wrun_', 'strm_') + '_user'; + + // Query distinct stream IDs that match the prefix + const results = await drizzle + .selectDistinct({ streamId: streams.streamId }) + .from(streams) + .where(like(streams.streamId, `${streamPrefix}%`)); + + return results.map((r) => r.streamId); + }, }; } diff --git a/packages/world-vercel/src/backend.ts b/packages/world-vercel/src/backend.ts index ee0045037..a74ee44a2 100644 --- a/packages/world-vercel/src/backend.ts +++ b/packages/world-vercel/src/backend.ts @@ -12,6 +12,7 @@ export function createVercel(config?: APIConfig): Streamer & Storage { writeToStream: streamer.writeToStream, closeStream: streamer.closeStream, readFromStream: streamer.readFromStream, + listStreams: streamer.listStreams, // Storage interface with namespaced methods runs: storage.runs, diff --git a/packages/world-vercel/src/streamer.ts b/packages/world-vercel/src/streamer.ts index bf8fe05d3..f711eb873 100644 --- a/packages/world-vercel/src/streamer.ts +++ b/packages/world-vercel/src/streamer.ts @@ -55,5 +55,13 @@ export function createStreamer(config?: APIConfig): Streamer { if (!res.ok) throw new Error(`Failed to fetch stream: ${res.status}`); return res.body as ReadableStream; }, + + async listStreams(runId: string) { + const httpConfig = await getHttpConfig(config); + const url = new URL(`${httpConfig.baseUrl}/v1/runs/${runId}/streams`); + const res = await fetch(url, { headers: httpConfig.headers }); + if (!res.ok) throw new Error(`Failed to list streams: ${res.status}`); + return (await res.json()) as string[]; + }, }; } diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index 1682d9df7..b688d7c03 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -42,6 +42,7 @@ export interface Streamer { name: string, startIndex?: number ): Promise>; + listStreams(runId: string): Promise; } export interface Storage { From 05d14719f4643e111a834d8c99eddbe084f028aa Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 13:07:42 -0800 Subject: [PATCH 03/31] List streams endpoint --- packages/world-local/src/streamer.ts | 34 +++++++++++++++++++++++++ packages/world-postgres/src/streamer.ts | 15 ++++++++++- packages/world-vercel/src/backend.ts | 1 + packages/world-vercel/src/streamer.ts | 8 ++++++ packages/world/src/interfaces.ts | 1 + 5 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/world-local/src/streamer.ts b/packages/world-local/src/streamer.ts index d416b5cb3..7de02ef2d 100644 --- a/packages/world-local/src/streamer.ts +++ b/packages/world-local/src/streamer.ts @@ -113,6 +113,40 @@ export function createStreamer(basedir: string): Streamer { streamEmitter.emit(`close:${name}` as const, { streamName: name }); }, + async listStreams(runId: string) { + const chunksDir = path.join(basedir, 'streams', 'chunks'); + const files = await listJSONFiles(chunksDir); + + // Convert runId (wrun_{ULID}) to stream prefix (strm_{ULID}_user) + const streamPrefix = runId.replace('wrun_', 'strm_') + '_user'; + + // Extract unique stream names that match the run's prefix + const streamNames = new Set(); + for (const file of files) { + // Files are named: {streamName}-{chunkId} + // Find the last occurrence of '-strm_' to split correctly + const lastDashIndex = file.lastIndexOf('-strm_'); + if (lastDashIndex === -1) { + // Try splitting at the last dash for legacy format + const parts = file.split('-'); + if (parts.length >= 2) { + parts.pop(); // Remove chunkId + const streamName = parts.join('-'); + if (streamName.startsWith(streamPrefix)) { + streamNames.add(streamName); + } + } + } else { + const streamName = file.substring(0, lastDashIndex); + if (streamName.startsWith(streamPrefix)) { + streamNames.add(streamName); + } + } + } + + return Array.from(streamNames); + }, + async readFromStream(name: string, startIndex = 0) { const chunksDir = path.join(basedir, 'streams', 'chunks'); let removeListeners = () => {}; diff --git a/packages/world-postgres/src/streamer.ts b/packages/world-postgres/src/streamer.ts index 957df885d..a50360711 100644 --- a/packages/world-postgres/src/streamer.ts +++ b/packages/world-postgres/src/streamer.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; import type { Streamer } from '@workflow/world'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, like } from 'drizzle-orm'; import type { Sql } from 'postgres'; import { monotonicFactory } from 'ulid'; import * as z from 'zod'; @@ -207,5 +207,18 @@ export function createStreamer(postgres: Sql, drizzle: Drizzle): Streamer { }, }); }, + + async listStreams(runId: string): Promise { + // Convert runId (wrun_{ULID}) to stream prefix (strm_{ULID}_user) + const streamPrefix = runId.replace('wrun_', 'strm_') + '_user'; + + // Query distinct stream IDs that match the prefix + const results = await drizzle + .selectDistinct({ streamId: streams.streamId }) + .from(streams) + .where(like(streams.streamId, `${streamPrefix}%`)); + + return results.map((r) => r.streamId); + }, }; } diff --git a/packages/world-vercel/src/backend.ts b/packages/world-vercel/src/backend.ts index ee0045037..a74ee44a2 100644 --- a/packages/world-vercel/src/backend.ts +++ b/packages/world-vercel/src/backend.ts @@ -12,6 +12,7 @@ export function createVercel(config?: APIConfig): Streamer & Storage { writeToStream: streamer.writeToStream, closeStream: streamer.closeStream, readFromStream: streamer.readFromStream, + listStreams: streamer.listStreams, // Storage interface with namespaced methods runs: storage.runs, diff --git a/packages/world-vercel/src/streamer.ts b/packages/world-vercel/src/streamer.ts index bf8fe05d3..f711eb873 100644 --- a/packages/world-vercel/src/streamer.ts +++ b/packages/world-vercel/src/streamer.ts @@ -55,5 +55,13 @@ export function createStreamer(config?: APIConfig): Streamer { if (!res.ok) throw new Error(`Failed to fetch stream: ${res.status}`); return res.body as ReadableStream; }, + + async listStreams(runId: string) { + const httpConfig = await getHttpConfig(config); + const url = new URL(`${httpConfig.baseUrl}/v1/runs/${runId}/streams`); + const res = await fetch(url, { headers: httpConfig.headers }); + if (!res.ok) throw new Error(`Failed to list streams: ${res.status}`); + return (await res.json()) as string[]; + }, }; } diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index 1682d9df7..b688d7c03 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -42,6 +42,7 @@ export interface Streamer { name: string, startIndex?: number ): Promise>; + listStreams(runId: string): Promise; } export interface Storage { From a082c352ba677fae7c7fd1f11d2bc37b71004e4e Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 13:09:37 -0800 Subject: [PATCH 04/31] changes --- .changeset/poor-frogs-wear.md | 8 ++++++++ packages/cli/src/commands/inspect.ts | 4 ++-- packages/cli/src/lib/inspect/output.ts | 2 +- packages/world-local/src/streamer.ts | 2 +- packages/world-postgres/src/streamer.ts | 2 +- packages/world-vercel/src/backend.ts | 2 +- packages/world-vercel/src/streamer.ts | 2 +- packages/world/src/interfaces.ts | 2 +- 8 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 .changeset/poor-frogs-wear.md diff --git a/.changeset/poor-frogs-wear.md b/.changeset/poor-frogs-wear.md new file mode 100644 index 000000000..857377a35 --- /dev/null +++ b/.changeset/poor-frogs-wear.md @@ -0,0 +1,8 @@ +--- +"@workflow/world-postgres": patch +"@workflow/world-vercel": patch +"@workflow/world-local": patch +"@workflow/world": patch +--- + +Add listByRunId endpoint to Streamer interface diff --git a/packages/cli/src/commands/inspect.ts b/packages/cli/src/commands/inspect.ts index f34a2bcbc..6bd1eae15 100644 --- a/packages/cli/src/commands/inspect.ts +++ b/packages/cli/src/commands/inspect.ts @@ -4,12 +4,12 @@ import { LOGGING_CONFIG, logger } from '../lib/config/log.js'; import type { InspectCLIOptions } from '../lib/config/types.js'; import { cliFlags } from '../lib/inspect/flags.js'; import { + listByRunId, listEvents, listHooks, listRuns, listSleeps, listSteps, - listStreams, showHook, showRun, showStep, @@ -175,7 +175,7 @@ export default class Inspect extends BaseCommand { if (id) { await showStream(world, id, options); } else { - await listStreams(world, options); + await listByRunId(world, options); } process.exit(0); } diff --git a/packages/cli/src/lib/inspect/output.ts b/packages/cli/src/lib/inspect/output.ts index 4dcfb500c..f4d63c27c 100644 --- a/packages/cli/src/lib/inspect/output.ts +++ b/packages/cli/src/lib/inspect/output.ts @@ -782,7 +782,7 @@ export const showStream = async ( * Listing streams only lists available stream IDs based on run/step passed, * and doesn't read any data from the streams. */ -export const listStreams = async ( +export const listByRunId = async ( world: World, opts: InspectCLIOptions = {} ) => { diff --git a/packages/world-local/src/streamer.ts b/packages/world-local/src/streamer.ts index 7de02ef2d..f5fbf4397 100644 --- a/packages/world-local/src/streamer.ts +++ b/packages/world-local/src/streamer.ts @@ -113,7 +113,7 @@ export function createStreamer(basedir: string): Streamer { streamEmitter.emit(`close:${name}` as const, { streamName: name }); }, - async listStreams(runId: string) { + async listByRunId(runId: string) { const chunksDir = path.join(basedir, 'streams', 'chunks'); const files = await listJSONFiles(chunksDir); diff --git a/packages/world-postgres/src/streamer.ts b/packages/world-postgres/src/streamer.ts index a50360711..4f00cd2e1 100644 --- a/packages/world-postgres/src/streamer.ts +++ b/packages/world-postgres/src/streamer.ts @@ -208,7 +208,7 @@ export function createStreamer(postgres: Sql, drizzle: Drizzle): Streamer { }); }, - async listStreams(runId: string): Promise { + async listByRunId(runId: string): Promise { // Convert runId (wrun_{ULID}) to stream prefix (strm_{ULID}_user) const streamPrefix = runId.replace('wrun_', 'strm_') + '_user'; diff --git a/packages/world-vercel/src/backend.ts b/packages/world-vercel/src/backend.ts index a74ee44a2..8fb46a8b3 100644 --- a/packages/world-vercel/src/backend.ts +++ b/packages/world-vercel/src/backend.ts @@ -12,7 +12,7 @@ export function createVercel(config?: APIConfig): Streamer & Storage { writeToStream: streamer.writeToStream, closeStream: streamer.closeStream, readFromStream: streamer.readFromStream, - listStreams: streamer.listStreams, + listByRunId: streamer.listByRunId, // Storage interface with namespaced methods runs: storage.runs, diff --git a/packages/world-vercel/src/streamer.ts b/packages/world-vercel/src/streamer.ts index f711eb873..b73f3f50f 100644 --- a/packages/world-vercel/src/streamer.ts +++ b/packages/world-vercel/src/streamer.ts @@ -56,7 +56,7 @@ export function createStreamer(config?: APIConfig): Streamer { return res.body as ReadableStream; }, - async listStreams(runId: string) { + async listByRunId(runId: string) { const httpConfig = await getHttpConfig(config); const url = new URL(`${httpConfig.baseUrl}/v1/runs/${runId}/streams`); const res = await fetch(url, { headers: httpConfig.headers }); diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index b688d7c03..6e8dae863 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -42,7 +42,7 @@ export interface Streamer { name: string, startIndex?: number ): Promise>; - listStreams(runId: string): Promise; + listByRunId(runId: string): Promise; } export interface Storage { From 7856720f869403be48abd9f20352efe68c269b43 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 13:10:45 -0800 Subject: [PATCH 05/31] Changesset --- .changeset/flat-lemons-carry.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/flat-lemons-carry.md diff --git a/.changeset/flat-lemons-carry.md b/.changeset/flat-lemons-carry.md new file mode 100644 index 000000000..70ec3422b --- /dev/null +++ b/.changeset/flat-lemons-carry.md @@ -0,0 +1,7 @@ +--- +"@workflow/cli": patch +"@workflow/web": patch +"@workflow/web-shared": patch +--- + +Add Web and CLI UI for listing and viewing streams From 1afb19982a734b2695cb2f7930b8da5968e1e70c Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 13:23:35 -0800 Subject: [PATCH 06/31] UI --- packages/cli/src/lib/inspect/output.ts | 2 +- packages/core/src/observability.ts | 60 ++++++-- .../web-shared/src/api/workflow-api-client.ts | 50 ++++++ .../src/api/workflow-server-actions.ts | 19 +++ packages/web-shared/src/index.ts | 2 + .../src/sidebar/attribute-panel.tsx | 144 +++++++++++++++++- .../src/stream-viewer.tsx} | 114 +++++++------- .../run/[runId]/streams/[streamId]/page.tsx | 24 --- .../web/src/components/run-detail-view.tsx | 100 +++++++++++- 9 files changed, 422 insertions(+), 93 deletions(-) rename packages/{web/src/components/stream-detail-view.tsx => web-shared/src/stream-viewer.tsx} (52%) delete mode 100644 packages/web/src/app/run/[runId]/streams/[streamId]/page.tsx diff --git a/packages/cli/src/lib/inspect/output.ts b/packages/cli/src/lib/inspect/output.ts index a20c9ef60..6f9997c24 100644 --- a/packages/cli/src/lib/inspect/output.ts +++ b/packages/cli/src/lib/inspect/output.ts @@ -798,7 +798,7 @@ export const listByRunId = async ( } try { - const streamIds = await world.listStreams(runId); + const streamIds = await world.listByRunId(runId); const matchingStreams = streamIds.map((streamId) => ({ runId, streamId, diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index e4acbfac0..5292f0ed5 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -12,22 +12,62 @@ import { const STREAM_ID_PREFIX = 'strm_'; -/* - * Check if a value is a stream ID +/** + * Marker for stream reference objects that can be rendered as links + */ +export const STREAM_REF_TYPE = '__workflow_stream_ref__'; + +/** + * A stream reference object that contains the stream ID and can be + * detected in the UI to render as a clickable link + */ +export interface StreamRef { + __type: typeof STREAM_REF_TYPE; + streamId: string; +} + +/** + * Check if a value is a stream ID string */ export const isStreamId = (value: unknown): boolean => { return typeof value === 'string' && value.startsWith(STREAM_ID_PREFIX); }; -const streamToStreamId = (value: any): string => { +/** + * Check if a value is a StreamRef object + */ +export const isStreamRef = (value: unknown): value is StreamRef => { + return ( + value !== null && + typeof value === 'object' && + '__type' in value && + value.__type === STREAM_REF_TYPE && + 'streamId' in value && + typeof value.streamId === 'string' + ); +}; + +/** + * Create a StreamRef object from a stream value. + * This is used during hydration to convert serialized streams into + * objects that can be rendered as links in the UI. + */ +const streamToStreamRef = (value: any): StreamRef => { + let streamId: string; if ('name' in value) { const name = String(value.name); if (!name.startsWith(STREAM_ID_PREFIX)) { - return `${STREAM_ID_PREFIX}${name}`; + streamId = `${STREAM_ID_PREFIX}${name}`; + } else { + streamId = name; } - return name; + } else { + streamId = `${STREAM_ID_PREFIX}null`; } - return `${STREAM_ID_PREFIX}null`; + return { + __type: STREAM_REF_TYPE, + streamId, + }; }; const serializedStepFunctionToString = (value: unknown): string => { @@ -44,15 +84,15 @@ const serializedStepFunctionToString = (value: unknown): string => { /** * This is an extra reviver for devalue that takes any streams that would be converted, - * into actual streams, and instead formats them as string links for printing in CLI output. + * into actual streams, and instead formats them as StreamRef objects for display in the UI. * * This is mainly because we don't want to open any streams that we aren't going to read from, * and so we can get the string ID/name, which the serializer stream doesn't provide. */ const streamPrintRevivers: Record any> = { - ReadableStream: streamToStreamId, - WritableStream: streamToStreamId, - TransformStream: streamToStreamId, + ReadableStream: streamToStreamRef, + WritableStream: streamToStreamRef, + TransformStream: streamToStreamRef, StepFunction: serializedStepFunctionToString, }; diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index ea5d89f3d..622312291 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -20,6 +20,7 @@ import { fetchRuns, fetchStep, fetchSteps, + fetchStreams, readStreamServerAction, recreateRun as recreateRunServerAction, } from './workflow-server-actions'; @@ -1105,3 +1106,52 @@ export async function readStream( } return result; } + +/** + * List all stream IDs for a run + */ +export async function listStreams( + env: EnvMap, + runId: string +): Promise { + const { error, result } = await unwrapServerActionResult( + fetchStreams(env, runId) + ); + if (error) { + throw error; + } + return result; +} + +/** + * Hook to fetch and manage stream list for a run + */ +export function useWorkflowStreams(env: EnvMap, runId: string) { + const [streams, setStreams] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await listStreams(env, runId); + setStreams(result); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setLoading(false); + } + }, [env, runId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { + streams, + loading, + error, + refresh: fetchData, + }; +} diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index 3cb23fff5..218db539b 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -533,6 +533,25 @@ export async function readStreamServerAction( } } +/** + * List all stream IDs for a run + */ +export async function fetchStreams( + env: EnvMap, + runId: string +): Promise> { + try { + const world = getWorldFromEnv(env); + const streams = await world.listByRunId(runId); + return createResponse(streams); + } catch (error) { + console.error('Failed to list streams:', error); + return createServerActionError(error, 'world.listByRunId', { + runId, + }); + } +} + /** * Fetch the workflows manifest from the workflow route directory * The manifest is generated at build time and contains static structure info about workflows diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 91c81ae4b..c2c639e5b 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -8,9 +8,11 @@ export * from './api/workflow-api-client'; export type { EnvMap } from './api/workflow-server-actions'; export { fetchEventsByCorrelationId, + fetchStreams, fetchWorkflowsManifest, } from './api/workflow-server-actions'; export { formatDuration } from './lib/utils'; export { RunTraceView } from './run-trace-view'; +export { StreamViewer } from './stream-viewer'; export type { Span, SpanEvent } from './trace-viewer/types'; export { WorkflowTraceViewer } from './workflow-trace-view'; diff --git a/packages/web-shared/src/sidebar/attribute-panel.tsx b/packages/web-shared/src/sidebar/attribute-panel.tsx index 7eff76bf0..2b8f5a1bf 100644 --- a/packages/web-shared/src/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/sidebar/attribute-panel.tsx @@ -7,7 +7,149 @@ import type { ReactNode } from 'react'; import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert'; import { DetailCard } from './detail-card'; +/** + * Marker for stream reference objects that can be rendered as links + * This is duplicated from @workflow/core/observability to avoid pulling in + * Node.js dependencies into the client bundle. + */ +const STREAM_REF_TYPE = '__workflow_stream_ref__'; + +/** + * A stream reference object that contains the stream ID and can be + * detected in the UI to render as a clickable link + */ +interface StreamRef { + __type: typeof STREAM_REF_TYPE; + streamId: string; +} + +/** + * Check if a value is a StreamRef object + */ +const isStreamRef = (value: unknown): value is StreamRef => { + return ( + value !== null && + typeof value === 'object' && + '__type' in value && + value.__type === STREAM_REF_TYPE && + 'streamId' in value && + typeof value.streamId === 'string' + ); +}; + +/** + * Renders a StreamRef as a styled link/badge + */ +const StreamRefDisplay = ({ streamRef }: { streamRef: StreamRef }) => { + return ( + + + + + + {streamRef.streamId.length > 40 + ? `${streamRef.streamId.slice(0, 20)}...${streamRef.streamId.slice(-15)}` + : streamRef.streamId} + + ); +}; + +/** + * Recursively transforms a value for JSON display, replacing StreamRef objects + * with placeholder strings that can be identified and replaced with React elements + */ +const transformValueForDisplay = ( + value: unknown +): { json: string; streamRefs: Map } => { + const streamRefs = new Map(); + let counter = 0; + + const transform = (v: unknown): unknown => { + if (isStreamRef(v)) { + const placeholder = `__STREAM_REF_${counter++}__`; + streamRefs.set(placeholder, v); + return placeholder; + } + if (Array.isArray(v)) { + return v.map(transform); + } + if (v !== null && typeof v === 'object') { + const result: Record = {}; + for (const [key, val] of Object.entries(v)) { + result[key] = transform(val); + } + return result; + } + return v; + }; + + const transformed = transform(value); + return { + json: JSON.stringify(transformed, null, 2), + streamRefs, + }; +}; + const JsonBlock = (value: unknown) => { + const { json, streamRefs } = transformValueForDisplay(value); + + // If no stream refs, just render plain JSON + if (streamRefs.size === 0) { + return ( +
+        {json}
+      
+ ); + } + + // Split the JSON by stream ref placeholders and render with React elements + const parts: ReactNode[] = []; + let remaining = json; + let keyIndex = 0; + + for (const [placeholder, streamRef] of streamRefs) { + const index = remaining.indexOf(`"${placeholder}"`); + if (index !== -1) { + // Add text before the placeholder + if (index > 0) { + parts.push(remaining.slice(0, index)); + } + // Add the StreamRef component + parts.push(); + remaining = remaining.slice(index + placeholder.length + 2); // +2 for quotes + } + } + + // Add any remaining text + if (remaining) { + parts.push(remaining); + } + return (
 {
         color: 'var(--ds-gray-1000)',
       }}
     >
-      {JSON.stringify(value, null, 2)}
+      {parts}
     
); }; diff --git a/packages/web/src/components/stream-detail-view.tsx b/packages/web-shared/src/stream-viewer.tsx similarity index 52% rename from packages/web/src/components/stream-detail-view.tsx rename to packages/web-shared/src/stream-viewer.tsx index ef824f7c3..9235ca588 100644 --- a/packages/web/src/components/stream-detail-view.tsx +++ b/packages/web-shared/src/stream-viewer.tsx @@ -1,15 +1,12 @@ 'use client'; -import { readStream } from '@workflow/web-shared'; -import type { EnvMap } from '@workflow/web-shared/server'; -import { ChevronLeft } from 'lucide-react'; -import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { readStream } from './api/workflow-api-client'; +import type { EnvMap } from './api/workflow-server-actions'; -interface StreamDetailViewProps { +interface StreamViewerProps { env: EnvMap; + runId: string; streamId: string; } @@ -18,8 +15,12 @@ interface Chunk { text: string; } -export function StreamDetailView({ env, streamId }: StreamDetailViewProps) { - const router = useRouter(); +/** + * StreamViewer component that displays real-time stream data. + * 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); const [error, setError] = useState(null); @@ -32,8 +33,7 @@ export function StreamDetailView({ env, streamId }: StreamDetailViewProps) { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chunks.length]); // Only depend on length, not the entire array + }, [chunks.length]); useEffect(() => { let mounted = true; @@ -105,52 +105,58 @@ export function StreamDetailView({ env, streamId }: StreamDetailViewProps) { }, [env, streamId]); return ( -
- - - - -
- {streamId} -
- - {isLive ? '● Live' : '● Closed'} - -
+ {error ? ( +
+
Error reading stream:
+
{error}
- - -
- {error ? ( -
-
Error reading stream:
-
{error}
-
- ) : chunks.length === 0 ? ( -
- {isLive ? 'Waiting for stream data...' : 'Stream is empty'} -
- ) : ( - chunks.map((chunk) => ( - {chunk.text} - )) - )} + ) : chunks.length === 0 ? ( +
+ {isLive ? 'Waiting for stream data...' : 'Stream is empty'}
- - + ) : ( + chunks.map((chunk) => ( + {chunk.text} + )) + )} +
); } diff --git a/packages/web/src/app/run/[runId]/streams/[streamId]/page.tsx b/packages/web/src/app/run/[runId]/streams/[streamId]/page.tsx deleted file mode 100644 index 003ba1847..000000000 --- a/packages/web/src/app/run/[runId]/streams/[streamId]/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; - -import { useParams } from 'next/navigation'; -import { useMemo } from 'react'; -import { ErrorBoundary } from '@/components/error-boundary'; -import { StreamDetailView } from '@/components/stream-detail-view'; -import { useQueryParamConfig, worldConfigToEnvMap } from '@/lib/config'; - -export default function StreamDetailPage() { - const params = useParams(); - - const config = useQueryParamConfig(); - const env = useMemo(() => worldConfigToEnvMap(config), [config]); - const streamId = params.streamId as string; - - return ( - - - - ); -} diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index 05b7ab507..b6d6df306 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -4,6 +4,8 @@ import { parseWorkflowName } from '@workflow/core/parse-name'; import { cancelRun, recreateRun, + StreamViewer, + useWorkflowStreams, useWorkflowTraceViewerData, type WorkflowRun, WorkflowTraceViewer, @@ -65,7 +67,8 @@ export function RunDetailView({ const [rerunning, setRerunning] = useState(false); const [showCancelDialog, setShowCancelDialog] = useState(false); const [showRerunDialog, setShowRerunDialog] = useState(false); - const [activeTab, setActiveTab] = useState<'trace' | 'graph'>('trace'); + const [activeTab, setActiveTab] = useState<'trace' | 'streams'>('trace'); + const [selectedStreamId, setSelectedStreamId] = useState(null); const env = useMemo(() => worldConfigToEnvMap(config), [config]); // Fetch workflow graph manifest @@ -88,6 +91,13 @@ export function RunDetailView({ } = useWorkflowTraceViewerData(env, runId, { live: true }); const run = runData ?? ({} as WorkflowRun); + // Fetch streams for this run + const { + streams, + loading: streamsLoading, + error: streamsError, + } = useWorkflowStreams(env, runId); + // Find the workflow graph for this run // The manifest is keyed by workflowId which matches run.workflowName // e.g., "workflow//example/workflows/1_simple.ts//simple" @@ -416,7 +426,7 @@ export function RunDetailView({
setActiveTab(v as 'trace' | 'graph')} + onValueChange={(v) => setActiveTab(v as 'trace' | 'streams')} className="flex-1 flex flex-col min-h-0" > @@ -449,7 +459,91 @@ export function RunDetailView({ -
+
+ {/* Stream list sidebar */} +
+
+ Streams ({streams.length}) +
+
+ {streamsLoading ? ( +
+ +
+ ) : streamsError ? ( +
+ {streamsError.message} +
+ ) : streams.length === 0 ? ( +
+ No streams found for this run +
+ ) : ( + streams.map((streamId) => ( + + )) + )} +
+
+ + {/* Stream viewer */} +
+ {selectedStreamId ? ( + + ) : ( +
+
+ {streams.length > 0 + ? 'Select a stream to view its data' + : 'No streams available'} +
+
+ )} +
+
{/* From c4b7febf06f29fe5c85180adc25479128350ce7d Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 13:53:55 -0800 Subject: [PATCH 07/31] Various UI fixes --- .../web-shared/src/api/workflow-api-client.ts | 50 +++++- .../src/api/workflow-server-actions.ts | 31 ++-- .../src/sidebar/attribute-panel.tsx | 146 +++++++++++------- .../src/sidebar/workflow-detail-panel.tsx | 4 + .../web-shared/src/workflow-trace-view.tsx | 11 +- .../web/src/components/run-detail-view.tsx | 55 ++++++- 6 files changed, 205 insertions(+), 92 deletions(-) diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index 622312291..5da26ebcc 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -74,7 +74,15 @@ export async function unwrapServerActionResult( ): Promise< { error: WorkflowWebAPIError; result: null } | { error: null; result: T } > { - const result = await promise; + let result: { success: boolean; data?: T; error?: ServerActionError }; + try { + result = await promise; + } catch (error) { + result = { + success: false, + error: error as ServerActionError, + }; + } if (!result.success) { console.error('[web-api-client] error', result.error); if (!result.error) { @@ -1093,18 +1101,46 @@ export async function recreateRun(env: EnvMap, runId: string): Promise { return resultData; } +function isServerActionError(value: unknown): value is ServerActionError { + return ( + typeof value === 'object' && + value !== null && + 'message' in value && + 'layer' in value && + 'cause' in value && + 'request' in value + ); +} + export async function readStream( env: EnvMap, streamId: string, startIndex?: number ): Promise> { - const { error, result } = await unwrapServerActionResult( - readStreamServerAction(env, streamId, startIndex) - ); - if (error) { - throw error; + try { + const stream = await readStreamServerAction(env, streamId, startIndex); + if (!stream) { + throw new WorkflowWebAPIError('Failed to read stream', { + layer: 'client', + }); + } + if (isServerActionError(stream)) { + throw new WorkflowWebAPIError(stream.message, { + layer: 'client', + cause: stream.cause, + request: stream.request, + }); + } + return stream; + } catch (error) { + if (error instanceof WorkflowWebAPIError) { + throw error; + } + throw new WorkflowWebAPIError('Failed to read stream', { + layer: 'client', + cause: error, + }); } - return result; } /** diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index 218db539b..144cd4653 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -297,7 +297,6 @@ export async function fetchSteps( hasMore: result.hasMore, }); } catch (error) { - console.error('Failed to fetch steps:', error); return createServerActionError>( error, 'world.steps.list', @@ -324,7 +323,6 @@ export async function fetchStep( const hydratedStep = hydrate(step as Step); return createResponse(hydratedStep); } catch (error) { - console.error('Failed to fetch step:', error); return createServerActionError(error, 'world.steps.get', { runId, stepId, @@ -359,7 +357,6 @@ export async function fetchEvents( hasMore: result.hasMore, }); } catch (error) { - console.error('Failed to fetch events:', error); return createServerActionError>( error, 'world.events.list', @@ -398,7 +395,6 @@ export async function fetchEventsByCorrelationId( hasMore: result.hasMore, }); } catch (error) { - console.error('Failed to fetch events by correlation ID:', error); return createServerActionError>( error, 'world.events.listByCorrelationId', @@ -436,7 +432,6 @@ export async function fetchHooks( hasMore: result.hasMore, }); } catch (error) { - console.error('Failed to fetch hooks:', error); return createServerActionError>( error, 'world.hooks.list', @@ -458,7 +453,6 @@ export async function fetchHook( const hook = await world.hooks.get(hookId, { resolveData }); return createResponse(hydrate(hook as Hook)); } catch (error) { - console.error('Failed to fetch hook:', error); return createServerActionError(error, 'world.hooks.get', { hookId, resolveData, @@ -478,7 +472,6 @@ export async function cancelRun( await world.runs.cancel(runId); return createResponse(undefined); } catch (error) { - console.error('Failed to cancel run:', error); return createServerActionError(error, 'world.runs.cancel', { runId }); } } @@ -506,7 +499,6 @@ export async function recreateRun( ); return createResponse(newRun.runId); } catch (error) { - console.error('Failed to start run:', error); return createServerActionError(error, 'recreateRun', { runId }); } } @@ -515,21 +507,21 @@ export async function readStreamServerAction( env: EnvMap, streamId: string, startIndex?: number -): Promise>> { +): Promise | ServerActionError> { try { const world = getWorldFromEnv(env); const stream = await world.readFromStream(streamId, startIndex); - return createResponse(stream); + return stream; } catch (error) { - console.error('Failed to read stream:', error); - return createServerActionError>( - error, - 'world.readFromStream', - { - streamId, - startIndex, - } - ); + const actionError = createServerActionError(error, 'world.readFromStream', { + streamId, + startIndex, + }); + if (!actionError.success) { + return actionError.error; + } + // Shouldn't happen, this is just a type guard + throw new Error(); } } @@ -545,7 +537,6 @@ export async function fetchStreams( const streams = await world.listByRunId(runId); return createResponse(streams); } catch (error) { - console.error('Failed to list streams:', error); return createServerActionError(error, 'world.listByRunId', { runId, }); diff --git a/packages/web-shared/src/sidebar/attribute-panel.tsx b/packages/web-shared/src/sidebar/attribute-panel.tsx index 2b8f5a1bf..b5f08607e 100644 --- a/packages/web-shared/src/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/sidebar/attribute-panel.tsx @@ -3,10 +3,17 @@ import { parseStepName, parseWorkflowName } from '@workflow/core/parse-name'; import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; import { AlertCircle } from 'lucide-react'; -import type { ReactNode } from 'react'; +import { createContext, type ReactNode, useContext } from 'react'; import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert'; import { DetailCard } from './detail-card'; +/** + * Context for stream click handler + */ +const StreamClickContext = createContext< + ((streamId: string) => void) | undefined +>(undefined); + /** * Marker for stream reference objects that can be rendered as links * This is duplicated from @workflow/core/observability to avoid pulling in @@ -25,8 +32,11 @@ interface StreamRef { /** * Check if a value is a StreamRef object + * */ const isStreamRef = (value: unknown): value is StreamRef => { + // TODO: This is duplicated from @workflow/core/observability, but can't be pulled + // in client-side code because it's a Node.js dependency. return ( value !== null && typeof value === 'object' && @@ -41,15 +51,25 @@ const isStreamRef = (value: unknown): value is StreamRef => { * Renders a StreamRef as a styled link/badge */ const StreamRefDisplay = ({ streamRef }: { streamRef: StreamRef }) => { + const onStreamClick = useContext(StreamClickContext); + + const handleClick = () => { + if (onStreamClick) { + onStreamClick(streamRef.streamId); + } + }; + return ( - { strokeLinecap="round" strokeLinejoin="round" > + Stream icon {streamRef.streamId.length > 40 ? `${streamRef.streamId.slice(0, 20)}...${streamRef.streamId.slice(-15)}` : streamRef.streamId} - + ); }; @@ -452,11 +473,14 @@ export const AttributePanel = ({ isLoading, error, expiredAt, + onStreamClick, }: { data: Record; isLoading?: boolean; error?: Error; expiredAt?: string | Date; + /** Callback when a stream reference is clicked */ + onStreamClick?: (streamId: string) => void; }) => { const displayData = data; const hasExpired = expiredAt != null && new Date(expiredAt) < new Date(); @@ -479,62 +503,64 @@ export const AttributePanel = ({ }); return ( -
- {/* Basic attributes in a vertical layout with border */} - {visibleBasicAttributes.length > 0 && ( -
- {visibleBasicAttributes.map((attribute) => ( -
- - {attribute} - - +
+ {/* Basic attributes in a vertical layout with border */} + {visibleBasicAttributes.length > 0 && ( +
+ {visibleBasicAttributes.map((attribute) => ( +
- {attributeToDisplayFn[ - attribute as keyof typeof attributeToDisplayFn - ]?.(displayData[attribute as keyof typeof displayData])} - -
- ))} -
- )} - {error ? ( - - - Failed to load resource details - - {error.message} - - - ) : hasExpired ? ( - - ) : ( - resolvedAttributes.map((attribute) => ( - - )) - )} -
+ + {attribute} + + + {attributeToDisplayFn[ + attribute as keyof typeof attributeToDisplayFn + ]?.(displayData[attribute as keyof typeof displayData])} + +
+ ))} +
+ )} + {error ? ( + + + Failed to load resource details + + {error.message} + + + ) : hasExpired ? ( + + ) : ( + resolvedAttributes.map((attribute) => ( + + )) + )} +
+ ); }; diff --git a/packages/web-shared/src/sidebar/workflow-detail-panel.tsx b/packages/web-shared/src/sidebar/workflow-detail-panel.tsx index 72856758d..fac59f1c6 100644 --- a/packages/web-shared/src/sidebar/workflow-detail-panel.tsx +++ b/packages/web-shared/src/sidebar/workflow-detail-panel.tsx @@ -16,9 +16,12 @@ import { AttributePanel } from './attribute-panel'; export function WorkflowDetailPanel({ env, run, + onStreamClick, }: { env: EnvMap; run: WorkflowRun; + /** Callback when a stream reference is clicked */ + onStreamClick?: (streamId: string) => void; }): React.JSX.Element | null { const { state } = useTraceViewer(); const { selected } = state; @@ -85,6 +88,7 @@ export function WorkflowDetailPanel({ expiredAt={run.expiredAt} isLoading={loading} error={error ?? undefined} + onStreamClick={onStreamClick} /> {resource !== 'run' && ( void; }) => { const [now, setNow] = useState(() => new Date()); @@ -179,7 +182,13 @@ export const WorkflowTraceViewer = ({ withPanel customSpanClassNameFunc={getCustomSpanClassName} customSpanEventClassNameFunc={getCustomSpanEventClassName} - customPanelComponent={} + customPanelComponent={ + + } > diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index b6d6df306..f8261aea7 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -12,8 +12,8 @@ import { } from '@workflow/web-shared'; import { AlertCircle, HelpCircle, List, Loader2 } from 'lucide-react'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { @@ -63,14 +63,60 @@ export function RunDetailView({ selectedId: _selectedId, }: RunDetailViewProps) { const router = useRouter(); + const searchParams = useSearchParams(); const [cancelling, setCancelling] = useState(false); const [rerunning, setRerunning] = useState(false); const [showCancelDialog, setShowCancelDialog] = useState(false); const [showRerunDialog, setShowRerunDialog] = useState(false); - const [activeTab, setActiveTab] = useState<'trace' | 'streams'>('trace'); - const [selectedStreamId, setSelectedStreamId] = useState(null); const env = useMemo(() => worldConfigToEnvMap(config), [config]); + // Read tab and streamId from URL search params + const activeTab = (searchParams.get('tab') as 'trace' | 'streams') || 'trace'; + const selectedStreamId = searchParams.get('streamId'); + + // Helper to update URL search params + const updateSearchParams = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + for (const [key, value] of Object.entries(updates)) { + if (value === null) { + params.delete(key); + } else { + params.set(key, value); + } + } + router.push(`?${params.toString()}`, { scroll: false }); + }, + [router, searchParams] + ); + + const setActiveTab = useCallback( + (tab: 'trace' | 'streams') => { + // When switching to trace tab, clear streamId + if (tab === 'trace') { + updateSearchParams({ tab, streamId: null }); + } else { + updateSearchParams({ tab }); + } + }, + [updateSearchParams] + ); + + const setSelectedStreamId = useCallback( + (streamId: string | null) => { + updateSearchParams({ streamId }); + }, + [updateSearchParams] + ); + + // Handler for clicking on stream refs in the trace viewer + const handleStreamClick = useCallback( + (streamId: string) => { + updateSearchParams({ tab: 'streams', streamId }); + }, + [updateSearchParams] + ); + // Fetch workflow graph manifest // const { // manifest: graphManifest, @@ -454,6 +500,7 @@ export function RunDetailView({ env={env} run={run} isLoading={loading} + onStreamClick={handleStreamClick} />
From c11e1fbad9c16df28407b8415320077ef4e1ae1b Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 13:55:06 -0800 Subject: [PATCH 08/31] Undo --- .../web/src/components/run-detail-view.tsx | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index 05b7ab507..19abf0542 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -8,7 +8,8 @@ import { type WorkflowRun, WorkflowTraceViewer, } from '@workflow/web-shared'; -import { AlertCircle, HelpCircle, List, Loader2 } from 'lucide-react'; +import { AlertCircle, HelpCircle, Loader2 } from 'lucide-react'; +// import { List, Network } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useMemo, useState } from 'react'; @@ -32,7 +33,8 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +// import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +// import { WorkflowGraphExecutionViewer } from '@/components/workflow-graph-execution-viewer'; import { Tooltip, TooltipContent, @@ -40,6 +42,8 @@ import { } from '@/components/ui/tooltip'; import { buildUrlWithConfig, worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; +// import { mapRunToExecution } from '@/lib/graph-execution-mapper'; +// import { useWorkflowGraphManifest } from '@/lib/use-workflow-graph'; import { CancelButton } from './display-utils/cancel-button'; import { CopyableText } from './display-utils/copyable-text'; import { LiveStatus } from './display-utils/live-status'; @@ -65,10 +69,11 @@ export function RunDetailView({ const [rerunning, setRerunning] = useState(false); const [showCancelDialog, setShowCancelDialog] = useState(false); const [showRerunDialog, setShowRerunDialog] = useState(false); - const [activeTab, setActiveTab] = useState<'trace' | 'graph'>('trace'); + // const [activeTab, setActiveTab] = useState<'trace' | 'graph'>('trace'); const env = useMemo(() => worldConfigToEnvMap(config), [config]); // Fetch workflow graph manifest + // TODO(Karthik): Uncomment after https://github.com/vercel/workflow/pull/455 is merged // const { // manifest: graphManifest, // loading: graphLoading, @@ -414,7 +419,8 @@ export function RunDetailView({
- setActiveTab(v as 'trace' | 'graph')} className="flex-1 flex flex-col min-h-0" @@ -424,14 +430,10 @@ export function RunDetailView({ Trace - - - Streams - - {/* + Graph - */} + @@ -448,11 +450,7 @@ export function RunDetailView({
- -
-
- - {/* +
{graphLoading ? (
@@ -489,8 +487,21 @@ export function RunDetailView({ /> )}
- */} - + + */} + + {/* Default trace view */} +
+ +
{auxiliaryDataLoading && (
From bfbfe88986bb83ec7f6a3dccb69e608acb97f73e Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 13:55:10 -0800 Subject: [PATCH 09/31] Revert "Undo" This reverts commit c11e1fbad9c16df28407b8415320077ef4e1ae1b. --- .../web/src/components/run-detail-view.tsx | 45 +++++++------------ 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index 19abf0542..05b7ab507 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -8,8 +8,7 @@ import { type WorkflowRun, WorkflowTraceViewer, } from '@workflow/web-shared'; -import { AlertCircle, HelpCircle, Loader2 } from 'lucide-react'; -// import { List, Network } from 'lucide-react'; +import { AlertCircle, HelpCircle, List, Loader2 } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useMemo, useState } from 'react'; @@ -33,8 +32,7 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb'; -// import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -// import { WorkflowGraphExecutionViewer } from '@/components/workflow-graph-execution-viewer'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, @@ -42,8 +40,6 @@ import { } from '@/components/ui/tooltip'; import { buildUrlWithConfig, worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; -// import { mapRunToExecution } from '@/lib/graph-execution-mapper'; -// import { useWorkflowGraphManifest } from '@/lib/use-workflow-graph'; import { CancelButton } from './display-utils/cancel-button'; import { CopyableText } from './display-utils/copyable-text'; import { LiveStatus } from './display-utils/live-status'; @@ -69,11 +65,10 @@ export function RunDetailView({ const [rerunning, setRerunning] = useState(false); const [showCancelDialog, setShowCancelDialog] = useState(false); const [showRerunDialog, setShowRerunDialog] = useState(false); - // const [activeTab, setActiveTab] = useState<'trace' | 'graph'>('trace'); + const [activeTab, setActiveTab] = useState<'trace' | 'graph'>('trace'); const env = useMemo(() => worldConfigToEnvMap(config), [config]); // Fetch workflow graph manifest - // TODO(Karthik): Uncomment after https://github.com/vercel/workflow/pull/455 is merged // const { // manifest: graphManifest, // loading: graphLoading, @@ -419,8 +414,7 @@ export function RunDetailView({
- {/* TODO(Karthik): Uncomment after https://github.com/vercel/workflow/pull/455 is merged */} - {/* setActiveTab(v as 'trace' | 'graph')} className="flex-1 flex flex-col min-h-0" @@ -430,10 +424,14 @@ export function RunDetailView({ Trace - + + + Streams + + {/* Graph - + */} @@ -450,7 +448,11 @@ export function RunDetailView({
- + +
+
+ + {/*
{graphLoading ? (
@@ -487,21 +489,8 @@ export function RunDetailView({ /> )}
- - */} - - {/* Default trace view */} -
- -
+ */} + {auxiliaryDataLoading && (
From 6ce1398530506a8f57363d06928e25af023ce465 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 14:12:30 -0800 Subject: [PATCH 10/31] Fix stream deserialization --- packages/cli/src/lib/inspect/output.ts | 2 +- .../web-shared/src/api/workflow-server-actions.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/inspect/output.ts b/packages/cli/src/lib/inspect/output.ts index 6f9997c24..fa1eaba95 100644 --- a/packages/cli/src/lib/inspect/output.ts +++ b/packages/cli/src/lib/inspect/output.ts @@ -749,7 +749,7 @@ export const showStream = async ( ) => { if (opts.runId || opts.stepId) { logger.warn( - 'Filtering by run-id or step-id is not supported in get calls, ignoring filter.' + 'Filtering by run-id or step-id is not supported when showing a stream, ignoring filter.' ); } const run = getRun(streamId); diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index 144cd4653..eb6ca0fbc 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -4,6 +4,10 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { hydrateResourceIO } from '@workflow/core/observability'; import { createWorld, start } from '@workflow/core/runtime'; +import { + getDeserializeStream, + getExternalRevivers, +} from '@workflow/core/serialization'; import { WorkflowAPIError, WorkflowRunNotFoundError } from '@workflow/errors'; import type { Event, @@ -510,7 +514,14 @@ export async function readStreamServerAction( ): Promise | ServerActionError> { try { const world = getWorldFromEnv(env); + // We should probably use getRun().getReadable() instead, to make the UI + // more consistent with runtime behavior, and also expose a "replay" and "startIndex", + // feature, to allow for testing World behavior. const stream = await world.readFromStream(streamId, startIndex); + + const revivers = getExternalRevivers(globalThis, [], ''); + const transform = getDeserializeStream(revivers); + return stream; } catch (error) { const actionError = createServerActionError(error, 'world.readFromStream', { From 767ac12e2ace26f501172fafbe3e19bc30cfdf32 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 14:54:09 -0800 Subject: [PATCH 11/31] Add test --- packages/world-local/src/streamer.test.ts | 92 +++++++++++++++++++++++ packages/world-local/src/streamer.ts | 4 +- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/world-local/src/streamer.test.ts b/packages/world-local/src/streamer.test.ts index 033ce36c7..ea59363e9 100644 --- a/packages/world-local/src/streamer.test.ts +++ b/packages/world-local/src/streamer.test.ts @@ -508,7 +508,99 @@ describe('streamer', () => { const content = chunks.join(''); expect(content).toBe('0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n'); }); + }); + + describe('listByRunId', () => { + it('should return empty array when no streams exist', async () => { + const { streamer } = await setupStreamer(); + + const streams = await streamer.listByRunId(TEST_RUN_ID); + expect(streams).toEqual([]); + }); + + it('should return streams that match the runId prefix', async () => { + const { streamer } = await setupStreamer(); + + // Create stream names that follow the expected pattern: + // runId wrun_test12345678901234 -> stream prefix strm_test12345678901234_user + const streamPrefix = TEST_RUN_ID.replace('wrun_', 'strm_') + '_user'; + const streamName1 = `${streamPrefix}_stdout`; + const streamName2 = `${streamPrefix}_stderr`; + + await streamer.writeToStream(streamName1, TEST_RUN_ID, 'stdout output'); + await streamer.writeToStream(streamName2, TEST_RUN_ID, 'stderr output'); + await streamer.closeStream(streamName1, TEST_RUN_ID); + await streamer.closeStream(streamName2, TEST_RUN_ID); + + const streams = await streamer.listByRunId(TEST_RUN_ID); + + expect(streams).toHaveLength(2); + expect(streams).toContain(streamName1); + expect(streams).toContain(streamName2); + }); + + it('should not return streams from different runIds', async () => { + const { streamer } = await setupStreamer(); + + const otherRunId = 'wrun_other1234567890123'; + const targetPrefix = TEST_RUN_ID.replace('wrun_', 'strm_') + '_user'; + const otherPrefix = otherRunId.replace('wrun_', 'strm_') + '_user'; + + const targetStream = `${targetPrefix}_stdout`; + const otherStream = `${otherPrefix}_stdout`; + + await streamer.writeToStream( + targetStream, + TEST_RUN_ID, + 'target output' + ); + await streamer.writeToStream(otherStream, otherRunId, 'other output'); + + const streams = await streamer.listByRunId(TEST_RUN_ID); + + expect(streams).toHaveLength(1); + expect(streams).toContain(targetStream); + expect(streams).not.toContain(otherStream); + }); + + it('should return unique stream names even with multiple chunks', async () => { + const { streamer } = await setupStreamer(); + + const streamPrefix = TEST_RUN_ID.replace('wrun_', 'strm_') + '_user'; + const streamName = `${streamPrefix}_output`; + + // Write multiple chunks to the same stream + await streamer.writeToStream(streamName, TEST_RUN_ID, 'chunk1'); + await new Promise((resolve) => setTimeout(resolve, 2)); + await streamer.writeToStream(streamName, TEST_RUN_ID, 'chunk2'); + await new Promise((resolve) => setTimeout(resolve, 2)); + await streamer.writeToStream(streamName, TEST_RUN_ID, 'chunk3'); + await streamer.closeStream(streamName, TEST_RUN_ID); + + const streams = await streamer.listByRunId(TEST_RUN_ID); + + // Should only return the stream name once, not once per chunk + expect(streams).toHaveLength(1); + expect(streams).toContain(streamName); + }); + + it('should handle stream names with dashes', async () => { + const { streamer } = await setupStreamer(); + + const streamPrefix = TEST_RUN_ID.replace('wrun_', 'strm_') + '_user'; + const streamName = `${streamPrefix}_my-complex-stream-name`; + + await streamer.writeToStream(streamName, TEST_RUN_ID, 'data'); + await streamer.closeStream(streamName, TEST_RUN_ID); + const streams = await streamer.listByRunId(TEST_RUN_ID); + + expect(streams).toHaveLength(1); + expect(streams).toContain(streamName); + }); + }); + + describe('integration scenarios', () => { it('should handle runId as a promise and flush correctly when promise resolves', async () => { const { streamer } = await setupStreamer(); const streamName = 'promise-runid-test'; diff --git a/packages/world-local/src/streamer.ts b/packages/world-local/src/streamer.ts index f5fbf4397..96c9b2f0f 100644 --- a/packages/world-local/src/streamer.ts +++ b/packages/world-local/src/streamer.ts @@ -195,7 +195,7 @@ export function createStreamer(basedir: string): Streamer { streamEmitter.off(`close:${name}` as const, closeListener); try { controller.close(); - } catch (e) { + } catch { // Ignore if controller is already closed (e.g., from cancel() or EOF) } }; @@ -252,7 +252,7 @@ export function createStreamer(basedir: string): Streamer { removeListeners(); try { controller.close(); - } catch (e) { + } catch { // Ignore if controller is already closed (e.g., from closeListener event) } return; From 7fb59fbfd2686c300fd2d9f9f0a2816d163e3cfe Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 15:16:41 -0800 Subject: [PATCH 12/31] Fix --- .changeset/flat-lemons-carry.md | 1 + packages/cli/src/lib/inspect/output.ts | 5 ++--- packages/web-shared/src/api/workflow-server-actions.ts | 4 ---- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.changeset/flat-lemons-carry.md b/.changeset/flat-lemons-carry.md index 70ec3422b..5fee5a71d 100644 --- a/.changeset/flat-lemons-carry.md +++ b/.changeset/flat-lemons-carry.md @@ -1,5 +1,6 @@ --- "@workflow/cli": patch +"@workflow/core": patch "@workflow/web": patch "@workflow/web-shared": patch --- diff --git a/packages/cli/src/lib/inspect/output.ts b/packages/cli/src/lib/inspect/output.ts index fa1eaba95..7544a5031 100644 --- a/packages/cli/src/lib/inspect/output.ts +++ b/packages/cli/src/lib/inspect/output.ts @@ -743,7 +743,7 @@ export const showStep = async ( }; export const showStream = async ( - _: World, + world: World, streamId: string, opts: InspectCLIOptions = {} ) => { @@ -752,8 +752,7 @@ export const showStream = async ( 'Filtering by run-id or step-id is not supported when showing a stream, ignoring filter.' ); } - const run = getRun(streamId); - const stream = run.readable; + const stream = await world.readFromStream(streamId); logger.info('Streaming to stdout, press CTRL+C to abort.'); logger.info( 'Use --json to output the stream as newline-delimited JSON without info logs.\n' diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index eb6ca0fbc..6057bdb8c 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -518,10 +518,6 @@ export async function readStreamServerAction( // more consistent with runtime behavior, and also expose a "replay" and "startIndex", // feature, to allow for testing World behavior. const stream = await world.readFromStream(streamId, startIndex); - - const revivers = getExternalRevivers(globalThis, [], ''); - const transform = getDeserializeStream(revivers); - return stream; } catch (error) { const actionError = createServerActionError(error, 'world.readFromStream', { From b7dda1da76f03d8b1c251e811208d81d91cb8ca3 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 15:24:25 -0800 Subject: [PATCH 13/31] Pretty print streams --- packages/cli/src/lib/inspect/output.ts | 12 +++- packages/cli/src/lib/inspect/stream.ts | 17 +++-- .../web-shared/src/api/workflow-api-client.ts | 2 +- .../src/api/workflow-server-actions.ts | 8 ++- packages/web-shared/src/stream-viewer.tsx | 66 ++++++++++++------- 5 files changed, 73 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/lib/inspect/output.ts b/packages/cli/src/lib/inspect/output.ts index 7544a5031..f41ae9c01 100644 --- a/packages/cli/src/lib/inspect/output.ts +++ b/packages/cli/src/lib/inspect/output.ts @@ -1,6 +1,10 @@ import { hydrateResourceIO } from '@workflow/core/observability'; import { parseStepName, parseWorkflowName } from '@workflow/core/parse-name'; import { getRun } from '@workflow/core/runtime'; +import { + getDeserializeStream, + getExternalRevivers, +} from '@workflow/core/serialization'; import type { Event, Hook, @@ -752,7 +756,13 @@ export const showStream = async ( 'Filtering by run-id or step-id is not supported when showing a stream, ignoring filter.' ); } - const stream = await world.readFromStream(streamId); + const rawStream = await world.readFromStream(streamId); + + // Deserialize the stream to get JavaScript objects + const revivers = getExternalRevivers(globalThis, [], ''); + const transform = getDeserializeStream(revivers); + const stream = rawStream.pipeThrough(transform); + logger.info('Streaming to stdout, press CTRL+C to abort.'); logger.info( 'Use --json to output the stream as newline-delimited JSON without info logs.\n' diff --git a/packages/cli/src/lib/inspect/stream.ts b/packages/cli/src/lib/inspect/stream.ts index 238c081b5..10dad9037 100644 --- a/packages/cli/src/lib/inspect/stream.ts +++ b/packages/cli/src/lib/inspect/stream.ts @@ -2,11 +2,11 @@ import { logger } from '../config/log.js'; import type { InspectCLIOptions } from '../config/types.js'; /** - * This function will read from the stream and write the output to the console. + * This function will read from a deserialized stream and write the output to the console. * If the stream is not closed, this function will block until the stream is closed. */ export const streamToConsole = async ( - stream: ReadableStream, + stream: ReadableStream, id: string, opts: InspectCLIOptions ) => { @@ -14,6 +14,7 @@ export const streamToConsole = async ( // Keep the Node.js event loop alive while we await stream closure. // Pending Promises alone do not keep the process alive when using oclif. const keepAlive = setInterval(() => {}, 60_000); + let chunkIndex = 0; try { for (;;) { const { value, done } = await reader.read(); @@ -21,15 +22,19 @@ export const streamToConsole = async ( break; } - // Skip empty chunks - this prevents crashes when value is undefined/null - // but done is false (stream waiting for more data) - if (!value || value.byteLength === 0) { + // Skip empty chunks + if (value === undefined || value === null) { continue; } + if (opts.json) { process.stdout.write(`${JSON.stringify(value)}\n`); } else { - logger.log(value); + // Format the value for display + const text = + typeof value === 'string' ? value : JSON.stringify(value, null, 2); + logger.log(`[${chunkIndex}] ${text}`); + chunkIndex++; } } } catch (err) { diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index 5da26ebcc..39cace1d6 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -1116,7 +1116,7 @@ export async function readStream( env: EnvMap, streamId: string, startIndex?: number -): Promise> { +): Promise> { try { const stream = await readStreamServerAction(env, streamId, startIndex); if (!stream) { diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index 6057bdb8c..fe5d64aaf 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -511,14 +511,18 @@ export async function readStreamServerAction( env: EnvMap, streamId: string, startIndex?: number -): Promise | ServerActionError> { +): Promise | ServerActionError> { try { const world = getWorldFromEnv(env); // We should probably use getRun().getReadable() instead, to make the UI // more consistent with runtime behavior, and also expose a "replay" and "startIndex", // feature, to allow for testing World behavior. const stream = await world.readFromStream(streamId, startIndex); - return stream; + + const revivers = getExternalRevivers(globalThis, [], ''); + const transform = getDeserializeStream(revivers); + + return stream.pipeThrough(transform); } catch (error) { const actionError = createServerActionError(error, 'world.readFromStream', { streamId, diff --git a/packages/web-shared/src/stream-viewer.tsx b/packages/web-shared/src/stream-viewer.tsx index 9235ca588..44dea06e4 100644 --- a/packages/web-shared/src/stream-viewer.tsx +++ b/packages/web-shared/src/stream-viewer.tsx @@ -52,16 +52,17 @@ export function StreamViewer({ env, streamId }: StreamViewerProps) { } }; - const addChunk = (text: string) => { - if (mounted && text) { + 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, - decoder: TextDecoder + reader: ReadableStreamDefaultReader ) => { for (;;) { if (abortControllerRef.current?.signal.aborted) { @@ -75,11 +76,7 @@ export function StreamViewer({ env, streamId }: StreamViewerProps) { break; } - // Skip empty chunks - if (value && value.byteLength > 0) { - const text = decoder.decode(value, { stream: true }); - addChunk(text); - } + addChunk(value); } }; @@ -87,8 +84,7 @@ export function StreamViewer({ env, streamId }: StreamViewerProps) { try { const stream = await readStream(env, streamId); const reader = stream.getReader(); - const decoder = new TextDecoder(); - await processStreamChunks(reader, decoder); + await processStreamChunks(reader); } catch (err) { handleStreamError(err); } @@ -134,26 +130,52 @@ export function StreamViewer({ env, streamId }: StreamViewerProps) {
{error ? ( -
+
Error reading stream:
{error}
) : chunks.length === 0 ? ( -
+
{isLive ? 'Waiting for stream data...' : 'Stream is empty'}
) : ( - chunks.map((chunk) => ( - {chunk.text} + chunks.map((chunk, index) => ( +
+              
+                
+                  [{index}]
+                
+                {chunk.text}
+              
+            
)) )}
From 11cf85e8d60fac837314891a3663775f96d06ac2 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 15:26:31 -0800 Subject: [PATCH 14/31] Auto-refresh stream list --- .../web-shared/src/api/workflow-api-client.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index 39cace1d6..0c5fb409e 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -1159,10 +1159,16 @@ export async function listStreams( return result; } +const STREAMS_REFRESH_INTERVAL_MS = 10000; + /** * Hook to fetch and manage stream list for a run */ -export function useWorkflowStreams(env: EnvMap, runId: string) { +export function useWorkflowStreams( + env: EnvMap, + runId: string, + refreshInterval: number = STREAMS_REFRESH_INTERVAL_MS +) { const [streams, setStreams] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -1180,10 +1186,21 @@ export function useWorkflowStreams(env: EnvMap, runId: string) { } }, [env, runId]); + // Initial load useEffect(() => { fetchData(); }, [fetchData]); + // Auto-refresh interval + useEffect(() => { + if (!refreshInterval || refreshInterval <= 0) { + return; + } + + const interval = setInterval(fetchData, refreshInterval); + return () => clearInterval(interval); + }, [refreshInterval, fetchData]); + return { streams, loading, From be4aff3a68e38754c48a1233a145a2c4c72d04fa Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 15:37:56 -0800 Subject: [PATCH 15/31] Rename replay run --- .../components/display-utils/cancel-button.tsx | 14 ++++++++++---- .../components/display-utils/rerun-button.tsx | 16 +++++++++++----- packages/web/src/components/hooks-table.tsx | 2 +- packages/web/src/components/run-detail-view.tsx | 8 ++++---- packages/web/src/components/runs-table.tsx | 2 +- 5 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/web/src/components/display-utils/cancel-button.tsx b/packages/web/src/components/display-utils/cancel-button.tsx index 7fff29cec..349b6bbe5 100644 --- a/packages/web/src/components/display-utils/cancel-button.tsx +++ b/packages/web/src/components/display-utils/cancel-button.tsx @@ -34,11 +34,17 @@ export function CancelButton({ - {cancelDisabledReason && ( - + + {cancelDisabledReason ? (

{cancelDisabledReason}

-
- )} + ) : ( +

+ This will set the run state to "cancelled", prevent further steps + from being scheduled, and disable active hooks. Active steps will + continue to run until they complete. +

+ )} +
); } diff --git a/packages/web/src/components/display-utils/rerun-button.tsx b/packages/web/src/components/display-utils/rerun-button.tsx index 361bc1ee4..08b2d7e81 100644 --- a/packages/web/src/components/display-utils/rerun-button.tsx +++ b/packages/web/src/components/display-utils/rerun-button.tsx @@ -30,15 +30,21 @@ export function RerunButton({ disabled={!canRerun || rerunning} > - {rerunning ? 'Re-running...' : 'Re-run'} + {rerunning ? 'Replaying...' : 'Replay'} - {rerunDisabledReason && ( - + + {rerunDisabledReason ? (

{rerunDisabledReason}

-
- )} + ) : ( +

+ This will start a new copy of the current run using the same + deployment, environment, and inputs. It will not affect the current + run. +

+ )} +
); } diff --git a/packages/web/src/components/hooks-table.tsx b/packages/web/src/components/hooks-table.tsx index 6c9761fb4..5548b4c7f 100644 --- a/packages/web/src/components/hooks-table.tsx +++ b/packages/web/src/components/hooks-table.tsx @@ -365,7 +365,7 @@ export function HooksTable({ }} > - Re-run + Replay Run { diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index f8261aea7..5231e5fb6 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -284,20 +284,20 @@ export function RunDetailView({ - {/* Re-run Confirmation Dialog */} + {/* Replay Run Confirmation Dialog */} - Re-run Workflow? + Replay Run? This can potentially re-run code that is meant to only execute - once. Are you sure you want to re-run the workflow? + once. Are you sure you want to replay the workflow run? Cancel - Re-run Workflow + Replay Run diff --git a/packages/web/src/components/runs-table.tsx b/packages/web/src/components/runs-table.tsx index 64e994b69..34796a74f 100644 --- a/packages/web/src/components/runs-table.tsx +++ b/packages/web/src/components/runs-table.tsx @@ -468,7 +468,7 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { }} > - Re-run + Replay Run { From dd69152c49edd0890d83fbd34b34edaf08d68787 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 15:42:06 -0800 Subject: [PATCH 16/31] Add wake-up run button --- .../web-shared/src/api/workflow-api-client.ts | 13 +++++ .../src/api/workflow-server-actions.ts | 31 ++++++++++++ packages/web-shared/src/index.ts | 1 + .../display-utils/wakeup-button.tsx | 50 +++++++++++++++++++ packages/web/src/components/hooks-table.tsx | 2 +- .../web/src/components/run-detail-view.tsx | 40 +++++++++++++++ packages/web/src/components/runs-table.tsx | 35 +++++++++++++ 7 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/components/display-utils/wakeup-button.tsx diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index 0c5fb409e..f308b1696 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -23,6 +23,7 @@ import { fetchStreams, readStreamServerAction, recreateRun as recreateRunServerAction, + wakeUpRun as wakeUpRunServerAction, } from './workflow-server-actions'; const MAX_ITEMS = 1000; @@ -1101,6 +1102,18 @@ export async function recreateRun(env: EnvMap, runId: string): Promise { return resultData; } +/** + * Wake up a workflow run by re-enqueuing it + */ +export async function wakeUpRun(env: EnvMap, runId: string): Promise { + const { error } = await unwrapServerActionResult( + wakeUpRunServerAction(env, runId) + ); + if (error) { + throw error; + } +} + function isServerActionError(value: unknown): value is ServerActionError { return ( typeof value === 'object' && diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index fe5d64aaf..a9b1a29ae 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -507,6 +507,37 @@ export async function recreateRun( } } +/** + * Wake up a workflow run by re-enqueuing it. + * + * This re-enqueues the workflow orchestration layer. It's a no-op unless the workflow + * got stuck due to an implementation issue in the World. Useful for debugging custom Worlds. + */ +export async function wakeUpRun( + worldEnv: EnvMap, + runId: string +): Promise> { + try { + const world = getWorldFromEnv({ ...worldEnv }); + const run = await world.runs.get(runId); + const deploymentId = run.deploymentId; + + await world.queue( + `__wkf_workflow_${run.workflowName}`, + { + runId, + }, + { + deploymentId, + } + ); + + return createResponse(undefined); + } catch (error) { + return createServerActionError(error, 'wakeUpRun', { runId }); + } +} + export async function readStreamServerAction( env: EnvMap, streamId: string, diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index c2c639e5b..2bce6ce61 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -11,6 +11,7 @@ export { fetchStreams, fetchWorkflowsManifest, } from './api/workflow-server-actions'; +export { wakeUpRun } from './api/workflow-api-client'; export { formatDuration } from './lib/utils'; export { RunTraceView } from './run-trace-view'; export { StreamViewer } from './stream-viewer'; diff --git a/packages/web/src/components/display-utils/wakeup-button.tsx b/packages/web/src/components/display-utils/wakeup-button.tsx new file mode 100644 index 000000000..c7a562ff5 --- /dev/null +++ b/packages/web/src/components/display-utils/wakeup-button.tsx @@ -0,0 +1,50 @@ +import { Zap } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Button } from '../ui/button'; + +interface WakeUpButtonProps { + canWakeUp: boolean; + wakingUp: boolean; + wakeUpDisabledReason: string | null; + onWakeUp: () => void; +} + +export function WakeUpButton({ + canWakeUp, + wakingUp, + wakeUpDisabledReason, + onWakeUp, +}: WakeUpButtonProps) { + return ( + + + + + + + + {wakeUpDisabledReason ? ( +

{wakeUpDisabledReason}

+ ) : ( +

+ Re-enqueue the workflow orchestration layer. This is a no-op, unless + the workflow got stuck due to an implementation issue in the World. + This is useful for debugging custom Worlds. +

+ )} +
+
+ ); +} diff --git a/packages/web/src/components/hooks-table.tsx b/packages/web/src/components/hooks-table.tsx index 5548b4c7f..0dfaeb37c 100644 --- a/packages/web/src/components/hooks-table.tsx +++ b/packages/web/src/components/hooks-table.tsx @@ -212,7 +212,7 @@ export function HooksTable({ {displayText} - +
Showing first 100 invocations. There may be more.
diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index 5231e5fb6..712911ed7 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -9,6 +9,7 @@ import { useWorkflowTraceViewerData, type WorkflowRun, WorkflowTraceViewer, + wakeUpRun, } from '@workflow/web-shared'; import { AlertCircle, HelpCircle, List, Loader2 } from 'lucide-react'; import Link from 'next/link'; @@ -48,6 +49,7 @@ import { LiveStatus } from './display-utils/live-status'; import { RelativeTime } from './display-utils/relative-time'; import { RerunButton } from './display-utils/rerun-button'; import { StatusBadge } from './display-utils/status-badge'; +import { WakeUpButton } from './display-utils/wakeup-button'; import { Skeleton } from './ui/skeleton'; interface RunDetailViewProps { @@ -66,6 +68,7 @@ export function RunDetailView({ const searchParams = useSearchParams(); const [cancelling, setCancelling] = useState(false); const [rerunning, setRerunning] = useState(false); + const [wakingUp, setWakingUp] = useState(false); const [showCancelDialog, setShowCancelDialog] = useState(false); const [showRerunDialog, setShowRerunDialog] = useState(false); const env = useMemo(() => worldConfigToEnvMap(config), [config]); @@ -195,6 +198,28 @@ export function RunDetailView({ setShowRerunDialog(true); }; + const handleWakeUpClick = async () => { + if (wakingUp) return; + + try { + setWakingUp(true); + await wakeUpRun(env, runId); + toast.success('Run woken up', { + description: 'The workflow orchestration layer has been re-enqueued.', + }); + // Trigger a refresh of the data + await update(); + } catch (err) { + console.error('Failed to wake up run:', err); + toast.error('Failed to wake up run', { + description: + err instanceof Error ? err.message : 'An unknown error occurred', + }); + } finally { + setWakingUp(false); + } + }; + const handleConfirmRerun = async () => { if (rerunning) return; @@ -259,6 +284,15 @@ export function RunDetailView({ }; const rerunDisabledReason = getRerunDisabledReason(); + // Determine if wake up is allowed and why + const canWakeUp = !loading && !wakingUp; + const getWakeUpDisabledReason = () => { + if (wakingUp) return 'Waking up workflow...'; + if (loading) return 'Loading run data...'; + return ''; + }; + const wakeUpDisabledReason = getWakeUpDisabledReason(); + return ( <> {/* Cancel Confirmation Dialog */} @@ -344,6 +378,12 @@ export function RunDetailView({ rerunDisabledReason={rerunDisabledReason} onRerun={handleRerunClick} /> + Replay Run
+ + + { + e.stopPropagation(); + try { + await wakeUpRun(env, run.runId); + toast.success('Run woken up', { + description: + 'The workflow orchestration layer has been re-enqueued.', + }); + reload(); + } catch (err) { + toast.error('Failed to wake up', { + description: + err instanceof Error + ? err.message + : 'Unknown error', + }); + } + }} + > + + Wake up + + + + Re-enqueue the workflow orchestration layer. + This is a no-op, unless the workflow got stuck + due to an implementation issue in the World. + This is useful for debugging custom Worlds. + + { e.stopPropagation(); From bbdc8c9f7f3327180e155211eaff9c504628d596 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 15:49:21 -0800 Subject: [PATCH 17/31] Wake up from sleep --- .../web-shared/src/api/workflow-api-client.ts | 18 +++++ .../src/api/workflow-server-actions.ts | 72 +++++++++++++++++++ packages/web-shared/src/index.ts | 2 +- .../display-utils/stop-sleep-button.tsx | 49 +++++++++++++ .../web/src/components/run-detail-view.tsx | 69 ++++++++++++++++++ packages/web/src/components/runs-table.tsx | 42 +++++++++++ 6 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/components/display-utils/stop-sleep-button.tsx diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index f308b1696..23e15dd72 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -23,6 +23,8 @@ import { fetchStreams, readStreamServerAction, recreateRun as recreateRunServerAction, + stopSleepRun as stopSleepRunServerAction, + type StopSleepResult, wakeUpRun as wakeUpRunServerAction, } from './workflow-server-actions'; @@ -1114,6 +1116,22 @@ export async function wakeUpRun(env: EnvMap, runId: string): Promise { } } +/** + * Stop any pending sleep() calls for a workflow run + */ +export async function stopSleepRun( + env: EnvMap, + runId: string +): Promise { + const { error, result: resultData } = await unwrapServerActionResult( + stopSleepRunServerAction(env, runId) + ); + if (error) { + throw error; + } + return resultData; +} + function isServerActionError(value: unknown): value is ServerActionError { return ( typeof value === 'object' && diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index a9b1a29ae..51b6ebc60 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -538,6 +538,78 @@ export async function wakeUpRun( } } +export interface StopSleepResult { + /** Number of pending sleeps that were stopped */ + stoppedCount: number; +} + +/** + * Stop any pending sleep() calls for a workflow run. + * + * This finds all wait_created events without matching wait_completed events, + * creates wait_completed events for them, and then re-enqueues the run. + */ +export async function stopSleepRun( + worldEnv: EnvMap, + runId: string +): Promise> { + try { + const world = getWorldFromEnv({ ...worldEnv }); + const run = await world.runs.get(runId); + const deploymentId = run.deploymentId; + + // 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) + ); + + const pendingWaits = waitCreatedEvents.filter( + (e) => !waitCompletedCorrelationIds.has(e.correlationId) + ); + + // Create wait_completed events for each pending wait + for (const waitEvent of pendingWaits) { + if (waitEvent.correlationId) { + await world.events.create(runId, { + eventType: 'wait_completed', + correlationId: waitEvent.correlationId, + }); + } + } + + // 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 }); + } catch (error) { + return createServerActionError(error, 'stopSleepRun', { + runId, + }); + } +} + export async function readStreamServerAction( env: EnvMap, streamId: string, diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 2bce6ce61..6cd9a21a9 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -11,7 +11,7 @@ export { fetchStreams, fetchWorkflowsManifest, } from './api/workflow-server-actions'; -export { wakeUpRun } from './api/workflow-api-client'; +export { stopSleepRun, wakeUpRun } from './api/workflow-api-client'; export { formatDuration } from './lib/utils'; export { RunTraceView } from './run-trace-view'; export { StreamViewer } from './stream-viewer'; diff --git a/packages/web/src/components/display-utils/stop-sleep-button.tsx b/packages/web/src/components/display-utils/stop-sleep-button.tsx new file mode 100644 index 000000000..279774afe --- /dev/null +++ b/packages/web/src/components/display-utils/stop-sleep-button.tsx @@ -0,0 +1,49 @@ +import { AlarmClockOff } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Button } from '../ui/button'; + +interface StopSleepButtonProps { + canStopSleep: boolean; + stoppingSleep: boolean; + stopSleepDisabledReason: string | null; + onStopSleep: () => void; +} + +export function StopSleepButton({ + canStopSleep, + stoppingSleep, + stopSleepDisabledReason, + onStopSleep, +}: StopSleepButtonProps) { + return ( + + + + + + + + {stopSleepDisabledReason ? ( +

{stopSleepDisabledReason}

+ ) : ( +

+ Interrupt any current calls to sleep() and wake up the + run. +

+ )} +
+
+ ); +} diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index 712911ed7..e7899516e 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -5,6 +5,7 @@ import { cancelRun, recreateRun, StreamViewer, + stopSleepRun, useWorkflowStreams, useWorkflowTraceViewerData, type WorkflowRun, @@ -49,6 +50,7 @@ import { LiveStatus } from './display-utils/live-status'; import { RelativeTime } from './display-utils/relative-time'; import { RerunButton } from './display-utils/rerun-button'; import { StatusBadge } from './display-utils/status-badge'; +import { StopSleepButton } from './display-utils/stop-sleep-button'; import { WakeUpButton } from './display-utils/wakeup-button'; import { Skeleton } from './ui/skeleton'; @@ -69,6 +71,7 @@ export function RunDetailView({ const [cancelling, setCancelling] = useState(false); const [rerunning, setRerunning] = useState(false); const [wakingUp, setWakingUp] = useState(false); + const [stoppingSleep, setStoppingSleep] = useState(false); const [showCancelDialog, setShowCancelDialog] = useState(false); const [showRerunDialog, setShowRerunDialog] = useState(false); const env = useMemo(() => worldConfigToEnvMap(config), [config]); @@ -147,6 +150,28 @@ export function RunDetailView({ error: streamsError, } = useWorkflowStreams(env, runId); + // Check for pending sleeps (wait_created without matching wait_completed) + const hasPendingSleeps = useMemo(() => { + if (!allEvents || allEvents.length === 0) return false; + const waitCreatedCorrelationIds = new Set( + allEvents + .filter((e) => e.eventType === 'wait_created') + .map((e) => e.correlationId) + ); + const waitCompletedCorrelationIds = new Set( + allEvents + .filter((e) => e.eventType === 'wait_completed') + .map((e) => e.correlationId) + ); + // Check if there's any wait_created without a matching wait_completed + for (const correlationId of waitCreatedCorrelationIds) { + if (!waitCompletedCorrelationIds.has(correlationId)) { + return true; + } + } + return false; + }, [allEvents]); + // Find the workflow graph for this run // The manifest is keyed by workflowId which matches run.workflowName // e.g., "workflow//example/workflows/1_simple.ts//simple" @@ -220,6 +245,34 @@ export function RunDetailView({ } }; + const handleStopSleepClick = async () => { + if (stoppingSleep) return; + + try { + setStoppingSleep(true); + const result = await stopSleepRun(env, runId); + if (result.stoppedCount > 0) { + toast.success('Sleep interrupted', { + description: `Stopped ${result.stoppedCount} pending sleep${result.stoppedCount > 1 ? 's' : ''} and woke up the run.`, + }); + } else { + toast.info('No pending sleeps', { + description: 'There were no pending sleep() calls to interrupt.', + }); + } + // Trigger a refresh of the data + await update(); + } catch (err) { + console.error('Failed to stop sleep:', err); + toast.error('Failed to stop sleep', { + description: + err instanceof Error ? err.message : 'An unknown error occurred', + }); + } finally { + setStoppingSleep(false); + } + }; + const handleConfirmRerun = async () => { if (rerunning) return; @@ -293,6 +346,16 @@ export function RunDetailView({ }; const wakeUpDisabledReason = getWakeUpDisabledReason(); + // Determine if stop sleep is allowed and why + const canStopSleep = !loading && !stoppingSleep && hasPendingSleeps; + const getStopSleepDisabledReason = () => { + if (stoppingSleep) return 'Stopping sleep...'; + if (loading) return 'Loading run data...'; + if (!hasPendingSleeps) return 'No pending sleep() calls to interrupt'; + return ''; + }; + const stopSleepDisabledReason = getStopSleepDisabledReason(); + return ( <> {/* Cancel Confirmation Dialog */} @@ -384,6 +447,12 @@ export function RunDetailView({ wakeUpDisabledReason={wakeUpDisabledReason} onWakeUp={handleWakeUpClick} /> + + + + { + e.stopPropagation(); + try { + const result = await stopSleepRun( + env, + run.runId + ); + if (result.stoppedCount > 0) { + toast.success('Sleep interrupted', { + description: `Stopped ${result.stoppedCount} pending sleep${result.stoppedCount > 1 ? 's' : ''} and woke up the run.`, + }); + } else { + toast.info('No pending sleeps', { + description: + 'There were no pending sleep() calls to interrupt.', + }); + } + reload(); + } catch (err) { + toast.error('Failed to stop sleep', { + description: + err instanceof Error + ? err.message + : 'Unknown error', + }); + } + }} + > + + Stop sleep + + + + Interrupt any current calls to{' '} + sleep() and wake up the run. + + { e.stopPropagation(); From 3a866306ade8244f6c91f9835f5d1d5b35b43e79 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 15:50:05 -0800 Subject: [PATCH 18/31] Changeset --- .changeset/fine-moles-sit.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fine-moles-sit.md diff --git a/.changeset/fine-moles-sit.md b/.changeset/fine-moles-sit.md new file mode 100644 index 000000000..fcefaf63f --- /dev/null +++ b/.changeset/fine-moles-sit.md @@ -0,0 +1,6 @@ +--- +"@workflow/web-shared": patch +"@workflow/web": patch +--- + +Add buttons to wake up workflow from sleep or scheduling issues From 54d0ba1dd37db33c3c7ad9b610ad00d4962f6447 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 15:54:26 -0800 Subject: [PATCH 19/31] rename --- packages/cli/src/commands/inspect.ts | 4 ++-- packages/cli/src/lib/inspect/output.ts | 2 +- packages/world-local/src/streamer.test.ts | 12 ++++++------ packages/world-local/src/streamer.ts | 2 +- packages/world-postgres/src/streamer.ts | 2 +- packages/world-vercel/src/backend.ts | 2 +- packages/world-vercel/src/streamer.ts | 2 +- packages/world/src/interfaces.ts | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/inspect.ts b/packages/cli/src/commands/inspect.ts index 6bd1eae15..85aa3dd95 100644 --- a/packages/cli/src/commands/inspect.ts +++ b/packages/cli/src/commands/inspect.ts @@ -4,12 +4,12 @@ import { LOGGING_CONFIG, logger } from '../lib/config/log.js'; import type { InspectCLIOptions } from '../lib/config/types.js'; import { cliFlags } from '../lib/inspect/flags.js'; import { - listByRunId, listEvents, listHooks, listRuns, listSleeps, listSteps, + listStreamsByRunId, showHook, showRun, showStep, @@ -175,7 +175,7 @@ export default class Inspect extends BaseCommand { if (id) { await showStream(world, id, options); } else { - await listByRunId(world, options); + await listStreamsByRunId(world, options); } process.exit(0); } diff --git a/packages/cli/src/lib/inspect/output.ts b/packages/cli/src/lib/inspect/output.ts index f4d63c27c..7866bb952 100644 --- a/packages/cli/src/lib/inspect/output.ts +++ b/packages/cli/src/lib/inspect/output.ts @@ -782,7 +782,7 @@ export const showStream = async ( * Listing streams only lists available stream IDs based on run/step passed, * and doesn't read any data from the streams. */ -export const listByRunId = async ( +export const listStreamsByRunId = async ( world: World, opts: InspectCLIOptions = {} ) => { diff --git a/packages/world-local/src/streamer.test.ts b/packages/world-local/src/streamer.test.ts index ea59363e9..a8deb0f23 100644 --- a/packages/world-local/src/streamer.test.ts +++ b/packages/world-local/src/streamer.test.ts @@ -510,11 +510,11 @@ describe('streamer', () => { }); }); - describe('listByRunId', () => { + describe('listStreamsByRunId', () => { it('should return empty array when no streams exist', async () => { const { streamer } = await setupStreamer(); - const streams = await streamer.listByRunId(TEST_RUN_ID); + const streams = await streamer.listStreamsByRunId(TEST_RUN_ID); expect(streams).toEqual([]); }); @@ -532,7 +532,7 @@ describe('streamer', () => { await streamer.closeStream(streamName1, TEST_RUN_ID); await streamer.closeStream(streamName2, TEST_RUN_ID); - const streams = await streamer.listByRunId(TEST_RUN_ID); + const streams = await streamer.listStreamsByRunId(TEST_RUN_ID); expect(streams).toHaveLength(2); expect(streams).toContain(streamName1); @@ -556,7 +556,7 @@ describe('streamer', () => { ); await streamer.writeToStream(otherStream, otherRunId, 'other output'); - const streams = await streamer.listByRunId(TEST_RUN_ID); + const streams = await streamer.listStreamsByRunId(TEST_RUN_ID); expect(streams).toHaveLength(1); expect(streams).toContain(targetStream); @@ -577,7 +577,7 @@ describe('streamer', () => { await streamer.writeToStream(streamName, TEST_RUN_ID, 'chunk3'); await streamer.closeStream(streamName, TEST_RUN_ID); - const streams = await streamer.listByRunId(TEST_RUN_ID); + const streams = await streamer.listStreamsByRunId(TEST_RUN_ID); // Should only return the stream name once, not once per chunk expect(streams).toHaveLength(1); @@ -593,7 +593,7 @@ describe('streamer', () => { await streamer.writeToStream(streamName, TEST_RUN_ID, 'data'); await streamer.closeStream(streamName, TEST_RUN_ID); - const streams = await streamer.listByRunId(TEST_RUN_ID); + const streams = await streamer.listStreamsByRunId(TEST_RUN_ID); expect(streams).toHaveLength(1); expect(streams).toContain(streamName); diff --git a/packages/world-local/src/streamer.ts b/packages/world-local/src/streamer.ts index 96c9b2f0f..162a9524c 100644 --- a/packages/world-local/src/streamer.ts +++ b/packages/world-local/src/streamer.ts @@ -113,7 +113,7 @@ export function createStreamer(basedir: string): Streamer { streamEmitter.emit(`close:${name}` as const, { streamName: name }); }, - async listByRunId(runId: string) { + async listStreamsByRunId(runId: string) { const chunksDir = path.join(basedir, 'streams', 'chunks'); const files = await listJSONFiles(chunksDir); diff --git a/packages/world-postgres/src/streamer.ts b/packages/world-postgres/src/streamer.ts index 4f00cd2e1..4a0c30e73 100644 --- a/packages/world-postgres/src/streamer.ts +++ b/packages/world-postgres/src/streamer.ts @@ -208,7 +208,7 @@ export function createStreamer(postgres: Sql, drizzle: Drizzle): Streamer { }); }, - async listByRunId(runId: string): Promise { + async listStreamsByRunId(runId: string): Promise { // Convert runId (wrun_{ULID}) to stream prefix (strm_{ULID}_user) const streamPrefix = runId.replace('wrun_', 'strm_') + '_user'; diff --git a/packages/world-vercel/src/backend.ts b/packages/world-vercel/src/backend.ts index 8fb46a8b3..be638aeeb 100644 --- a/packages/world-vercel/src/backend.ts +++ b/packages/world-vercel/src/backend.ts @@ -12,7 +12,7 @@ export function createVercel(config?: APIConfig): Streamer & Storage { writeToStream: streamer.writeToStream, closeStream: streamer.closeStream, readFromStream: streamer.readFromStream, - listByRunId: streamer.listByRunId, + listStreamsByRunId: streamer.listStreamsByRunId, // Storage interface with namespaced methods runs: storage.runs, diff --git a/packages/world-vercel/src/streamer.ts b/packages/world-vercel/src/streamer.ts index b73f3f50f..24e1b00ac 100644 --- a/packages/world-vercel/src/streamer.ts +++ b/packages/world-vercel/src/streamer.ts @@ -56,7 +56,7 @@ export function createStreamer(config?: APIConfig): Streamer { return res.body as ReadableStream; }, - async listByRunId(runId: string) { + async listStreamsByRunId(runId: string) { const httpConfig = await getHttpConfig(config); const url = new URL(`${httpConfig.baseUrl}/v1/runs/${runId}/streams`); const res = await fetch(url, { headers: httpConfig.headers }); diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index 6e8dae863..0c22703f9 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -42,7 +42,7 @@ export interface Streamer { name: string, startIndex?: number ): Promise>; - listByRunId(runId: string): Promise; + listStreamsByRunId(runId: string): Promise; } export interface Storage { From df92c03adff1322f248fe417e677e148255df2c7 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 15:58:00 -0800 Subject: [PATCH 20/31] One or the other --- .../web/src/components/run-detail-view.tsx | 27 ++++++------ packages/web/src/components/runs-table.tsx | 42 ------------------- 2 files changed, 15 insertions(+), 54 deletions(-) diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index e7899516e..fc81aba52 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -441,18 +441,21 @@ export function RunDetailView({ rerunDisabledReason={rerunDisabledReason} onRerun={handleRerunClick} /> - - + {hasPendingSleeps ? ( + + ) : ( + + )} - - - { - e.stopPropagation(); - try { - const result = await stopSleepRun( - env, - run.runId - ); - if (result.stoppedCount > 0) { - toast.success('Sleep interrupted', { - description: `Stopped ${result.stoppedCount} pending sleep${result.stoppedCount > 1 ? 's' : ''} and woke up the run.`, - }); - } else { - toast.info('No pending sleeps', { - description: - 'There were no pending sleep() calls to interrupt.', - }); - } - reload(); - } catch (err) { - toast.error('Failed to stop sleep', { - description: - err instanceof Error - ? err.message - : 'Unknown error', - }); - } - }} - > - - Stop sleep - - - - Interrupt any current calls to{' '} - sleep() and wake up the run. - - { e.stopPropagation(); From 61a76adb5ca4e6476f9dc0c9f877592207825d1a Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 16:03:22 -0800 Subject: [PATCH 21/31] Remove unused import --- packages/cli/src/lib/inspect/output.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/lib/inspect/output.ts b/packages/cli/src/lib/inspect/output.ts index fdbd7b1ba..4c3ae98e1 100644 --- a/packages/cli/src/lib/inspect/output.ts +++ b/packages/cli/src/lib/inspect/output.ts @@ -1,6 +1,5 @@ import { hydrateResourceIO } from '@workflow/core/observability'; import { parseStepName, parseWorkflowName } from '@workflow/core/parse-name'; -import { getRun } from '@workflow/core/runtime'; import { getDeserializeStream, getExternalRevivers, From 804755d394e2db541076099016438df5985858e3 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 16:08:13 -0800 Subject: [PATCH 22/31] Unify actions --- packages/web-shared/src/index.ts | 3 +- packages/web/src/components/run-actions.tsx | 568 ++++++++++++++++++ .../web/src/components/run-detail-view.tsx | 158 +---- packages/web/src/components/runs-table.tsx | 160 ++--- 4 files changed, 645 insertions(+), 244 deletions(-) create mode 100644 packages/web/src/components/run-actions.tsx diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 6cd9a21a9..89bbf5a31 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -5,13 +5,14 @@ export { export type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; export * from './api/workflow-api-client'; +export { stopSleepRun, wakeUpRun } from './api/workflow-api-client'; export type { EnvMap } from './api/workflow-server-actions'; export { + fetchEvents, fetchEventsByCorrelationId, fetchStreams, fetchWorkflowsManifest, } from './api/workflow-server-actions'; -export { stopSleepRun, wakeUpRun } from './api/workflow-api-client'; export { formatDuration } from './lib/utils'; export { RunTraceView } from './run-trace-view'; export { StreamViewer } from './stream-viewer'; diff --git a/packages/web/src/components/run-actions.tsx b/packages/web/src/components/run-actions.tsx new file mode 100644 index 000000000..8921d676d --- /dev/null +++ b/packages/web/src/components/run-actions.tsx @@ -0,0 +1,568 @@ +'use client'; + +import { + cancelRun, + type EnvMap, + type Event, + recreateRun, + stopSleepRun, + wakeUpRun, +} from '@workflow/web-shared'; +import type { WorkflowRunStatus } from '@workflow/world'; +import { AlarmClockOff, Loader2, RotateCw, XCircle, Zap } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Button } from './ui/button'; + +/** + * Compute whether there are pending sleeps from an events list + */ +export function hasPendingSleepsFromEvents( + events: Event[] | undefined +): boolean { + if (!events || events.length === 0) return false; + const waitCreatedCorrelationIds = new Set( + events + .filter((e) => e.eventType === 'wait_created') + .map((e) => e.correlationId) + ); + const waitCompletedCorrelationIds = new Set( + events + .filter((e) => e.eventType === 'wait_completed') + .map((e) => e.correlationId) + ); + for (const correlationId of waitCreatedCorrelationIds) { + if (!waitCompletedCorrelationIds.has(correlationId)) { + return true; + } + } + return false; +} + +// ============================================================================ +// Shared Props and Types +// ============================================================================ + +export interface RunActionCallbacks { + onSuccess?: () => void; + onNavigateToRun?: (runId: string) => void; +} + +export interface RunActionsBaseProps { + env: EnvMap; + runId: string; + runStatus: WorkflowRunStatus | undefined; + events?: Event[]; + eventsLoading?: boolean; + callbacks?: RunActionCallbacks; +} + +// ============================================================================ +// Dropdown Menu Items (for runs-table) +// ============================================================================ + +export interface RunActionsDropdownItemsProps extends RunActionsBaseProps { + /** Stop click event propagation (useful in table rows) */ + stopPropagation?: boolean; +} + +export function RunActionsDropdownItems({ + env, + runId, + runStatus, + events, + eventsLoading, + callbacks, + stopPropagation = false, +}: RunActionsDropdownItemsProps) { + const [rerunning, setRerunning] = useState(false); + const [wakingUp, setWakingUp] = useState(false); + const [stoppingSleep, setStoppingSleep] = useState(false); + const [cancelling, setCancelling] = useState(false); + + const hasPendingSleeps = useMemo( + () => hasPendingSleepsFromEvents(events), + [events] + ); + + const handleReplay = async (e: React.MouseEvent) => { + if (stopPropagation) e.stopPropagation(); + if (rerunning) return; + + try { + setRerunning(true); + const newRunId = await recreateRun(env, runId); + toast.success('New run started', { + description: `Run ID: ${newRunId}`, + }); + callbacks?.onSuccess?.(); + callbacks?.onNavigateToRun?.(newRunId); + } catch (err) { + toast.error('Failed to re-run', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } finally { + setRerunning(false); + } + }; + + const handleWakeUp = async (e: React.MouseEvent) => { + if (stopPropagation) e.stopPropagation(); + if (wakingUp) return; + + try { + setWakingUp(true); + await wakeUpRun(env, runId); + toast.success('Run woken up', { + description: 'The workflow orchestration layer has been re-enqueued.', + }); + callbacks?.onSuccess?.(); + } catch (err) { + toast.error('Failed to wake up', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } finally { + setWakingUp(false); + } + }; + + const handleStopSleep = async (e: React.MouseEvent) => { + if (stopPropagation) e.stopPropagation(); + if (stoppingSleep) return; + + try { + setStoppingSleep(true); + const result = await stopSleepRun(env, runId); + if (result.stoppedCount > 0) { + toast.success('Sleep interrupted', { + description: `Stopped ${result.stoppedCount} pending sleep${result.stoppedCount > 1 ? 's' : ''} and woke up the run.`, + }); + } else { + toast.info('No pending sleeps', { + description: 'There were no pending sleep calls to interrupt.', + }); + } + callbacks?.onSuccess?.(); + } catch (err) { + toast.error('Failed to stop sleep', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } finally { + setStoppingSleep(false); + } + }; + + const handleCancel = async (e: React.MouseEvent) => { + if (stopPropagation) e.stopPropagation(); + if (cancelling) return; + + if (runStatus !== 'pending') { + toast.error('Cannot cancel', { + description: 'Only pending runs can be cancelled', + }); + return; + } + + try { + setCancelling(true); + await cancelRun(env, runId); + toast.success('Run cancelled'); + callbacks?.onSuccess?.(); + } catch (err) { + toast.error('Failed to cancel', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } finally { + setCancelling(false); + } + }; + + return ( + <> + + + {rerunning ? 'Replaying...' : 'Replay Run'} + + + {eventsLoading ? ( + + + Loading... + + ) : hasPendingSleeps ? ( + + + + + {stoppingSleep ? 'Stopping...' : 'Stop sleep'} + + + + Interrupt any current calls to sleep() and wake up the + run. + + + ) : ( + + + + + {wakingUp ? 'Waking up...' : 'Wake up'} + + + + Re-enqueue the workflow orchestration layer. This is a no-op, unless + the workflow got stuck due to an implementation issue in the World. + This is useful for debugging custom Worlds. + + + )} + + + + {cancelling ? 'Cancelling...' : 'Cancel'} + + + ); +} + +// ============================================================================ +// Buttons (for run-detail-view) +// ============================================================================ + +export interface RunActionsButtonsProps extends RunActionsBaseProps { + loading?: boolean; + /** Called when cancel button is clicked - typically shows a confirmation dialog */ + onCancelClick?: () => void; + /** Called when rerun button is clicked - typically shows a confirmation dialog */ + onRerunClick?: () => void; +} + +export function RunActionsButtons({ + env, + runId, + runStatus, + events, + eventsLoading, + loading, + callbacks, + onCancelClick, + onRerunClick, +}: RunActionsButtonsProps) { + const [wakingUp, setWakingUp] = useState(false); + const [stoppingSleep, setStoppingSleep] = useState(false); + + const hasPendingSleeps = useMemo( + () => hasPendingSleepsFromEvents(events), + [events] + ); + + const isRunActive = runStatus === 'pending' || runStatus === 'running'; + const canCancel = isRunActive; + + const handleWakeUp = async () => { + if (wakingUp) return; + + try { + setWakingUp(true); + await wakeUpRun(env, runId); + toast.success('Run woken up', { + description: 'The workflow orchestration layer has been re-enqueued.', + }); + callbacks?.onSuccess?.(); + } catch (err) { + console.error('Failed to wake up run:', err); + toast.error('Failed to wake up run', { + description: + err instanceof Error ? err.message : 'An unknown error occurred', + }); + } finally { + setWakingUp(false); + } + }; + + const handleStopSleep = async () => { + if (stoppingSleep) return; + + try { + setStoppingSleep(true); + const result = await stopSleepRun(env, runId); + if (result.stoppedCount > 0) { + toast.success('Sleep interrupted', { + description: `Stopped ${result.stoppedCount} pending sleep${result.stoppedCount > 1 ? 's' : ''} and woke up the run.`, + }); + } else { + toast.info('No pending sleeps', { + description: 'There were no pending sleep() calls to interrupt.', + }); + } + callbacks?.onSuccess?.(); + } catch (err) { + console.error('Failed to stop sleep:', err); + toast.error('Failed to stop sleep', { + description: + err instanceof Error ? err.message : 'An unknown error occurred', + }); + } finally { + setStoppingSleep(false); + } + }; + + // Rerun button logic + const canRerun = !loading && !isRunActive; + const rerunDisabledReason = loading + ? 'Loading run data...' + : isRunActive + ? 'Cannot re-run while workflow is still running' + : ''; + + // Wake up button logic + const canWakeUp = !loading && !wakingUp; + const wakeUpDisabledReason = wakingUp + ? 'Waking up workflow...' + : loading + ? 'Loading run data...' + : ''; + + // Stop sleep button logic + const canStopSleep = !loading && !stoppingSleep && hasPendingSleeps; + const stopSleepDisabledReason = stoppingSleep + ? 'Stopping sleep...' + : loading + ? 'Loading run data...' + : !hasPendingSleeps + ? 'No pending sleep() calls to interrupt' + : ''; + + // Cancel button logic + const cancelDisabledReason = + runStatus === 'completed' + ? 'Run has already completed' + : runStatus === 'failed' + ? 'Run has already failed' + : runStatus === 'cancelled' + ? 'Run has already been cancelled' + : ''; + + return ( + <> + {/* Rerun Button */} + + + + + + + + {rerunDisabledReason ? ( +

{rerunDisabledReason}

+ ) : ( +

+ This will start a new copy of the current run using the same + deployment, environment, and inputs. It will not affect the + current run. +

+ )} +
+
+ + {/* Wake up / Stop sleep Button */} + {eventsLoading ? ( + + ) : hasPendingSleeps ? ( + + + + + + + + {stopSleepDisabledReason ? ( +

{stopSleepDisabledReason}

+ ) : ( +

+ Interrupt any current calls to sleep() and wake up + the run. +

+ )} +
+
+ ) : ( + + + + + + + + {wakeUpDisabledReason ? ( +

{wakeUpDisabledReason}

+ ) : ( +

+ Re-enqueue the workflow orchestration layer. This is a no-op, + unless the workflow got stuck due to an implementation issue in + the World. This is useful for debugging custom Worlds. +

+ )} +
+
+ )} + + {/* Cancel Button */} + + + + + + + + {cancelDisabledReason ? ( +

{cancelDisabledReason}

+ ) : ( +

Cancel the workflow run

+ )} +
+
+ + ); +} + +// ============================================================================ +// Lazy-loading wrapper for dropdown (fetches events when dropdown opens) +// ============================================================================ + +interface LazyRunActionsDropdownContentProps + extends RunActionsDropdownItemsProps { + fetchEvents: () => Promise; +} + +export function LazyRunActionsDropdownContent({ + fetchEvents, + ...props +}: LazyRunActionsDropdownContentProps) { + const [events, setEvents] = useState(undefined); + const [eventsLoading, setEventsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setEventsLoading(true); + + fetchEvents() + .then((result) => { + if (!cancelled) { + setEvents(result); + } + }) + .catch((err) => { + console.error('Failed to fetch events:', err); + if (!cancelled) { + setEvents(undefined); + } + }) + .finally(() => { + if (!cancelled) { + setEventsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [fetchEvents]); + + return ( + + ); +} + +// ============================================================================ +// Hook for lazy loading events (alternative approach) +// ============================================================================ + +export function useLazyEvents( + fetchEvents: () => Promise, + enabled: boolean +) { + const [events, setEvents] = useState(undefined); + const [loading, setLoading] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + + useEffect(() => { + if (!enabled || hasFetched) return; + + let cancelled = false; + setLoading(true); + + fetchEvents() + .then((result) => { + if (!cancelled) { + setEvents(result); + setHasFetched(true); + } + }) + .catch((err) => { + console.error('Failed to fetch events:', err); + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [enabled, hasFetched, fetchEvents]); + + return { events, loading }; +} diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index fc81aba52..51971f74b 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -5,12 +5,10 @@ import { cancelRun, recreateRun, StreamViewer, - stopSleepRun, useWorkflowStreams, useWorkflowTraceViewerData, type WorkflowRun, WorkflowTraceViewer, - wakeUpRun, } from '@workflow/web-shared'; import { AlertCircle, HelpCircle, List, Loader2 } from 'lucide-react'; import Link from 'next/link'; @@ -44,14 +42,11 @@ import { } from '@/components/ui/tooltip'; import { buildUrlWithConfig, worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; -import { CancelButton } from './display-utils/cancel-button'; import { CopyableText } from './display-utils/copyable-text'; import { LiveStatus } from './display-utils/live-status'; import { RelativeTime } from './display-utils/relative-time'; -import { RerunButton } from './display-utils/rerun-button'; import { StatusBadge } from './display-utils/status-badge'; -import { StopSleepButton } from './display-utils/stop-sleep-button'; -import { WakeUpButton } from './display-utils/wakeup-button'; +import { RunActionsButtons } from './run-actions'; import { Skeleton } from './ui/skeleton'; interface RunDetailViewProps { @@ -70,8 +65,6 @@ export function RunDetailView({ const searchParams = useSearchParams(); const [cancelling, setCancelling] = useState(false); const [rerunning, setRerunning] = useState(false); - const [wakingUp, setWakingUp] = useState(false); - const [stoppingSleep, setStoppingSleep] = useState(false); const [showCancelDialog, setShowCancelDialog] = useState(false); const [showRerunDialog, setShowRerunDialog] = useState(false); const env = useMemo(() => worldConfigToEnvMap(config), [config]); @@ -150,28 +143,6 @@ export function RunDetailView({ error: streamsError, } = useWorkflowStreams(env, runId); - // Check for pending sleeps (wait_created without matching wait_completed) - const hasPendingSleeps = useMemo(() => { - if (!allEvents || allEvents.length === 0) return false; - const waitCreatedCorrelationIds = new Set( - allEvents - .filter((e) => e.eventType === 'wait_created') - .map((e) => e.correlationId) - ); - const waitCompletedCorrelationIds = new Set( - allEvents - .filter((e) => e.eventType === 'wait_completed') - .map((e) => e.correlationId) - ); - // Check if there's any wait_created without a matching wait_completed - for (const correlationId of waitCreatedCorrelationIds) { - if (!waitCompletedCorrelationIds.has(correlationId)) { - return true; - } - } - return false; - }, [allEvents]); - // Find the workflow graph for this run // The manifest is keyed by workflowId which matches run.workflowName // e.g., "workflow//example/workflows/1_simple.ts//simple" @@ -223,56 +194,6 @@ export function RunDetailView({ setShowRerunDialog(true); }; - const handleWakeUpClick = async () => { - if (wakingUp) return; - - try { - setWakingUp(true); - await wakeUpRun(env, runId); - toast.success('Run woken up', { - description: 'The workflow orchestration layer has been re-enqueued.', - }); - // Trigger a refresh of the data - await update(); - } catch (err) { - console.error('Failed to wake up run:', err); - toast.error('Failed to wake up run', { - description: - err instanceof Error ? err.message : 'An unknown error occurred', - }); - } finally { - setWakingUp(false); - } - }; - - const handleStopSleepClick = async () => { - if (stoppingSleep) return; - - try { - setStoppingSleep(true); - const result = await stopSleepRun(env, runId); - if (result.stoppedCount > 0) { - toast.success('Sleep interrupted', { - description: `Stopped ${result.stoppedCount} pending sleep${result.stoppedCount > 1 ? 's' : ''} and woke up the run.`, - }); - } else { - toast.info('No pending sleeps', { - description: 'There were no pending sleep() calls to interrupt.', - }); - } - // Trigger a refresh of the data - await update(); - } catch (err) { - console.error('Failed to stop sleep:', err); - toast.error('Failed to stop sleep', { - description: - err instanceof Error ? err.message : 'An unknown error occurred', - }); - } finally { - setStoppingSleep(false); - } - }; - const handleConfirmRerun = async () => { if (rerunning) return; @@ -315,47 +236,6 @@ export function RunDetailView({ const hasError = false; const errorMessage = ''; - // Determine if cancel is allowed and why - const canCancel = run.status === 'pending' || run.status === 'running'; - const getCancelDisabledReason = () => { - if (cancelling) return 'Cancelling run...'; - if (run.status === 'completed') return 'Run has already completed'; - if (run.status === 'failed') return 'Run has already failed'; - if (run.status === 'cancelled') return 'Run has already been cancelled'; - return ''; - }; - const cancelDisabledReason = getCancelDisabledReason(); - - // Determine if re-run is allowed and why - const isRunActive = run.status === 'pending' || run.status === 'running'; - const canRerun = !loading && !isRunActive && !rerunning; - const getRerunDisabledReason = () => { - if (rerunning) return 'Re-running workflow...'; - if (loading) return 'Loading run data...'; - if (isRunActive) return 'Cannot re-run while workflow is still running'; - return ''; - }; - const rerunDisabledReason = getRerunDisabledReason(); - - // Determine if wake up is allowed and why - const canWakeUp = !loading && !wakingUp; - const getWakeUpDisabledReason = () => { - if (wakingUp) return 'Waking up workflow...'; - if (loading) return 'Loading run data...'; - return ''; - }; - const wakeUpDisabledReason = getWakeUpDisabledReason(); - - // Determine if stop sleep is allowed and why - const canStopSleep = !loading && !stoppingSleep && hasPendingSleeps; - const getStopSleepDisabledReason = () => { - if (stoppingSleep) return 'Stopping sleep...'; - if (loading) return 'Loading run data...'; - if (!hasPendingSleeps) return 'No pending sleep() calls to interrupt'; - return ''; - }; - const stopSleepDisabledReason = getStopSleepDisabledReason(); - return ( <> {/* Cancel Confirmation Dialog */} @@ -435,32 +315,16 @@ export function RunDetailView({
{/* Right side controls */} - - {hasPendingSleeps ? ( - - ) : ( - - )} -
diff --git a/packages/web/src/components/runs-table.tsx b/packages/web/src/components/runs-table.tsx index 43c0b19cf..6c8e6fc48 100644 --- a/packages/web/src/components/runs-table.tsx +++ b/packages/web/src/components/runs-table.tsx @@ -2,11 +2,11 @@ import { parseWorkflowName } from '@workflow/core/parse-name'; import { - cancelRun, + type EnvMap, + type Event, + fetchEvents, getErrorMessage, - recreateRun, useWorkflowRuns, - wakeUpRun, } from '@workflow/web-shared'; import type { WorkflowRunStatus } from '@workflow/world'; import { @@ -17,13 +17,9 @@ import { ChevronRight, MoreHorizontal, RefreshCw, - RotateCw, - XCircle, - Zap, } from 'lucide-react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { toast } from 'sonner'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; @@ -31,7 +27,6 @@ import { DocsLink } from '@/components/ui/docs-link'; import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { @@ -60,6 +55,61 @@ import { CopyableText } from './display-utils/copyable-text'; import { RelativeTime } from './display-utils/relative-time'; import { StatusBadge } from './display-utils/status-badge'; import { TableSkeleton } from './display-utils/table-skeleton'; +import { RunActionsDropdownItems } from './run-actions'; + +// Wrapper that fetches events lazily when dropdown content mounts +function RunActionsDropdownContent({ + env, + runId, + runStatus, + onSuccess, +}: { + env: EnvMap; + runId: string; + runStatus: WorkflowRunStatus | undefined; + onSuccess: () => void; +}) { + const [events, setEvents] = useState(undefined); + const [eventsLoading, setEventsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setEventsLoading(true); + + fetchEvents(env, runId, { limit: 1000 }) + .then((result: Awaited>) => { + if (!cancelled && result.success) { + setEvents(result.data.data); + } + }) + .catch((err: unknown) => { + console.error('Failed to fetch events:', err); + }) + .finally(() => { + if (!cancelled) { + setEventsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [env, runId]); + + return ( + + + + ); +} interface RunsTableProps { config: WorldConfig; @@ -446,94 +496,12 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { - - { - e.stopPropagation(); - try { - const newRunId = await recreateRun( - env, - run.runId - ); - toast.success('New run started', { - description: `Run ID: ${newRunId}`, - }); - reload(); - } catch (err) { - toast.error('Failed to re-run', { - description: - err instanceof Error - ? err.message - : 'Unknown error', - }); - } - }} - > - - Replay Run - - - - { - e.stopPropagation(); - try { - await wakeUpRun(env, run.runId); - toast.success('Run woken up', { - description: - 'The workflow orchestration layer has been re-enqueued.', - }); - reload(); - } catch (err) { - toast.error('Failed to wake up', { - description: - err instanceof Error - ? err.message - : 'Unknown error', - }); - } - }} - > - - Wake up - - - - Re-enqueue the workflow orchestration layer. - This is a no-op, unless the workflow got stuck - due to an implementation issue in the World. - This is useful for debugging custom Worlds. - - - { - e.stopPropagation(); - if (run.status !== 'pending') { - toast.error('Cannot cancel', { - description: - 'Only pending runs can be cancelled', - }); - return; - } - try { - await cancelRun(env, run.runId); - toast.success('Run cancelled'); - reload(); - } catch (err) { - toast.error('Failed to cancel', { - description: - err instanceof Error - ? err.message - : 'Unknown error', - }); - } - }} - disabled={run.status !== 'pending'} - > - - Cancel - - + From af8216f6e407abbfb38467a3d4c35af840813690 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 16:09:26 -0800 Subject: [PATCH 23/31] Fixes --- packages/web-shared/src/index.ts | 7 ------- packages/web/src/components/hooks-table.tsx | 2 +- packages/web/src/components/run-actions.tsx | 2 +- packages/web/src/components/runs-table.tsx | 2 +- packages/web/src/lib/flow-graph/use-workflow-graph.ts | 7 ++----- 5 files changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 89bbf5a31..287c170b7 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -5,14 +5,7 @@ export { export type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; export * from './api/workflow-api-client'; -export { stopSleepRun, wakeUpRun } from './api/workflow-api-client'; export type { EnvMap } from './api/workflow-server-actions'; -export { - fetchEvents, - fetchEventsByCorrelationId, - fetchStreams, - fetchWorkflowsManifest, -} from './api/workflow-server-actions'; export { formatDuration } from './lib/utils'; export { RunTraceView } from './run-trace-view'; export { StreamViewer } from './stream-viewer'; diff --git a/packages/web/src/components/hooks-table.tsx b/packages/web/src/components/hooks-table.tsx index 0dfaeb37c..d134105e0 100644 --- a/packages/web/src/components/hooks-table.tsx +++ b/packages/web/src/components/hooks-table.tsx @@ -2,11 +2,11 @@ import { cancelRun, - fetchEventsByCorrelationId, getErrorMessage, recreateRun, useWorkflowHooks, } from '@workflow/web-shared'; +import { fetchEventsByCorrelationId } from '@workflow/web-shared/server'; import type { Event, Hook } from '@workflow/world'; import { AlertCircle, diff --git a/packages/web/src/components/run-actions.tsx b/packages/web/src/components/run-actions.tsx index 8921d676d..7f54662cf 100644 --- a/packages/web/src/components/run-actions.tsx +++ b/packages/web/src/components/run-actions.tsx @@ -10,7 +10,7 @@ import { } from '@workflow/web-shared'; import type { WorkflowRunStatus } from '@workflow/world'; import { AlarmClockOff, Loader2, RotateCw, XCircle, Zap } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { diff --git a/packages/web/src/components/runs-table.tsx b/packages/web/src/components/runs-table.tsx index 6c8e6fc48..b3ba19de8 100644 --- a/packages/web/src/components/runs-table.tsx +++ b/packages/web/src/components/runs-table.tsx @@ -4,10 +4,10 @@ import { parseWorkflowName } from '@workflow/core/parse-name'; import { type EnvMap, type Event, - fetchEvents, getErrorMessage, useWorkflowRuns, } from '@workflow/web-shared'; +import { fetchEvents } from '@workflow/web-shared/server'; import type { WorkflowRunStatus } from '@workflow/world'; import { AlertCircle, diff --git a/packages/web/src/lib/flow-graph/use-workflow-graph.ts b/packages/web/src/lib/flow-graph/use-workflow-graph.ts index 2dcd7aafb..5c5e5b612 100644 --- a/packages/web/src/lib/flow-graph/use-workflow-graph.ts +++ b/packages/web/src/lib/flow-graph/use-workflow-graph.ts @@ -1,18 +1,15 @@ 'use client'; import { - fetchWorkflowsManifest, unwrapServerActionResult, WorkflowWebAPIError, } from '@workflow/web-shared'; +import { fetchWorkflowsManifest } from '@workflow/web-shared/server'; import { useCallback, useEffect, useRef, useState } from 'react'; import { worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; import { adaptManifest } from '@/lib/flow-graph/manifest-adapter'; -import type { - RawWorkflowsManifest, - WorkflowGraphManifest, -} from '@/lib/flow-graph/workflow-graph-types'; +import type { WorkflowGraphManifest } from '@/lib/flow-graph/workflow-graph-types'; /** * Hook to fetch the workflow graph manifest from the workflow data directory From 6e4daab6d800e135c7530dc7694e954272f06c43 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 16:03:22 -0800 Subject: [PATCH 24/31] Remove unused import --- packages/cli/src/lib/inspect/output.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/lib/inspect/output.ts b/packages/cli/src/lib/inspect/output.ts index fdbd7b1ba..4c3ae98e1 100644 --- a/packages/cli/src/lib/inspect/output.ts +++ b/packages/cli/src/lib/inspect/output.ts @@ -1,6 +1,5 @@ import { hydrateResourceIO } from '@workflow/core/observability'; import { parseStepName, parseWorkflowName } from '@workflow/core/parse-name'; -import { getRun } from '@workflow/core/runtime'; import { getDeserializeStream, getExternalRevivers, From 3d147b223b052a101f737317c50c745d4d5774fa Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 16:10:21 -0800 Subject: [PATCH 25/31] Fixes --- packages/web-shared/src/index.ts | 5 ----- packages/web/src/components/hooks-table.tsx | 2 +- packages/web/src/lib/flow-graph/use-workflow-graph.ts | 7 ++----- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index c2c639e5b..287c170b7 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -6,11 +6,6 @@ export { export type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; export * from './api/workflow-api-client'; export type { EnvMap } from './api/workflow-server-actions'; -export { - fetchEventsByCorrelationId, - fetchStreams, - fetchWorkflowsManifest, -} from './api/workflow-server-actions'; export { formatDuration } from './lib/utils'; export { RunTraceView } from './run-trace-view'; export { StreamViewer } from './stream-viewer'; diff --git a/packages/web/src/components/hooks-table.tsx b/packages/web/src/components/hooks-table.tsx index 6c9761fb4..2f3f60e55 100644 --- a/packages/web/src/components/hooks-table.tsx +++ b/packages/web/src/components/hooks-table.tsx @@ -2,11 +2,11 @@ import { cancelRun, - fetchEventsByCorrelationId, getErrorMessage, recreateRun, useWorkflowHooks, } from '@workflow/web-shared'; +import { fetchEventsByCorrelationId } from '@workflow/web-shared/server'; import type { Event, Hook } from '@workflow/world'; import { AlertCircle, diff --git a/packages/web/src/lib/flow-graph/use-workflow-graph.ts b/packages/web/src/lib/flow-graph/use-workflow-graph.ts index 2dcd7aafb..5c5e5b612 100644 --- a/packages/web/src/lib/flow-graph/use-workflow-graph.ts +++ b/packages/web/src/lib/flow-graph/use-workflow-graph.ts @@ -1,18 +1,15 @@ 'use client'; import { - fetchWorkflowsManifest, unwrapServerActionResult, WorkflowWebAPIError, } from '@workflow/web-shared'; +import { fetchWorkflowsManifest } from '@workflow/web-shared/server'; import { useCallback, useEffect, useRef, useState } from 'react'; import { worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; import { adaptManifest } from '@/lib/flow-graph/manifest-adapter'; -import type { - RawWorkflowsManifest, - WorkflowGraphManifest, -} from '@/lib/flow-graph/workflow-graph-types'; +import type { WorkflowGraphManifest } from '@/lib/flow-graph/workflow-graph-types'; /** * Hook to fetch the workflow graph manifest from the workflow data directory From 9c16423146ac9a53a111ead31166ae221f4c33c4 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 16:29:48 -0800 Subject: [PATCH 26/31] Allow individual wakeups --- .../web-shared/src/api/workflow-api-client.ts | 6 +- .../src/api/workflow-server-actions.ts | 31 ++++++-- .../src/sidebar/workflow-detail-panel.tsx | 70 ++++++++++++++++++- .../display-utils/stop-sleep-button.tsx | 2 +- packages/web/src/components/run-actions.tsx | 8 +-- 5 files changed, 104 insertions(+), 13 deletions(-) diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index 23e15dd72..b7f14da96 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -24,6 +24,7 @@ import { readStreamServerAction, recreateRun as recreateRunServerAction, stopSleepRun as stopSleepRunServerAction, + type StopSleepOptions, type StopSleepResult, wakeUpRun as wakeUpRunServerAction, } from './workflow-server-actions'; @@ -1121,10 +1122,11 @@ export async function wakeUpRun(env: EnvMap, runId: string): Promise { */ export async function stopSleepRun( env: EnvMap, - runId: string + runId: string, + options?: StopSleepOptions ): Promise { const { error, result: resultData } = await unwrapServerActionResult( - stopSleepRunServerAction(env, runId) + stopSleepRunServerAction(env, runId, options) ); if (error) { throw error; diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index 629091f97..16b2ec4a1 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -543,15 +543,29 @@ export interface StopSleepResult { 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[]; +} + /** - * Stop any pending sleep() calls for a workflow run. + * Stop pending sleep() calls for a workflow run. * - * This finds all wait_created events without matching wait_completed events, + * This finds wait_created events without matching wait_completed events, * creates wait_completed events for them, and then re-enqueues the run. + * + * @param worldEnv - Environment configuration for the World + * @param runId - The run ID to stop sleep calls for + * @param options - Optional settings to narrow down targeting */ export async function stopSleepRun( worldEnv: EnvMap, - runId: string + runId: string, + options?: StopSleepOptions ): Promise> { try { const world = getWorldFromEnv({ ...worldEnv }); @@ -575,10 +589,18 @@ export async function stopSleepRun( .map((e) => e.correlationId) ); - const pendingWaits = waitCreatedEvents.filter( + 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) { @@ -606,6 +628,7 @@ export async function stopSleepRun( } catch (error) { return createServerActionError(error, 'stopSleepRun', { runId, + correlationIds: options?.correlationIds, }); } } diff --git a/packages/web-shared/src/sidebar/workflow-detail-panel.tsx b/packages/web-shared/src/sidebar/workflow-detail-panel.tsx index fac59f1c6..5eccb142f 100644 --- a/packages/web-shared/src/sidebar/workflow-detail-panel.tsx +++ b/packages/web-shared/src/sidebar/workflow-detail-panel.tsx @@ -2,9 +2,13 @@ import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; import clsx from 'clsx'; -import { useEffect, useMemo } from 'react'; +import { AlarmClockOff } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; -import { useWorkflowResourceData } from '../api/workflow-api-client'; +import { + stopSleepRun, + useWorkflowResourceData, +} from '../api/workflow-api-client'; import type { EnvMap } from '../api/workflow-server-actions'; import { EventsList } from '../sidebar/events-list'; import { useTraceViewer } from '../trace-viewer'; @@ -25,6 +29,7 @@ export function WorkflowDetailPanel({ }): React.JSX.Element | null { const { state } = useTraceViewer(); const { selected } = state; + const [stoppingSleep, setStoppingSleep] = useState(false); const data = selected?.span.attributes?.data as | Step @@ -54,6 +59,15 @@ export function WorkflowDetailPanel({ return { resource: undefined, resourceId: undefined, runId: undefined }; }, [selected, data]); + // Check if this sleep is still pending (no wait_completed event) + const isSleepPending = useMemo(() => { + if (resource !== 'sleep' || !selected?.span.events) return false; + const hasWaitCompleted = selected.span.events.some( + (e) => e.name === 'wait_completed' + ); + return !hasWaitCompleted; + }, [resource, selected?.span.events]); + // Fetch full resource data with events const { data: fetchedData, @@ -74,6 +88,33 @@ export function WorkflowDetailPanel({ } }, [error, resource, selected]); + const handleStopSleep = async () => { + if (stoppingSleep || !resourceId) return; + + try { + setStoppingSleep(true); + const result = await stopSleepRun(env, run.runId, { + correlationIds: [resourceId], + }); + if (result.stoppedCount > 0) { + toast.success('Sleep interrupted', { + description: + 'The sleep call has been interrupted and the run woken up.', + }); + } else { + toast.info('Sleep already completed', { + description: 'This sleep call has already finished.', + }); + } + } catch (err) { + console.error('Failed to stop sleep:', err); + toast.error('Failed to stop sleep', { + description: + err instanceof Error ? err.message : 'An unknown error occurred', + }); + } + }; + if (!selected || !resource || !resourceId) { return null; } @@ -82,6 +123,31 @@ export function WorkflowDetailPanel({ return (
+ {/* Stop sleep button for pending sleep calls */} + {resource === 'sleep' && isSleepPending && ( +
+ +

+ Interrupt this specific sleep call and wake up the run. +

+
+ )} + {/* Content display */} {stopSleepDisabledReason}

) : (

- Interrupt any current calls to sleep() and wake up the + Interrupt any current calls to sleep and wake up the run.

)} diff --git a/packages/web/src/components/run-actions.tsx b/packages/web/src/components/run-actions.tsx index 7f54662cf..73b3c3452 100644 --- a/packages/web/src/components/run-actions.tsx +++ b/packages/web/src/components/run-actions.tsx @@ -207,7 +207,7 @@ export function RunActionsDropdownItems({ - Interrupt any current calls to sleep() and wake up the + Interrupt any current calls to sleep and wake up the run. @@ -305,7 +305,7 @@ export function RunActionsButtons({ }); } else { toast.info('No pending sleeps', { - description: 'There were no pending sleep() calls to interrupt.', + description: 'There were no pending sleep calls to interrupt.', }); } callbacks?.onSuccess?.(); @@ -343,7 +343,7 @@ export function RunActionsButtons({ : loading ? 'Loading run data...' : !hasPendingSleeps - ? 'No pending sleep() calls to interrupt' + ? 'No pending sleep calls to interrupt' : ''; // Cancel button logic @@ -411,7 +411,7 @@ export function RunActionsButtons({

{stopSleepDisabledReason}

) : (

- Interrupt any current calls to sleep() and wake up + Interrupt any current calls to sleep and wake up the run.

)} From 5ab8d1e53518b315bd1e7d6a64d54c4eb605bc83 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 8 Dec 2025 16:31:03 -0800 Subject: [PATCH 27/31] Fix --- .../web-shared/src/sidebar/workflow-detail-panel.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/web-shared/src/sidebar/workflow-detail-panel.tsx b/packages/web-shared/src/sidebar/workflow-detail-panel.tsx index 5eccb142f..7482d3faf 100644 --- a/packages/web-shared/src/sidebar/workflow-detail-panel.tsx +++ b/packages/web-shared/src/sidebar/workflow-detail-panel.tsx @@ -60,13 +60,18 @@ export function WorkflowDetailPanel({ }, [selected, data]); // Check if this sleep is still pending (no wait_completed event) + // We include events length to ensure recomputation when new events are added + // (the array reference might not change when events are pushed to it) + const spanEvents = selected?.span.events; + const spanEventsLength = spanEvents?.length ?? 0; const isSleepPending = useMemo(() => { - if (resource !== 'sleep' || !selected?.span.events) return false; - const hasWaitCompleted = selected.span.events.some( + void spanEventsLength; // Force dependency on length for reactivity + if (resource !== 'sleep' || !spanEvents) return false; + const hasWaitCompleted = spanEvents.some( (e) => e.name === 'wait_completed' ); return !hasWaitCompleted; - }, [resource, selected?.span.events]); + }, [resource, spanEvents, spanEventsLength]); // Fetch full resource data with events const { From 289e5762d17a22494574c2beff79b2cee852fd32 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Tue, 9 Dec 2025 12:58:00 -0800 Subject: [PATCH 28/31] Clean up --- .../web-shared/src/api/workflow-api-client.ts | 14 +- .../src/api/workflow-server-actions.ts | 16 +- packages/web-shared/src/index.ts | 9 + packages/web-shared/src/lib/event-analysis.ts | 241 ++++++++++++ .../src/sidebar/workflow-detail-panel.tsx | 29 +- .../display-utils/stop-sleep-button.tsx | 49 --- .../display-utils/wakeup-button.tsx | 50 --- packages/web/src/components/run-actions.tsx | 361 ++++++++++-------- .../web/src/components/run-detail-view.tsx | 2 + packages/web/src/components/runs-table.tsx | 96 +++-- 10 files changed, 545 insertions(+), 322 deletions(-) create mode 100644 packages/web-shared/src/lib/event-analysis.ts delete mode 100644 packages/web/src/components/display-utils/stop-sleep-button.tsx delete mode 100644 packages/web/src/components/display-utils/wakeup-button.tsx diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index b7f14da96..86bf7678e 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -23,10 +23,10 @@ import { fetchStreams, readStreamServerAction, recreateRun as recreateRunServerAction, - stopSleepRun as stopSleepRunServerAction, + wakeUpRun as wakeUpRunServerAction, type StopSleepOptions, type StopSleepResult, - wakeUpRun as wakeUpRunServerAction, + reenqueueRun as reenqueueRunServerAction, } from './workflow-server-actions'; const MAX_ITEMS = 1000; @@ -1108,9 +1108,9 @@ export async function recreateRun(env: EnvMap, runId: string): Promise { /** * Wake up a workflow run by re-enqueuing it */ -export async function wakeUpRun(env: EnvMap, runId: string): Promise { +export async function reenqueueRun(env: EnvMap, runId: string): Promise { const { error } = await unwrapServerActionResult( - wakeUpRunServerAction(env, runId) + reenqueueRunServerAction(env, runId) ); if (error) { throw error; @@ -1118,15 +1118,15 @@ export async function wakeUpRun(env: EnvMap, runId: string): Promise { } /** - * Stop any pending sleep() calls for a workflow run + * Wake up a workflow run by interrupting any pending sleep() calls */ -export async function stopSleepRun( +export async function wakeUpRun( env: EnvMap, runId: string, options?: StopSleepOptions ): Promise { const { error, result: resultData } = await unwrapServerActionResult( - stopSleepRunServerAction(env, runId, options) + wakeUpRunServerAction(env, runId, options) ); if (error) { throw error; diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index 16b2ec4a1..94afa7507 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -508,12 +508,12 @@ export async function recreateRun( } /** - * Wake up a workflow run by re-enqueuing it. + * Re-enqueue a workflow run. * * This re-enqueues the workflow orchestration layer. It's a no-op unless the workflow * got stuck due to an implementation issue in the World. Useful for debugging custom Worlds. */ -export async function wakeUpRun( +export async function reenqueueRun( worldEnv: EnvMap, runId: string ): Promise> { @@ -534,7 +534,7 @@ export async function wakeUpRun( return createResponse(undefined); } catch (error) { - return createServerActionError(error, 'wakeUpRun', { runId }); + return createServerActionError(error, 'reenqueueRun', { runId }); } } @@ -553,16 +553,16 @@ export interface StopSleepOptions { } /** - * Stop pending sleep() calls for a workflow run. + * Wake up a workflow run by interrupting pending sleep() calls. * * This finds wait_created events without matching wait_completed events, * creates wait_completed events for them, and then re-enqueues the run. * * @param worldEnv - Environment configuration for the World - * @param runId - The run ID to stop sleep calls for - * @param options - Optional settings to narrow down targeting + * @param runId - The run ID to wake up + * @param options - Optional settings to narrow down targeting (specific correlation IDs) */ -export async function stopSleepRun( +export async function wakeUpRun( worldEnv: EnvMap, runId: string, options?: StopSleepOptions @@ -626,7 +626,7 @@ export async function stopSleepRun( return createResponse({ stoppedCount: pendingWaits.length }); } catch (error) { - return createServerActionError(error, 'stopSleepRun', { + return createServerActionError(error, 'wakeUpRun', { runId, correlationIds: options?.correlationIds, }); diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 287c170b7..cb51cda57 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -6,6 +6,15 @@ export { export type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; export * from './api/workflow-api-client'; export type { EnvMap } from './api/workflow-server-actions'; +export type { EventAnalysis } from './lib/event-analysis'; +export { + analyzeEvents, + hasPendingHooksFromEvents, + hasPendingSleepsFromEvents, + hasPendingStepsFromEvents, + isTerminalStatus, + shouldShowReenqueueButton, +} from './lib/event-analysis'; export { formatDuration } from './lib/utils'; export { RunTraceView } from './run-trace-view'; export { StreamViewer } from './stream-viewer'; diff --git a/packages/web-shared/src/lib/event-analysis.ts b/packages/web-shared/src/lib/event-analysis.ts new file mode 100644 index 000000000..0058c0b0f --- /dev/null +++ b/packages/web-shared/src/lib/event-analysis.ts @@ -0,0 +1,241 @@ +/** + * Shared utilities for analyzing workflow events. + * Used by run-actions and trace viewer components. + */ + +import type { Event, WorkflowRunStatus } from '@workflow/world'; + +// Time thresholds for Re-enqueue button visibility +const STEP_ACTIVITY_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes +const STEP_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Result of analyzing events for a workflow run + */ +export interface EventAnalysis { + /** Whether there are pending sleep/wait calls */ + hasPendingSleeps: boolean; + /** Whether there are pending steps (started but not completed/failed) */ + hasPendingSteps: boolean; + /** Whether there are pending hooks (created but not disposed) */ + hasPendingHooks: boolean; + /** Correlation IDs of pending sleeps */ + pendingSleepIds: string[]; + /** Correlation IDs of pending steps */ + pendingStepIds: string[]; + /** Correlation IDs of pending hooks */ + pendingHookIds: string[]; + /** Timestamp of the last step_started or step_retrying event */ + lastStepActivityAt: Date | null; + /** Timestamp of the last step completion (step_completed or step_failed) */ + lastStepCompletionAt: Date | null; +} + +/** + * Analyze events to determine pending sleeps, steps, and hooks. + */ +export function analyzeEvents(events: Event[] | undefined): EventAnalysis { + if (!events || events.length === 0) { + return { + hasPendingSleeps: false, + hasPendingSteps: false, + hasPendingHooks: false, + pendingSleepIds: [], + pendingStepIds: [], + pendingHookIds: [], + lastStepActivityAt: null, + lastStepCompletionAt: null, + }; + } + + // Group events by correlation ID for each type + const waitCreated = new Map(); + const waitCompleted = new Set(); + const stepStarted = new Map(); + const stepCompleted = new Set(); + const hookCreated = new Map(); + const hookDisposed = new Set(); + + let lastStepActivityAt: Date | null = null; + let lastStepCompletionAt: Date | null = null; + + for (const event of events) { + const correlationId = event.correlationId; + if (!correlationId) continue; + + switch (event.eventType) { + // Sleeps/Waits + case 'wait_created': + waitCreated.set(correlationId, event); + break; + case 'wait_completed': + waitCompleted.add(correlationId); + break; + + // Steps + case 'step_started': + stepStarted.set(correlationId, event); + if ( + !lastStepActivityAt || + new Date(event.createdAt) > lastStepActivityAt + ) { + lastStepActivityAt = new Date(event.createdAt); + } + break; + case 'step_retrying': + if ( + !lastStepActivityAt || + new Date(event.createdAt) > lastStepActivityAt + ) { + lastStepActivityAt = new Date(event.createdAt); + } + break; + case 'step_completed': + case 'step_failed': + stepCompleted.add(correlationId); + if ( + !lastStepCompletionAt || + new Date(event.createdAt) > lastStepCompletionAt + ) { + lastStepCompletionAt = new Date(event.createdAt); + } + break; + + // Hooks + case 'hook_created': + hookCreated.set(correlationId, event); + break; + case 'hook_disposed': + hookDisposed.add(correlationId); + break; + } + } + + // Find pending items (created but not completed) + const pendingSleepIds = Array.from(waitCreated.keys()).filter( + (id) => !waitCompleted.has(id) + ); + const pendingStepIds = Array.from(stepStarted.keys()).filter( + (id) => !stepCompleted.has(id) + ); + const pendingHookIds = Array.from(hookCreated.keys()).filter( + (id) => !hookDisposed.has(id) + ); + + return { + hasPendingSleeps: pendingSleepIds.length > 0, + hasPendingSteps: pendingStepIds.length > 0, + hasPendingHooks: pendingHookIds.length > 0, + pendingSleepIds, + pendingStepIds, + pendingHookIds, + lastStepActivityAt, + lastStepCompletionAt, + }; +} + +/** + * Check if a workflow run status is terminal (completed, failed, or cancelled) + */ +export function isTerminalStatus( + status: WorkflowRunStatus | undefined +): boolean { + return ( + status === 'completed' || status === 'failed' || status === 'cancelled' + ); +} + +/** + * Determine if the Re-enqueue button should be shown without the debug flag. + * + * The Re-enqueue button is shown when the workflow appears to be stuck: + * - The workflow is not in a terminal state + * - There are no pending sleeps (which would show the Wake up button instead) + * - There are no pending hooks (which are waiting for external input) + * - Either: + * - The last step_started or step_retrying event was >30 minutes ago, OR + * - There have been no pending steps for >5 minutes (all steps completed/failed) + */ +export function shouldShowReenqueueButton( + events: Event[] | undefined, + status: WorkflowRunStatus | undefined +): boolean { + // Never show if in terminal state + if (isTerminalStatus(status)) { + return false; + } + + const analysis = analyzeEvents(events); + + // Don't show if there are pending sleeps (Wake up button handles this) + if (analysis.hasPendingSleeps) { + return false; + } + + // Don't show if there are pending hooks (waiting for external input) + if (analysis.hasPendingHooks) { + return false; + } + + const now = Date.now(); + + // Check if last step activity was >30 minutes ago + if (analysis.lastStepActivityAt) { + const timeSinceLastActivity = now - analysis.lastStepActivityAt.getTime(); + if (timeSinceLastActivity > STEP_ACTIVITY_TIMEOUT_MS) { + return true; + } + } + + // Check if there are no pending steps and last completion was >5 minutes ago + if (!analysis.hasPendingSteps && analysis.lastStepCompletionAt) { + const timeSinceLastCompletion = + now - analysis.lastStepCompletionAt.getTime(); + if (timeSinceLastCompletion > STEP_IDLE_TIMEOUT_MS) { + return true; + } + } + + // If there's no step activity at all but the run is not terminal, + // and we've been waiting for a while, show the button + if ( + !analysis.lastStepActivityAt && + !analysis.hasPendingSteps && + !analysis.hasPendingSleeps && + !analysis.hasPendingHooks + ) { + // This case handles runs that haven't started any steps yet + // but aren't in a terminal state - they might be stuck + return true; + } + + return false; +} + +/** + * Check if there are pending sleeps from an events list. + * This is a convenience function for backwards compatibility. + */ +export function hasPendingSleepsFromEvents( + events: Event[] | undefined +): boolean { + return analyzeEvents(events).hasPendingSleeps; +} + +/** + * Check if there are pending steps from an events list. + */ +export function hasPendingStepsFromEvents( + events: Event[] | undefined +): boolean { + return analyzeEvents(events).hasPendingSteps; +} + +/** + * Check if there are pending hooks from an events list. + */ +export function hasPendingHooksFromEvents( + events: Event[] | undefined +): boolean { + return analyzeEvents(events).hasPendingHooks; +} diff --git a/packages/web-shared/src/sidebar/workflow-detail-panel.tsx b/packages/web-shared/src/sidebar/workflow-detail-panel.tsx index 7482d3faf..a1472e602 100644 --- a/packages/web-shared/src/sidebar/workflow-detail-panel.tsx +++ b/packages/web-shared/src/sidebar/workflow-detail-panel.tsx @@ -2,13 +2,10 @@ import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; import clsx from 'clsx'; -import { AlarmClockOff } from 'lucide-react'; +import { Zap } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; -import { - stopSleepRun, - useWorkflowResourceData, -} from '../api/workflow-api-client'; +import { useWorkflowResourceData, wakeUpRun } from '../api/workflow-api-client'; import type { EnvMap } from '../api/workflow-server-actions'; import { EventsList } from '../sidebar/events-list'; import { useTraceViewer } from '../trace-viewer'; @@ -93,16 +90,16 @@ export function WorkflowDetailPanel({ } }, [error, resource, selected]); - const handleStopSleep = async () => { + const handleWakeUp = async () => { if (stoppingSleep || !resourceId) return; try { setStoppingSleep(true); - const result = await stopSleepRun(env, run.runId, { + const result = await wakeUpRun(env, run.runId, { correlationIds: [resourceId], }); if (result.stoppedCount > 0) { - toast.success('Sleep interrupted', { + toast.success('Run woken up', { description: 'The sleep call has been interrupted and the run woken up.', }); @@ -112,11 +109,13 @@ export function WorkflowDetailPanel({ }); } } catch (err) { - console.error('Failed to stop sleep:', err); - toast.error('Failed to stop sleep', { + console.error('Failed to wake up run:', err); + toast.error('Failed to wake up run', { description: err instanceof Error ? err.message : 'An unknown error occurred', }); + } finally { + setStoppingSleep(false); } }; @@ -128,12 +127,12 @@ export function WorkflowDetailPanel({ return (
- {/* Stop sleep button for pending sleep calls */} + {/* Wake up button for pending sleep calls */} {resource === 'sleep' && isSleepPending && (

- Interrupt this specific sleep call and wake up the run. + Interrupt this sleep call and wake up the run.

)} diff --git a/packages/web/src/components/display-utils/stop-sleep-button.tsx b/packages/web/src/components/display-utils/stop-sleep-button.tsx deleted file mode 100644 index 4fae656be..000000000 --- a/packages/web/src/components/display-utils/stop-sleep-button.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { AlarmClockOff } from 'lucide-react'; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { Button } from '../ui/button'; - -interface StopSleepButtonProps { - canStopSleep: boolean; - stoppingSleep: boolean; - stopSleepDisabledReason: string | null; - onStopSleep: () => void; -} - -export function StopSleepButton({ - canStopSleep, - stoppingSleep, - stopSleepDisabledReason, - onStopSleep, -}: StopSleepButtonProps) { - return ( - - - - - - - - {stopSleepDisabledReason ? ( -

{stopSleepDisabledReason}

- ) : ( -

- Interrupt any current calls to sleep and wake up the - run. -

- )} -
-
- ); -} diff --git a/packages/web/src/components/display-utils/wakeup-button.tsx b/packages/web/src/components/display-utils/wakeup-button.tsx deleted file mode 100644 index c7a562ff5..000000000 --- a/packages/web/src/components/display-utils/wakeup-button.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Zap } from 'lucide-react'; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { Button } from '../ui/button'; - -interface WakeUpButtonProps { - canWakeUp: boolean; - wakingUp: boolean; - wakeUpDisabledReason: string | null; - onWakeUp: () => void; -} - -export function WakeUpButton({ - canWakeUp, - wakingUp, - wakeUpDisabledReason, - onWakeUp, -}: WakeUpButtonProps) { - return ( - - - - - - - - {wakeUpDisabledReason ? ( -

{wakeUpDisabledReason}

- ) : ( -

- Re-enqueue the workflow orchestration layer. This is a no-op, unless - the workflow got stuck due to an implementation issue in the World. - This is useful for debugging custom Worlds. -

- )} -
-
- ); -} diff --git a/packages/web/src/components/run-actions.tsx b/packages/web/src/components/run-actions.tsx index 73b3c3452..292cefcbd 100644 --- a/packages/web/src/components/run-actions.tsx +++ b/packages/web/src/components/run-actions.tsx @@ -1,16 +1,18 @@ 'use client'; import { + analyzeEvents, cancelRun, type EnvMap, type Event, recreateRun, - stopSleepRun, + reenqueueRun, + shouldShowReenqueueButton, wakeUpRun, } from '@workflow/web-shared'; import type { WorkflowRunStatus } from '@workflow/world'; -import { AlarmClockOff, Loader2, RotateCw, XCircle, Zap } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { Loader2, RotateCw, XCircle, Zap } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { @@ -20,31 +22,6 @@ import { } from '@/components/ui/tooltip'; import { Button } from './ui/button'; -/** - * Compute whether there are pending sleeps from an events list - */ -export function hasPendingSleepsFromEvents( - events: Event[] | undefined -): boolean { - if (!events || events.length === 0) return false; - const waitCreatedCorrelationIds = new Set( - events - .filter((e) => e.eventType === 'wait_created') - .map((e) => e.correlationId) - ); - const waitCompletedCorrelationIds = new Set( - events - .filter((e) => e.eventType === 'wait_completed') - .map((e) => e.correlationId) - ); - for (const correlationId of waitCreatedCorrelationIds) { - if (!waitCompletedCorrelationIds.has(correlationId)) { - return true; - } - } - return false; -} - // ============================================================================ // Shared Props and Types // ============================================================================ @@ -64,36 +41,39 @@ export interface RunActionsBaseProps { } // ============================================================================ -// Dropdown Menu Items (for runs-table) +// Shared Hook for Run Actions // ============================================================================ -export interface RunActionsDropdownItemsProps extends RunActionsBaseProps { - /** Stop click event propagation (useful in table rows) */ - stopPropagation?: boolean; +interface UseRunActionsOptions { + env: EnvMap; + runId: string; + runStatus: WorkflowRunStatus | undefined; + events?: Event[]; + callbacks?: RunActionCallbacks; } -export function RunActionsDropdownItems({ +function useRunActions({ env, runId, runStatus, events, - eventsLoading, callbacks, - stopPropagation = false, -}: RunActionsDropdownItemsProps) { +}: UseRunActionsOptions) { const [rerunning, setRerunning] = useState(false); + const [reenqueuing, setReenqueuing] = useState(false); const [wakingUp, setWakingUp] = useState(false); - const [stoppingSleep, setStoppingSleep] = useState(false); const [cancelling, setCancelling] = useState(false); - const hasPendingSleeps = useMemo( - () => hasPendingSleepsFromEvents(events), - [events] + const eventAnalysis = useMemo(() => analyzeEvents(events), [events]); + const hasPendingSleeps = eventAnalysis.hasPendingSleeps; + + const showReenqueueForStuckWorkflow = useMemo( + () => shouldShowReenqueueButton(events, runStatus), + [events, runStatus] ); - const handleReplay = async (e: React.MouseEvent) => { - if (stopPropagation) e.stopPropagation(); - if (rerunning) return; + const handleReplay = useCallback(async () => { + if (rerunning) return null; try { setRerunning(true); @@ -103,45 +83,45 @@ export function RunActionsDropdownItems({ }); callbacks?.onSuccess?.(); callbacks?.onNavigateToRun?.(newRunId); + return newRunId; } catch (err) { toast.error('Failed to re-run', { description: err instanceof Error ? err.message : 'Unknown error', }); + return null; } finally { setRerunning(false); } - }; + }, [env, runId, rerunning, callbacks]); - const handleWakeUp = async (e: React.MouseEvent) => { - if (stopPropagation) e.stopPropagation(); - if (wakingUp) return; + const handleReenqueue = useCallback(async () => { + if (reenqueuing) return; try { - setWakingUp(true); - await wakeUpRun(env, runId); - toast.success('Run woken up', { + setReenqueuing(true); + await reenqueueRun(env, runId); + toast.success('Run re-enqueued', { description: 'The workflow orchestration layer has been re-enqueued.', }); callbacks?.onSuccess?.(); } catch (err) { - toast.error('Failed to wake up', { + toast.error('Failed to re-enqueue', { description: err instanceof Error ? err.message : 'Unknown error', }); } finally { - setWakingUp(false); + setReenqueuing(false); } - }; + }, [env, runId, reenqueuing, callbacks]); - const handleStopSleep = async (e: React.MouseEvent) => { - if (stopPropagation) e.stopPropagation(); - if (stoppingSleep) return; + const handleWakeUp = useCallback(async () => { + if (wakingUp) return; try { - setStoppingSleep(true); - const result = await stopSleepRun(env, runId); + setWakingUp(true); + const result = await wakeUpRun(env, runId); if (result.stoppedCount > 0) { - toast.success('Sleep interrupted', { - description: `Stopped ${result.stoppedCount} pending sleep${result.stoppedCount > 1 ? 's' : ''} and woke up the run.`, + toast.success('Run woken up', { + description: `Interrupted ${result.stoppedCount} pending sleep${result.stoppedCount > 1 ? 's' : ''} and woke up the run.`, }); } else { toast.info('No pending sleeps', { @@ -150,16 +130,15 @@ export function RunActionsDropdownItems({ } callbacks?.onSuccess?.(); } catch (err) { - toast.error('Failed to stop sleep', { + toast.error('Failed to wake up', { description: err instanceof Error ? err.message : 'Unknown error', }); } finally { - setStoppingSleep(false); + setWakingUp(false); } - }; + }, [env, runId, wakingUp, callbacks]); - const handleCancel = async (e: React.MouseEvent) => { - if (stopPropagation) e.stopPropagation(); + const handleCancel = useCallback(async () => { if (cancelling) return; if (runStatus !== 'pending') { @@ -181,15 +160,116 @@ export function RunActionsDropdownItems({ } finally { setCancelling(false); } + }, [env, runId, runStatus, cancelling, callbacks]); + + return { + // State + rerunning, + reenqueuing, + wakingUp, + cancelling, + hasPendingSleeps, + showReenqueueForStuckWorkflow, + // Handlers + handleReplay, + handleReenqueue, + handleWakeUp, + handleCancel, + }; +} + +// ============================================================================ +// Shared Tooltip Content +// ============================================================================ + +function WakeUpTooltipContent() { + return ( + <> + Interrupt any current calls to sleep and wake up the run. + + ); +} + +function ReenqueueTooltipContent({ isStuck }: { isStuck: boolean }) { + if (isStuck) { + return ( + <> + This workflow appears to be stuck. Re-enqueue the workflow orchestration + layer to resume execution. + + ); + } + return ( + <> + Re-enqueue the workflow orchestration layer. This is a no-op, unless the + workflow got stuck due to an implementation issue in the World. This is + useful for debugging custom Worlds. + + ); +} + +// ============================================================================ +// Dropdown Menu Items (for runs-table) +// ============================================================================ + +export interface RunActionsDropdownItemsProps extends RunActionsBaseProps { + /** Stop click event propagation (useful in table rows) */ + stopPropagation?: boolean; + /** Show debug actions like Re-enqueue (requires debug=1 URL param) */ + showDebugActions?: boolean; +} + +export function RunActionsDropdownItems({ + env, + runId, + runStatus, + events, + eventsLoading, + callbacks, + stopPropagation = false, + showDebugActions = false, +}: RunActionsDropdownItemsProps) { + const { + rerunning, + reenqueuing, + wakingUp, + cancelling, + hasPendingSleeps, + showReenqueueForStuckWorkflow, + handleReplay, + handleReenqueue, + handleWakeUp, + handleCancel, + } = useRunActions({ env, runId, runStatus, events, callbacks }); + + const onReplay = (e: React.MouseEvent) => { + if (stopPropagation) e.stopPropagation(); + handleReplay(); + }; + + const onReenqueue = (e: React.MouseEvent) => { + if (stopPropagation) e.stopPropagation(); + handleReenqueue(); + }; + + const onWakeUp = (e: React.MouseEvent) => { + if (stopPropagation) e.stopPropagation(); + handleWakeUp(); + }; + + const onCancel = (e: React.MouseEvent) => { + if (stopPropagation) e.stopPropagation(); + handleCancel(); }; return ( <> - + {rerunning ? 'Replaying...' : 'Replay Run'} + {/* Wake up button - only shown when there are pending sleeps */} {eventsLoading ? ( @@ -198,37 +278,36 @@ export function RunActionsDropdownItems({ ) : hasPendingSleeps ? ( - - - {stoppingSleep ? 'Stopping...' : 'Stop sleep'} + + + {wakingUp ? 'Waking up...' : 'Wake up'} - Interrupt any current calls to sleep and wake up the - run. + - ) : ( + ) : null} + + {/* Re-enqueue button - shown with debug=1 param OR when workflow appears stuck */} + {(showDebugActions || showReenqueueForStuckWorkflow) && ( - + - {wakingUp ? 'Waking up...' : 'Wake up'} + {reenqueuing ? 'Re-enqueuing...' : 'Re-enqueue'} - Re-enqueue the workflow orchestration layer. This is a no-op, unless - the workflow got stuck due to an implementation issue in the World. - This is useful for debugging custom Worlds. + )} @@ -248,6 +327,8 @@ export interface RunActionsButtonsProps extends RunActionsBaseProps { onCancelClick?: () => void; /** Called when rerun button is clicked - typically shows a confirmation dialog */ onRerunClick?: () => void; + /** Show debug actions like Re-enqueue (requires debug=1 URL param) */ + showDebugActions?: boolean; } export function RunActionsButtons({ @@ -260,66 +341,20 @@ export function RunActionsButtons({ callbacks, onCancelClick, onRerunClick, + showDebugActions = false, }: RunActionsButtonsProps) { - const [wakingUp, setWakingUp] = useState(false); - const [stoppingSleep, setStoppingSleep] = useState(false); - - const hasPendingSleeps = useMemo( - () => hasPendingSleepsFromEvents(events), - [events] - ); + const { + reenqueuing, + wakingUp, + hasPendingSleeps, + showReenqueueForStuckWorkflow, + handleReenqueue, + handleWakeUp, + } = useRunActions({ env, runId, runStatus, events, callbacks }); const isRunActive = runStatus === 'pending' || runStatus === 'running'; const canCancel = isRunActive; - const handleWakeUp = async () => { - if (wakingUp) return; - - try { - setWakingUp(true); - await wakeUpRun(env, runId); - toast.success('Run woken up', { - description: 'The workflow orchestration layer has been re-enqueued.', - }); - callbacks?.onSuccess?.(); - } catch (err) { - console.error('Failed to wake up run:', err); - toast.error('Failed to wake up run', { - description: - err instanceof Error ? err.message : 'An unknown error occurred', - }); - } finally { - setWakingUp(false); - } - }; - - const handleStopSleep = async () => { - if (stoppingSleep) return; - - try { - setStoppingSleep(true); - const result = await stopSleepRun(env, runId); - if (result.stoppedCount > 0) { - toast.success('Sleep interrupted', { - description: `Stopped ${result.stoppedCount} pending sleep${result.stoppedCount > 1 ? 's' : ''} and woke up the run.`, - }); - } else { - toast.info('No pending sleeps', { - description: 'There were no pending sleep calls to interrupt.', - }); - } - callbacks?.onSuccess?.(); - } catch (err) { - console.error('Failed to stop sleep:', err); - toast.error('Failed to stop sleep', { - description: - err instanceof Error ? err.message : 'An unknown error occurred', - }); - } finally { - setStoppingSleep(false); - } - }; - // Rerun button logic const canRerun = !loading && !isRunActive; const rerunDisabledReason = loading @@ -328,18 +363,18 @@ export function RunActionsButtons({ ? 'Cannot re-run while workflow is still running' : ''; - // Wake up button logic - const canWakeUp = !loading && !wakingUp; - const wakeUpDisabledReason = wakingUp - ? 'Waking up workflow...' + // Re-enqueue button logic + const canReenqueue = !loading && !reenqueuing; + const reenqueueDisabledReason = reenqueuing + ? 'Re-enqueuing workflow...' : loading ? 'Loading run data...' : ''; - // Stop sleep button logic - const canStopSleep = !loading && !stoppingSleep && hasPendingSleeps; - const stopSleepDisabledReason = stoppingSleep - ? 'Stopping sleep...' + // Wake up button logic + const canWakeUp = !loading && !wakingUp && hasPendingSleeps; + const wakeUpDisabledReason = wakingUp + ? 'Waking up workflow...' : loading ? 'Loading run data...' : !hasPendingSleeps @@ -386,7 +421,7 @@ export function RunActionsButtons({ - {/* Wake up / Stop sleep Button */} + {/* Wake up Button - only shown when there are pending sleeps */} {eventsLoading ? ( - {stopSleepDisabledReason ? ( -

{stopSleepDisabledReason}

+ {wakeUpDisabledReason ? ( +

{wakeUpDisabledReason}

) : (

- Interrupt any current calls to sleep and wake up - the run. +

)}
- ) : ( + ) : null} + + {/* Re-enqueue Button - shown with debug=1 param OR when workflow appears stuck */} + {(showDebugActions || showReenqueueForStuckWorkflow) && ( - {wakeUpDisabledReason ? ( -

{wakeUpDisabledReason}

+ {reenqueueDisabledReason ? ( +

{reenqueueDisabledReason}

) : (

- Re-enqueue the workflow orchestration layer. This is a no-op, - unless the workflow got stuck due to an implementation issue in - the World. This is useful for debugging custom Worlds. +

)}
diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index 51971f74b..c3d2e34b4 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -72,6 +72,7 @@ export function RunDetailView({ // Read tab and streamId from URL search params const activeTab = (searchParams.get('tab') as 'trace' | 'streams') || 'trace'; const selectedStreamId = searchParams.get('streamId'); + const showDebugActions = searchParams.get('debug') === '1'; // Helper to update URL search params const updateSearchParams = useCallback( @@ -325,6 +326,7 @@ export function RunDetailView({ onRerunClick={handleRerunClick} onCancelClick={handleCancelClick} callbacks={{ onSuccess: update }} + showDebugActions={showDebugActions} />
diff --git a/packages/web/src/components/runs-table.tsx b/packages/web/src/components/runs-table.tsx index b3ba19de8..06e00b997 100644 --- a/packages/web/src/components/runs-table.tsx +++ b/packages/web/src/components/runs-table.tsx @@ -57,17 +57,19 @@ import { StatusBadge } from './display-utils/status-badge'; import { TableSkeleton } from './display-utils/table-skeleton'; import { RunActionsDropdownItems } from './run-actions'; -// Wrapper that fetches events lazily when dropdown content mounts -function RunActionsDropdownContent({ +// Inner content that fetches events when it mounts (only rendered when dropdown is open) +function RunActionsDropdownContentInner({ env, runId, runStatus, onSuccess, + showDebugActions, }: { env: EnvMap; runId: string; runStatus: WorkflowRunStatus | undefined; onSuccess: () => void; + showDebugActions: boolean; }) { const [events, setEvents] = useState(undefined); const [eventsLoading, setEventsLoading] = useState(true); @@ -97,17 +99,59 @@ function RunActionsDropdownContent({ }, [env, runId]); return ( - - - + + ); +} + +// Wrapper that only renders content when dropdown is open (lazy loading) +function LazyDropdownMenu({ + env, + runId, + runStatus, + onSuccess, + showDebugActions, +}: { + env: EnvMap; + runId: string; + runStatus: WorkflowRunStatus | undefined; + onSuccess: () => void; + showDebugActions: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + + {isOpen && ( + + + + )} + ); } @@ -326,6 +370,7 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { ? (rawStatus as WorkflowRunStatus | 'all') : undefined; const workflowNameFilter = searchParams.get('workflow') as string | 'all'; + const showDebugActions = searchParams.get('debug') === '1'; const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); const [lastRefreshTime, setLastRefreshTime] = useState( () => new Date() @@ -485,24 +530,13 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { )} - - - - - - + ))} From dd85ccde19e787685b4500717257590481bcf7e2 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Tue, 9 Dec 2025 14:43:12 -0800 Subject: [PATCH 29/31] Wording --- packages/web/src/components/run-actions.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/src/components/run-actions.tsx b/packages/web/src/components/run-actions.tsx index 292cefcbd..210a13506 100644 --- a/packages/web/src/components/run-actions.tsx +++ b/packages/web/src/components/run-actions.tsx @@ -194,8 +194,8 @@ function ReenqueueTooltipContent({ isStuck }: { isStuck: boolean }) { if (isStuck) { return ( <> - This workflow appears to be stuck. Re-enqueue the workflow orchestration - layer to resume execution. + This workflow has no active steps or sleep calls, it maybe be stuck. + Re-enqueue the workflow orchestration layer to resume execution. ); } From 4b87602f090d275164406a4c72165c01fc2a8555 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Tue, 9 Dec 2025 15:03:36 -0800 Subject: [PATCH 30/31] UX improvements --- .../display-utils/cancel-button.tsx | 50 ---------- packages/web/src/components/run-actions.tsx | 95 +++++++++++-------- 2 files changed, 57 insertions(+), 88 deletions(-) delete mode 100644 packages/web/src/components/display-utils/cancel-button.tsx diff --git a/packages/web/src/components/display-utils/cancel-button.tsx b/packages/web/src/components/display-utils/cancel-button.tsx deleted file mode 100644 index 349b6bbe5..000000000 --- a/packages/web/src/components/display-utils/cancel-button.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { XCircle } from 'lucide-react'; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { Button } from '../ui/button'; - -interface CancelButtonProps { - canCancel: boolean; - cancelling: boolean; - cancelDisabledReason: string | null; - onCancel: () => void; -} - -export function CancelButton({ - canCancel, - cancelling, - cancelDisabledReason, - onCancel, -}: CancelButtonProps) { - return ( - - - - - - - - {cancelDisabledReason ? ( -

{cancelDisabledReason}

- ) : ( -

- This will set the run state to "cancelled", prevent further steps - from being scheduled, and disable active hooks. Active steps will - continue to run until they complete. -

- )} -
-
- ); -} diff --git a/packages/web/src/components/run-actions.tsx b/packages/web/src/components/run-actions.tsx index 210a13506..416bba986 100644 --- a/packages/web/src/components/run-actions.tsx +++ b/packages/web/src/components/run-actions.tsx @@ -141,9 +141,10 @@ function useRunActions({ const handleCancel = useCallback(async () => { if (cancelling) return; - if (runStatus !== 'pending') { + const isRunActive = runStatus === 'pending' || runStatus === 'running'; + if (!isRunActive) { toast.error('Cannot cancel', { - description: 'Only pending runs can be cancelled', + description: 'Only active runs can be cancelled', }); return; } @@ -262,6 +263,12 @@ export function RunActionsDropdownItems({ handleCancel(); }; + const isRunActive = runStatus === 'pending' || runStatus === 'running'; + + // Determine which button to show: Wake up, Re-enqueue, or disabled Wake up + const showReenqueue = + !eventsLoading && (showDebugActions || showReenqueueForStuckWorkflow); + return ( <> @@ -269,46 +276,53 @@ export function RunActionsDropdownItems({ {rerunning ? 'Replaying...' : 'Replay Run'} - {/* Wake up button - only shown when there are pending sleeps */} + {/* Wake up / Re-enqueue button - mutually exclusive */} {eventsLoading ? ( + // Loading state: show Wake up button with spinner - Loading... + Wake up - ) : hasPendingSleeps ? ( + ) : showReenqueue ? ( + // Re-enqueue: shown when debug flag or stuck workflow detected - + - {wakingUp ? 'Waking up...' : 'Wake up'} + {reenqueuing ? 'Re-enqueuing...' : 'Re-enqueue'} - + - ) : null} - - {/* Re-enqueue button - shown with debug=1 param OR when workflow appears stuck */} - {(showDebugActions || showReenqueueForStuckWorkflow) && ( + ) : ( + // Wake up: enabled if pending sleeps, disabled otherwise - + - {reenqueuing ? 'Re-enqueuing...' : 'Re-enqueue'} + {wakingUp ? 'Waking up...' : 'Wake up'} - + {hasPendingSleeps ? ( + + ) : ( + <>No pending sleep calls to interrupt. + )} )} {cancelling ? 'Cancelling...' : 'Cancel'} @@ -391,6 +405,10 @@ export function RunActionsButtons({ ? 'Run has already been cancelled' : ''; + // Determine which button to show: Wake up, Re-enqueue, or disabled Wake up + const showReenqueue = + !eventsLoading && (showDebugActions || showReenqueueForStuckWorkflow); + return ( <> {/* Rerun Button */} @@ -421,62 +439,63 @@ export function RunActionsButtons({ - {/* Wake up Button - only shown when there are pending sleeps */} + {/* Wake up / Re-enqueue Button - mutually exclusive */} {eventsLoading ? ( + // Loading state: show Wake up button with spinner - ) : hasPendingSleeps ? ( + ) : showReenqueue ? ( + // Re-enqueue: shown when debug flag or stuck workflow detected - {wakeUpDisabledReason ? ( -

{wakeUpDisabledReason}

+ {reenqueueDisabledReason ? ( +

{reenqueueDisabledReason}

) : (

- +

)}
- ) : null} - - {/* Re-enqueue Button - shown with debug=1 param OR when workflow appears stuck */} - {(showDebugActions || showReenqueueForStuckWorkflow) && ( + ) : ( + // Wake up: enabled if pending sleeps, disabled otherwise - {reenqueueDisabledReason ? ( -

{reenqueueDisabledReason}

+ {wakeUpDisabledReason ? ( +

{wakeUpDisabledReason}

) : (

- +

)}
From 60099e8296854d822810ba2a6dab1613fe5574a9 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Tue, 9 Dec 2025 17:07:07 -0800 Subject: [PATCH 31/31] Fix data staleness --- packages/web/src/components/run-actions.tsx | 52 --------------------- packages/web/src/components/runs-table.tsx | 39 ++++++++-------- 2 files changed, 20 insertions(+), 71 deletions(-) diff --git a/packages/web/src/components/run-actions.tsx b/packages/web/src/components/run-actions.tsx index 416bba986..2a2afebae 100644 --- a/packages/web/src/components/run-actions.tsx +++ b/packages/web/src/components/run-actions.tsx @@ -529,58 +529,6 @@ export function RunActionsButtons({ ); } -// ============================================================================ -// Lazy-loading wrapper for dropdown (fetches events when dropdown opens) -// ============================================================================ - -interface LazyRunActionsDropdownContentProps - extends RunActionsDropdownItemsProps { - fetchEvents: () => Promise; -} - -export function LazyRunActionsDropdownContent({ - fetchEvents, - ...props -}: LazyRunActionsDropdownContentProps) { - const [events, setEvents] = useState(undefined); - const [eventsLoading, setEventsLoading] = useState(true); - - useEffect(() => { - let cancelled = false; - setEventsLoading(true); - - fetchEvents() - .then((result) => { - if (!cancelled) { - setEvents(result); - } - }) - .catch((err) => { - console.error('Failed to fetch events:', err); - if (!cancelled) { - setEvents(undefined); - } - }) - .finally(() => { - if (!cancelled) { - setEventsLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [fetchEvents]); - - return ( - - ); -} - // ============================================================================ // Hook for lazy loading events (alternative approach) // ============================================================================ diff --git a/packages/web/src/components/runs-table.tsx b/packages/web/src/components/runs-table.tsx index 06e00b997..ea4614fdd 100644 --- a/packages/web/src/components/runs-table.tsx +++ b/packages/web/src/components/runs-table.tsx @@ -7,8 +7,8 @@ import { getErrorMessage, useWorkflowRuns, } from '@workflow/web-shared'; -import { fetchEvents } from '@workflow/web-shared/server'; -import type { WorkflowRunStatus } from '@workflow/world'; +import { fetchEvents, fetchRun } from '@workflow/web-shared/server'; +import type { WorkflowRun, WorkflowRunStatus } from '@workflow/world'; import { AlertCircle, ArrowDownAZ, @@ -72,39 +72,40 @@ function RunActionsDropdownContentInner({ showDebugActions: boolean; }) { const [events, setEvents] = useState(undefined); - const [eventsLoading, setEventsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [run, setRun] = useState(undefined); + const status = run?.status || runStatus; useEffect(() => { - let cancelled = false; - setEventsLoading(true); + setIsLoading(true); - fetchEvents(env, runId, { limit: 1000 }) - .then((result: Awaited>) => { - if (!cancelled && result.success) { - setEvents(result.data.data); + Promise.all([ + fetchRun(env, runId, 'none'), + fetchEvents(env, runId, { limit: 1000, sortOrder: 'desc' }), + ]) + .then(([runResult, eventsResult]) => { + if (runResult.success) { + setRun(runResult.data); + } + if (eventsResult.success) { + setEvents(eventsResult.data.data); } }) .catch((err: unknown) => { - console.error('Failed to fetch events:', err); + console.error('Failed to fetch run or events:', err); }) .finally(() => { - if (!cancelled) { - setEventsLoading(false); - } + setIsLoading(false); }); - - return () => { - cancelled = true; - }; }, [env, runId]); return (