Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/run-limit-guidance-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Preserve `workflowVm` and `snapshotThreshold` from `executionContext` in `hydrateResourceIO` for observability UIs
5 changes: 5 additions & 0 deletions .changeset/run-limit-guidance-web-shared.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/run-limit-guidance-web.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/web": patch
---

Recommend the QuickJS VM with snapshotting on runs that failed with `MAX_EVENTS_EXCEEDED` or `REPLAY_TIMEOUT`
31 changes: 31 additions & 0 deletions packages/core/src/serialization-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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']) },
Expand Down
26 changes: 17 additions & 9 deletions packages/core/src/serialization-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)
: undefined;
if (workflowCoreVersion) {
return { ...rest, workflowCoreVersion } as unknown as T;
const preserved: Record<string, unknown> = {};
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;
Expand Down
11 changes: 10 additions & 1 deletion packages/web-shared/src/components/sidebar/attribute-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ type AttributeKey =
| 'resumeAt'
| 'expiredAt'
| 'workflowCoreVersion'
| 'workflowVm'
| 'receivedCount'
| 'lastReceivedAt'
| 'disposedAt'
Expand Down Expand Up @@ -277,6 +278,7 @@ const attributeOrder: AttributeKey[] = [
'computeInstanceId',
'specVersion',
'workflowCoreVersion',
'workflowVm',
'ownerId',
'projectId',
'environment',
Expand Down Expand Up @@ -335,6 +337,7 @@ const attributeDisplayNames: Partial<Record<AttributeKey, string>> = {
computeInstanceId: 'Compute Instance ID',
specVersion: 'Spec Version',
workflowCoreVersion: '@workflow/core version',
workflowVm: 'Workflow VM',
occurredAt: 'Occurred',
createdAt: 'Created',
startedAt: 'Started',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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') {
Expand Down
7 changes: 7 additions & 0 deletions packages/web-shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
90 changes: 90 additions & 0 deletions packages/web-shared/src/lib/run-limit-guidance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import {
getRunLimitGuidance,
isQuickJSSnapshotRun,
} from './run-limit-guidance';

const failed = (errorCode: string, extra: Record<string, unknown> = {}) => ({
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);
});
});
111 changes: 111 additions & 0 deletions packages/web-shared/src/lib/run-limit-guidance.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)
: 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`,
};
}
2 changes: 2 additions & 0 deletions packages/web/app/components/run-detail-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -722,6 +723,7 @@ export function RunDetailView({
)}
</div>
</div>
<RunLimitGuidanceAlert run={runData} />
</div>

<div className="mt-4 flex-1 flex flex-col min-h-0">
Expand Down
Loading
Loading