diff --git a/.changeset/run-cancel-reason.md b/.changeset/run-cancel-reason.md new file mode 100644 index 0000000000..dad71ea97f --- /dev/null +++ b/.changeset/run-cancel-reason.md @@ -0,0 +1,8 @@ +--- +'@workflow/core': patch +'@workflow/world': patch +'@workflow/world-vercel': patch +'@workflow/web-shared': patch +--- + +Add an optional reason to run cancellation (`run.cancel({ cancelReason })`), recorded on the cancellation event and shown in the run detail view. diff --git a/docs/content/docs/v5/api-reference/workflow-api/get-run.mdx b/docs/content/docs/v5/api-reference/workflow-api/get-run.mdx index c03a1a2971..b457421e85 100644 --- a/docs/content/docs/v5/api-reference/workflow-api/get-run.mdx +++ b/docs/content/docs/v5/api-reference/workflow-api/get-run.mdx @@ -170,6 +170,25 @@ const { stoppedCount } = await run.wakeUp({ }); ``` +### Cancel a Run + +Cancel a workflow run. You can pass an optional free-text `cancelReason` (up to 512 characters) that is recorded on the run's cancellation event and shown in the run detail view: + +```typescript lineNumbers +import { getRun } from "workflow/api"; + +export async function POST(req: Request) { + const { runId } = await req.json(); + const run = getRun(runId); + + await run.cancel({ cancelReason: "Superseded by a newer submission" }); // [!code highlight] + + return Response.json({ cancelled: true }); +} +``` + +The options object is optional — `await run.cancel()` cancels the run without recording a reason. + ## Related Functions - [`start()`](/docs/api-reference/workflow-api/start) - Start a new workflow and get its run ID. diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index b75a6ce760..7d0d1d4029 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -102,6 +102,7 @@ export { type WorkflowReadableStreamOptions, } from './runtime/run.js'; export { + type CancelRunOptions, cancelRun, listStreams, type ReadStreamOptions, diff --git a/packages/core/src/runtime/run.ts b/packages/core/src/runtime/run.ts index cc74606881..7ee8b9769e 100644 --- a/packages/core/src/runtime/run.ts +++ b/packages/core/src/runtime/run.ts @@ -19,6 +19,7 @@ import { import { getWorkflowRunStreamId } from '../util.js'; import { getWorldLazy } from './get-world-lazy.js'; import { + type CancelRunOptions, type StopSleepOptions, type StopSleepResult, wakeUpRun, @@ -160,13 +161,20 @@ export class Run { /** * Cancels the workflow run. + * + * @param options - Optional cancellation settings. `cancelReason` records a + * free-text reason (max 512 chars) on the run_cancelled event, surfaced in + * the run detail view. */ - async cancel(): Promise { + async cancel(options?: CancelRunOptions): Promise { 'use step'; const world = await this.#lazyWorldPromise; await world.events.create(this.runId, { eventType: 'run_cancelled', specVersion: SPEC_VERSION_CURRENT, + ...(options?.cancelReason !== undefined + ? { eventData: { cancelReason: options.cancelReason } } + : {}), }); } diff --git a/packages/core/src/runtime/runs.ts b/packages/core/src/runtime/runs.ts index ebc2846929..5c95a65188 100644 --- a/packages/core/src/runtime/runs.ts +++ b/packages/core/src/runtime/runs.ts @@ -37,6 +37,14 @@ export interface StopSleepOptions { correlationIds?: string[]; } +export interface CancelRunOptions { + /** + * Optional free-text reason for the cancellation (max 512 chars), recorded + * on the run_cancelled event and surfaced in the run detail view. + */ + cancelReason?: string; +} + const normalizeWorkflowArgs = (args: unknown): unknown[] => { return Array.isArray(args) ? args : [args]; }; @@ -85,17 +93,27 @@ export async function recreateRunFromExisting( /** * Cancel a workflow run. + * + * @param options - Optional cancellation settings. `cancelReason` records a + * free-text reason (max 512 chars) on the run_cancelled event. */ -export async function cancelRun(world: World, runId: string): Promise { +export async function cancelRun( + world: World, + runId: string, + options?: CancelRunOptions +): Promise { try { const run = await world.runs.get(runId, { resolveData: 'none' }); const specVersion = run.specVersion ?? SPEC_VERSION_LEGACY; const compatMode = isLegacySpecVersion(specVersion); - const eventData = { + const eventRequest = { eventType: 'run_cancelled' as const, specVersion, + ...(options?.cancelReason !== undefined + ? { eventData: { cancelReason: options.cancelReason } } + : {}), }; - await world.events.create(runId, eventData, { v1Compat: compatMode }); + await world.events.create(runId, eventRequest, { v1Compat: compatMode }); } catch (err) { throw new Error( `Failed to cancel run ${runId}: ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index b3840d3457..e5404487e5 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -628,6 +628,27 @@ function PayloadBlock({ return ; } + // Cancellation reason — render the free-text reason as a readable line + // instead of a raw JSON payload (the only field run_cancelled carries). + if (eventType === 'run_cancelled') { + const cancelReason = + cleaned != null && + typeof cleaned === 'object' && + typeof (cleaned as Record).cancelReason === 'string' + ? ((cleaned as Record).cancelReason as string) + : null; + if (cancelReason) { + return ( +
+ Reason: + + {cancelReason} + +
+ ); + } + } + return (
; @@ -226,6 +229,7 @@ function buildPostFrameMeta( meta.hookIsWebhook = input.hookIsWebhook; if (input.hookIsSystem !== undefined) meta.hookIsSystem = input.hookIsSystem; if (input.errorCode !== undefined) meta.errorCode = input.errorCode; + if (input.cancelReason !== undefined) meta.cancelReason = input.cancelReason; if (input.executionContext !== undefined) { meta.executionContext = input.executionContext; } diff --git a/packages/world-vercel/src/events.test.ts b/packages/world-vercel/src/events.test.ts index c1e3c66c1b..cde58c8044 100644 --- a/packages/world-vercel/src/events.test.ts +++ b/packages/world-vercel/src/events.test.ts @@ -226,6 +226,27 @@ describe('splitEventDataForV4 attribute fields', () => { expect(started.meta.input).toBeUndefined(); }); + it('carries the run_cancelled cancelReason in the frame meta, not the payload', () => { + const { payload, meta } = splitEventDataForV4({ + eventType: 'run_cancelled', + specVersion: 4, + eventData: { cancelReason: 'superseded by newer run' }, + } as AnyEventRequest); + + expect(payload).toBeUndefined(); + expect(meta.cancelReason).toBe('superseded by newer run'); + }); + + it('omits cancelReason from meta when run_cancelled carries no reason', () => { + const { payload, meta } = splitEventDataForV4({ + eventType: 'run_cancelled', + specVersion: 4, + } as AnyEventRequest); + + expect(payload).toBeUndefined(); + expect(meta.cancelReason).toBeUndefined(); + }); + it('carries latency telemetry (ttfs/stso/optimizations) in the frame meta on step terminal events', () => { const completed = splitEventDataForV4({ eventType: 'step_completed', diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index c98348191c..7021e2f05f 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -180,6 +180,7 @@ interface SplitEventData { hookIsWebhook?: boolean; hookIsSystem?: boolean; errorCode?: string; + cancelReason?: string; /** Structured executionContext, included verbatim in frame meta. */ executionContext?: Record; /** Initial run attributes (run_created / resilient-start run_started). */ @@ -219,6 +220,7 @@ type MetaSourceField = | 'isWebhook' | 'isSystem' | 'errorCode' + | 'cancelReason' | 'executionContext' | 'attributes' | 'changes' @@ -317,6 +319,11 @@ export function splitEventDataForV4(data: AnyEventRequest): SplitEventData { if (typeof eventData.errorCode === 'string') { meta.errorCode = eventData.errorCode; } + // run_cancelled optionally carries a free-text cancellation reason. Small + // plaintext metadata, so it rides in the frame meta like errorCode. + if (typeof eventData.cancelReason === 'string') { + meta.cancelReason = eventData.cancelReason; + } if ( eventData.executionContext !== undefined && eventData.executionContext !== null && diff --git a/packages/world/src/events.test.ts b/packages/world/src/events.test.ts new file mode 100644 index 0000000000..34d661b20d --- /dev/null +++ b/packages/world/src/events.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { CreateEventSchema, EventSchema } from './events'; + +describe('run_cancelled cancelReason', () => { + it('accepts a run_cancelled create request with no eventData', () => { + const parsed = CreateEventSchema.parse({ + eventType: 'run_cancelled', + specVersion: 4, + }); + expect(parsed.eventType).toBe('run_cancelled'); + }); + + it('accepts an optional cancelReason on the create request', () => { + const parsed = CreateEventSchema.parse({ + eventType: 'run_cancelled', + specVersion: 4, + eventData: { cancelReason: 'superseded by newer run' }, + }); + expect(parsed.eventType).toBe('run_cancelled'); + // eventData is only present on the run_cancelled branch of the union. + expect( + (parsed as { eventData?: { cancelReason?: string } }).eventData + ?.cancelReason + ).toBe('superseded by newer run'); + }); + + it('rejects a cancelReason longer than 512 chars', () => { + const result = CreateEventSchema.safeParse({ + eventType: 'run_cancelled', + specVersion: 4, + eventData: { cancelReason: 'x'.repeat(513) }, + }); + expect(result.success).toBe(false); + }); + + it('retains cancelReason when reading back a stored run_cancelled event (not stripped)', () => { + const parsed = EventSchema.parse({ + eventType: 'run_cancelled', + runId: 'wrun_00000000000000000000000000', + eventId: 'evnt_00000000000000000000000000', + createdAt: new Date().toISOString(), + specVersion: 4, + eventData: { cancelReason: 'operator cancelled' }, + }); + expect( + (parsed as { eventData?: { cancelReason?: string } }).eventData + ?.cancelReason + ).toBe('operator cancelled'); + }); +}); diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index c506f71d89..29c82acb6c 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -393,6 +393,14 @@ const RunFailedEventSchema = BaseEventSchema.extend({ */ const RunCancelledEventSchema = BaseEventSchema.extend({ eventType: z.literal('run_cancelled'), + eventData: z + .object({ + // Optional free-text reason for the cancellation. Kept as small + // plaintext metadata (like run_failed's errorCode) so it survives + // resolveData: 'none' and can be displayed without decryption. + cancelReason: z.string().max(512).optional(), + }) + .optional(), }); // Discriminated union for user-creatable events (requests to world.events.create)