From fb2da565cb1b4b51926f23eee4436f32216ae18b Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 13 Oct 2026 16:20:16 -0700 Subject: [PATCH] [web] [web-shared] Recommend QuickJS snapshotting on runs that hit the event limit or replay timeout --- .changeset/run-limit-guidance-core.md | 5 + .changeset/run-limit-guidance-web-shared.md | 5 + .changeset/run-limit-guidance-web.md | 5 + .../core/src/serialization-format.test.ts | 31 +++++ packages/core/src/serialization-format.ts | 26 ++-- .../components/sidebar/attribute-panel.tsx | 11 +- packages/web-shared/src/index.ts | 7 ++ .../src/lib/run-limit-guidance.test.ts | 90 ++++++++++++++ .../web-shared/src/lib/run-limit-guidance.ts | 111 ++++++++++++++++++ .../web/app/components/run-detail-view.tsx | 2 + .../run-limit-guidance-alert.test.tsx | 44 +++++++ .../components/run-limit-guidance-alert.tsx | 46 ++++++++ packages/web/app/components/ui/alert.tsx | 2 + 13 files changed, 375 insertions(+), 10 deletions(-) create mode 100644 .changeset/run-limit-guidance-core.md create mode 100644 .changeset/run-limit-guidance-web-shared.md create mode 100644 .changeset/run-limit-guidance-web.md create mode 100644 packages/web-shared/src/lib/run-limit-guidance.test.ts create mode 100644 packages/web-shared/src/lib/run-limit-guidance.ts create mode 100644 packages/web/app/components/run-limit-guidance-alert.test.tsx create mode 100644 packages/web/app/components/run-limit-guidance-alert.tsx diff --git a/.changeset/run-limit-guidance-core.md b/.changeset/run-limit-guidance-core.md new file mode 100644 index 0000000000..b84b2a4228 --- /dev/null +++ b/.changeset/run-limit-guidance-core.md @@ -0,0 +1,5 @@ +--- +"@workflow/core": patch +--- + +Preserve `workflowVm` and `snapshotThreshold` from `executionContext` in `hydrateResourceIO` for observability UIs diff --git a/.changeset/run-limit-guidance-web-shared.md b/.changeset/run-limit-guidance-web-shared.md new file mode 100644 index 0000000000..3218838aed --- /dev/null +++ b/.changeset/run-limit-guidance-web-shared.md @@ -0,0 +1,5 @@ +--- +"@workflow/web-shared": patch +--- + +Add `getRunLimitGuidance()` for runs that failed with `MAX_EVENTS_EXCEEDED` or `REPLAY_TIMEOUT`, and show the run's workflow VM in the sidebar diff --git a/.changeset/run-limit-guidance-web.md b/.changeset/run-limit-guidance-web.md new file mode 100644 index 0000000000..1a15ce97a3 --- /dev/null +++ b/.changeset/run-limit-guidance-web.md @@ -0,0 +1,5 @@ +--- +"@workflow/web": patch +--- + +Recommend the QuickJS VM with snapshotting on runs that failed with `MAX_EVENTS_EXCEEDED` or `REPLAY_TIMEOUT` diff --git a/packages/core/src/serialization-format.test.ts b/packages/core/src/serialization-format.test.ts index a0591d5daf..131390e6f1 100644 --- a/packages/core/src/serialization-format.test.ts +++ b/packages/core/src/serialization-format.test.ts @@ -213,6 +213,37 @@ describe('hydrateResourceIO', () => { expect(hydrated.dynamicWorkflowCode).toBe(code); }); + it('strips executionContext but keeps display-safe fields', () => { + const hydrated = hydrateResourceIO( + { + runId: 'wrun_ctx', + executionContext: { + workflowCoreVersion: '5.0.0', + workflowVm: 'quickjs', + snapshotThreshold: 1000, + traceCarrier: { traceparent: 'x' }, + }, + }, + testRevivers + ) as Record; + expect('executionContext' in hydrated).toBe(false); + expect(hydrated.workflowCoreVersion).toBe('5.0.0'); + expect(hydrated.workflowVm).toBe('quickjs'); + expect(hydrated.snapshotThreshold).toBe(1000); + expect('traceCarrier' in hydrated).toBe(false); + }); + + it('does not add VM fields when executionContext lacks them', () => { + const hydrated = hydrateResourceIO( + { runId: 'wrun_ctx', executionContext: { traceCarrier: {} } }, + testRevivers + ) as Record; + expect('executionContext' in hydrated).toBe(false); + expect('workflowCoreVersion' in hydrated).toBe(false); + expect('workflowVm' in hydrated).toBe(false); + expect('snapshotThreshold' in hydrated).toBe(false); + }); + it('should not add dynamicWorkflowCode to a static run', () => { const hydrated = hydrateResourceIO( { runId: 'wrun_static', input: makeDevlPayload(['arg']) }, diff --git a/packages/core/src/serialization-format.ts b/packages/core/src/serialization-format.ts index 158a1b72d2..11bc9dc4c9 100644 --- a/packages/core/src/serialization-format.ts +++ b/packages/core/src/serialization-format.ts @@ -868,19 +868,27 @@ export function hydrateResourceIO< hydrated = hydrateWorkflowIO(resource, revivers); } - // Strip executionContext, preserving only workflowCoreVersion for display + // Strip executionContext, preserving only display-safe fields: + // workflowCoreVersion, and the VM engine + snapshot policy (which UIs use + // to tell whether a run was eligible for the QuickJS event limit + // exemption). if ('executionContext' in hydrated) { const { executionContext, ...rest } = hydrated; - const workflowCoreVersion = - executionContext && - typeof executionContext === 'object' && - 'workflowCoreVersion' in executionContext - ? executionContext.workflowCoreVersion + const ctx = + executionContext && typeof executionContext === 'object' + ? (executionContext as Record) : undefined; - if (workflowCoreVersion) { - return { ...rest, workflowCoreVersion } as unknown as T; + const preserved: Record = {}; + if (ctx?.workflowCoreVersion) { + preserved.workflowCoreVersion = ctx.workflowCoreVersion; } - return rest as T; + if (typeof ctx?.workflowVm === 'string') { + preserved.workflowVm = ctx.workflowVm; + } + if (typeof ctx?.snapshotThreshold === 'number') { + preserved.snapshotThreshold = ctx.snapshotThreshold; + } + return { ...rest, ...preserved } as unknown as T; } return hydrated; diff --git a/packages/web-shared/src/components/sidebar/attribute-panel.tsx b/packages/web-shared/src/components/sidebar/attribute-panel.tsx index de0ebbf7f4..c5860f4f62 100644 --- a/packages/web-shared/src/components/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/components/sidebar/attribute-panel.tsx @@ -239,6 +239,7 @@ type AttributeKey = | 'resumeAt' | 'expiredAt' | 'workflowCoreVersion' + | 'workflowVm' | 'receivedCount' | 'lastReceivedAt' | 'disposedAt' @@ -277,6 +278,7 @@ const attributeOrder: AttributeKey[] = [ 'computeInstanceId', 'specVersion', 'workflowCoreVersion', + 'workflowVm', 'ownerId', 'projectId', 'environment', @@ -335,6 +337,7 @@ const attributeDisplayNames: Partial> = { computeInstanceId: 'Compute Instance ID', specVersion: 'Spec Version', workflowCoreVersion: '@workflow/core version', + workflowVm: 'Workflow VM', occurredAt: 'Occurred', createdAt: 'Created', startedAt: 'Started', @@ -471,6 +474,9 @@ const attributeToDisplayFn: Record< computeInstanceId: opaqueIdOrNull, specVersion: (value: unknown) => String(value), workflowCoreVersion: (value: unknown) => String(value), + // Only present when the run's executionContext recorded an engine. + workflowVm: (value: unknown) => + typeof value === 'string' && value.length > 0 ? value : null, // Tenancy (we don't show these) ownerId: (_value: unknown) => null, projectId: (_value: unknown) => null, @@ -905,7 +911,7 @@ export const AttributePanel = ({ /** Resource type of the selected span, used to show targeted loading skeletons. */ resource?: string; }) => { - // Extract workflowCoreVersion from executionContext for display + // Extract workflowCoreVersion / workflowVm from executionContext for display const displayData = useMemo(() => { const result = { ...data }; const execCtx = data.executionContext as @@ -914,6 +920,9 @@ export const AttributePanel = ({ if (execCtx?.workflowCoreVersion) { result.workflowCoreVersion = execCtx.workflowCoreVersion; } + if (typeof execCtx?.workflowVm === 'string') { + result.workflowVm = execCtx.workflowVm; + } if (moduleSpecifier) { result.moduleSpecifier = moduleSpecifier; } else if (typeof data.stepName === 'string') { diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 65d71ba4e7..41d1a699e5 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -59,6 +59,13 @@ export { STREAM_REF_TYPE, truncateId, } from './lib/hydration'; +export { + getRunLimitGuidance, + isQuickJSSnapshotRun, + type RunLimitGuidance, + type RunLimitGuidanceInput, + type RunLimitGuidanceKind, +} from './lib/run-limit-guidance'; export { isSealedNoopEvent, SEALED_EVENT_MESSAGE, diff --git a/packages/web-shared/src/lib/run-limit-guidance.test.ts b/packages/web-shared/src/lib/run-limit-guidance.test.ts new file mode 100644 index 0000000000..2ba2f9728b --- /dev/null +++ b/packages/web-shared/src/lib/run-limit-guidance.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { + getRunLimitGuidance, + isQuickJSSnapshotRun, +} from './run-limit-guidance'; + +const failed = (errorCode: string, extra: Record = {}) => ({ + status: 'failed', + errorCode, + ...extra, +}); + +describe('getRunLimitGuidance', () => { + it('returns null for missing, non-failed, and unrelated runs', () => { + expect(getRunLimitGuidance(undefined)).toBeNull(); + expect(getRunLimitGuidance(null)).toBeNull(); + expect( + getRunLimitGuidance({ + status: 'running', + errorCode: 'MAX_EVENTS_EXCEEDED', + }) + ).toBeNull(); + expect(getRunLimitGuidance(failed('USER_ERROR'))).toBeNull(); + expect(getRunLimitGuidance({ status: 'failed' })).toBeNull(); + }); + + it('recommends WORKFLOW_VM=quickjs for node runs that hit the event limit', () => { + const guidance = getRunLimitGuidance(failed('MAX_EVENTS_EXCEEDED')); + expect(guidance).toMatchObject({ + kind: 'max-events-exceeded', + envVar: 'WORKFLOW_VM=quickjs', + docsUrl: 'https://workflow-sdk.dev/err/max-events-exceeded', + }); + expect( + getRunLimitGuidance(failed('MAX_EVENTS_EXCEEDED', { workflowVm: 'node' })) + ?.envVar + ).toBe('WORKFLOW_VM=quickjs'); + }); + + it('recommends QuickJS for replay timeouts', () => { + expect(getRunLimitGuidance(failed('REPLAY_TIMEOUT'))).toMatchObject({ + kind: 'replay-timeout', + envVar: 'WORKFLOW_VM=quickjs', + docsUrl: 'https://workflow-sdk.dev/err/replay-timeout', + }); + }); + + it('suggests a snapshot threshold for QuickJS runs without one', () => { + for (const extra of [ + { workflowVm: 'quickjs' }, + { workflowVm: 'quickjs', snapshotThreshold: 0 }, + ]) { + const guidance = getRunLimitGuidance( + failed('MAX_EVENTS_EXCEEDED', extra) + ); + expect(guidance?.envVar).toBe('WORKFLOW_SNAPSHOT_THRESHOLD=1000'); + expect(guidance?.description).toContain('QuickJS'); + } + }); + + it('returns null for runs already on QuickJS with snapshotting', () => { + const hydrated = { workflowVm: 'quickjs', snapshotThreshold: 1000 }; + expect( + getRunLimitGuidance(failed('MAX_EVENTS_EXCEEDED', hydrated)) + ).toBeNull(); + expect(getRunLimitGuidance(failed('REPLAY_TIMEOUT', hydrated))).toBeNull(); + // Raw (unhydrated) runs carry the policy in executionContext. + expect( + getRunLimitGuidance( + failed('MAX_EVENTS_EXCEEDED', { executionContext: hydrated }) + ) + ).toBeNull(); + }); +}); + +describe('isQuickJSSnapshotRun', () => { + it('requires the QuickJS VM and a positive safe-integer threshold', () => { + expect( + isQuickJSSnapshotRun({ workflowVm: 'quickjs', snapshotThreshold: 1 }) + ).toBe(true); + expect(isQuickJSSnapshotRun({ workflowVm: 'quickjs' })).toBe(false); + expect( + isQuickJSSnapshotRun({ workflowVm: 'quickjs', snapshotThreshold: 1.5 }) + ).toBe(false); + expect( + isQuickJSSnapshotRun({ workflowVm: 'node', snapshotThreshold: 1000 }) + ).toBe(false); + expect(isQuickJSSnapshotRun({})).toBe(false); + }); +}); diff --git a/packages/web-shared/src/lib/run-limit-guidance.ts b/packages/web-shared/src/lib/run-limit-guidance.ts new file mode 100644 index 0000000000..2b9531f294 --- /dev/null +++ b/packages/web-shared/src/lib/run-limit-guidance.ts @@ -0,0 +1,111 @@ +/** + * Guidance for runs that failed because they outgrew the default per-run + * event limit or replay budget. Pure (no React) so every observability UI + * (the self-hosted web UI and the Vercel dashboard) can share the decision + * and the copy while rendering it with its own components. + */ + +/** Error codes this guidance applies to (`RUN_ERROR_CODES` values). */ +const MAX_EVENTS_EXCEEDED = 'MAX_EVENTS_EXCEEDED'; +const REPLAY_TIMEOUT = 'REPLAY_TIMEOUT'; + +const DOCS_BASE_URL = 'https://workflow-sdk.dev/err'; + +export type RunLimitGuidanceKind = 'max-events-exceeded' | 'replay-timeout'; + +export interface RunLimitGuidance { + kind: RunLimitGuidanceKind; + /** Short heading, e.g. for an alert title. */ + title: string; + /** One or two sentences explaining what to do. Plain text. */ + description: string; + /** The env var assignment to suggest, for rendering as code. */ + envVar: string; + /** Docs page for the error code. */ + docsUrl: string; +} + +/** + * The run fields this guidance reads. Accepts both a hydrated run (where + * `hydrateResourceIO` lifts `workflowVm` / `snapshotThreshold` out of the + * stripped `executionContext`) and a raw run (with `executionContext`). + */ +export interface RunLimitGuidanceInput { + status?: string; + errorCode?: string | null; + workflowVm?: unknown; + snapshotThreshold?: unknown; + executionContext?: unknown; +} + +function readVmPolicy(run: RunLimitGuidanceInput): { + workflowVm: unknown; + snapshotThreshold: unknown; +} { + const ctx = + run.executionContext && typeof run.executionContext === 'object' + ? (run.executionContext as Record) + : undefined; + return { + workflowVm: run.workflowVm ?? ctx?.workflowVm, + snapshotThreshold: run.snapshotThreshold ?? ctx?.snapshotThreshold, + }; +} + +/** + * Whether the run was started on the QuickJS VM with snapshotting enabled, + * which exempts it from the default per-run event limit. Mirrors the World + * policy (`isEventLimitExemptRun` in `@workflow/world`). + */ +export function isQuickJSSnapshotRun(run: RunLimitGuidanceInput): boolean { + const { workflowVm, snapshotThreshold } = readVmPolicy(run); + return ( + workflowVm === 'quickjs' && + typeof snapshotThreshold === 'number' && + Number.isSafeInteger(snapshotThreshold) && + snapshotThreshold > 0 + ); +} + +/** + * Returns guidance to show for a failed run whose error code is + * `MAX_EVENTS_EXCEEDED` or `REPLAY_TIMEOUT` and that wasn't already running + * on the QuickJS VM with snapshotting. Returns `null` otherwise. + */ +export function getRunLimitGuidance( + run: RunLimitGuidanceInput | null | undefined +): RunLimitGuidance | null { + if (!run || run.status !== 'failed') return null; + const code = run.errorCode; + if (code !== MAX_EVENTS_EXCEEDED && code !== REPLAY_TIMEOUT) return null; + // Already on the recommended configuration; switching wouldn't help. + if (isQuickJSSnapshotRun(run)) return null; + + const { workflowVm } = readVmPolicy(run); + const onQuickJS = workflowVm === 'quickjs'; + + const remediation = onQuickJS + ? "This run used the QuickJS VM, but a snapshot threshold wasn't recorded when it started (snapshotting was disabled, or the run was started by an older SDK), so the default event limit applied. Set a positive WORKFLOW_SNAPSHOT_THRESHOLD in the environment that starts runs, or upgrade the workflow package, so new runs are exempt." + : 'Run long-running workflows on the QuickJS VM with snapshotting. Snapshotted runs resume without replaying the full event log and are exempt from the default per-run event limit. Set this in the environment that starts runs; only new runs are affected.'; + + if (code === MAX_EVENTS_EXCEEDED) { + return { + kind: 'max-events-exceeded', + title: 'This run reached the per-run event limit', + description: remediation, + envVar: onQuickJS + ? 'WORKFLOW_SNAPSHOT_THRESHOLD=1000' + : 'WORKFLOW_VM=quickjs', + docsUrl: `${DOCS_BASE_URL}/max-events-exceeded`, + }; + } + return { + kind: 'replay-timeout', + title: 'This run exceeded the workflow replay time limit', + description: `Replay time grows with the size of the event log. ${remediation}`, + envVar: onQuickJS + ? 'WORKFLOW_SNAPSHOT_THRESHOLD=1000' + : 'WORKFLOW_VM=quickjs', + docsUrl: `${DOCS_BASE_URL}/replay-timeout`, + }; +} diff --git a/packages/web/app/components/run-detail-view.tsx b/packages/web/app/components/run-detail-view.tsx index f05b4f2b5d..075d505894 100644 --- a/packages/web/app/components/run-detail-view.tsx +++ b/packages/web/app/components/run-detail-view.tsx @@ -26,6 +26,7 @@ import { import { useCallback, useMemo, useRef, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router'; import { toast } from 'sonner'; +import { RunLimitGuidanceAlert } from '~/components/run-limit-guidance-alert'; import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'; import { AlertDialog, @@ -722,6 +723,7 @@ export function RunDetailView({ )} +
diff --git a/packages/web/app/components/run-limit-guidance-alert.test.tsx b/packages/web/app/components/run-limit-guidance-alert.test.tsx new file mode 100644 index 0000000000..34c5bd6709 --- /dev/null +++ b/packages/web/app/components/run-limit-guidance-alert.test.tsx @@ -0,0 +1,44 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { RunLimitGuidanceAlert } from './run-limit-guidance-alert'; + +afterEach(cleanup); + +describe('RunLimitGuidanceAlert', () => { + it('recommends QuickJS for a node run that hit the event limit', () => { + render( + + ); + expect(screen.getByRole('alert').textContent).toContain( + 'WORKFLOW_VM=quickjs' + ); + expect( + screen.getByRole('link', { name: 'Learn more' }).getAttribute('href') + ).toBe('https://workflow-sdk.dev/err/max-events-exceeded'); + }); + + it('renders nothing for runs already on QuickJS with snapshotting', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders nothing for other failures', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); +}); diff --git a/packages/web/app/components/run-limit-guidance-alert.tsx b/packages/web/app/components/run-limit-guidance-alert.tsx new file mode 100644 index 0000000000..373b8c7b24 --- /dev/null +++ b/packages/web/app/components/run-limit-guidance-alert.tsx @@ -0,0 +1,46 @@ +import { + getRunLimitGuidance, + type RunLimitGuidanceInput, +} from '@workflow/web-shared'; +import { AlertTriangle } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'; + +/** + * Banner for runs that failed with MAX_EVENTS_EXCEEDED or REPLAY_TIMEOUT + * and weren't already on the QuickJS VM with snapshotting. Renders nothing + * otherwise. The decision and copy live in `@workflow/web-shared` so the + * Vercel dashboard shows the same guidance. + */ +export function RunLimitGuidanceAlert({ + run, +}: { + run: RunLimitGuidanceInput | null | undefined; +}) { + const guidance = getRunLimitGuidance(run); + if (!guidance) return null; + + return ( + + + {guidance.title} + +

{guidance.description}

+

+ Set{' '} + + {guidance.envVar} + {' '} + and start the workflow again.{' '} + + Learn more + +

+
+
+ ); +} diff --git a/packages/web/app/components/ui/alert.tsx b/packages/web/app/components/ui/alert.tsx index 7a236eb217..2f1f8e35d6 100644 --- a/packages/web/app/components/ui/alert.tsx +++ b/packages/web/app/components/ui/alert.tsx @@ -11,6 +11,8 @@ const alertVariants = cva( default: 'bg-background text-foreground', destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + warning: + 'border-[var(--ds-amber-400)] bg-[var(--ds-amber-100)] text-[var(--ds-amber-900)] [&>svg]:text-[var(--ds-amber-900)]', }, }, defaultVariants: {