From d7bfd24da9fc6a63916360d4efb6248b1b02144f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:46:58 +0000 Subject: [PATCH] Add optional reason to run cancellation (#2840) Signed-off-by: Peter Wielander --- .changeset/run-cancel-reason.md | 8 +++ .../api-reference/workflow-api/get-run.mdx | 19 +++++++ packages/core/src/runtime.ts | 1 + packages/core/src/runtime/run.ts | 10 +++- packages/core/src/runtime/runs.ts | 24 +++++++-- .../src/components/event-list-view.tsx | 21 ++++++++ packages/workflow/src/api-workflow.ts | 1 + packages/workflow/src/api.ts | 1 + packages/world-vercel/src/events-v4.ts | 4 ++ packages/world-vercel/src/events.test.ts | 21 ++++++++ packages/world-vercel/src/events.ts | 7 +++ packages/world/src/events.test.ts | 50 +++++++++++++++++++ packages/world/src/events.ts | 8 +++ 13 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 .changeset/run-cancel-reason.md create mode 100644 packages/world/src/events.test.ts 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/api-reference/workflow-api/get-run.mdx b/docs/content/docs/api-reference/workflow-api/get-run.mdx index 47be7831de..a68b79a5c5 100644 --- a/docs/content/docs/api-reference/workflow-api/get-run.mdx +++ b/docs/content/docs/api-reference/workflow-api/get-run.mdx @@ -164,6 +164,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 9452fc9594..351022e587 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -73,6 +73,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 02a6459a70..bd7dbdca48 100644 --- a/packages/core/src/runtime/run.ts +++ b/packages/core/src/runtime/run.ts @@ -16,6 +16,7 @@ import { } from '../serialization.js'; import { getWorkflowRunStreamId } from '../util.js'; import { + type CancelRunOptions, type StopSleepOptions, type StopSleepResult, wakeUpRun, @@ -142,11 +143,18 @@ 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 { await this.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 8be0c268f1..c36a241c5e 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 38ec990630..51cf0ed90c 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -595,6 +595,27 @@ function PayloadBlock({ ); } + // 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 (
; @@ -138,6 +141,7 @@ function buildPostFrameMeta( if (input.errorCode !== undefined) meta.errorCode = input.errorCode; if (input.error !== undefined) meta.error = input.error; if (input.stack !== undefined) meta.stack = input.stack; + 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 7ec3db5683..71317656ba 100644 --- a/packages/world-vercel/src/events.test.ts +++ b/packages/world-vercel/src/events.test.ts @@ -173,6 +173,27 @@ describe('splitEventDataForV4 structured errors', () => { expect(meta.stack).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('keeps an already-dehydrated (Uint8Array) error on the body path', () => { // A runtime that DOES dehydrate errors hands the split a Uint8Array; // it must stream as the opaque frame body, untouched, with no meta.error. diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index 1baec032fa..a4e27553fb 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -198,6 +198,7 @@ interface SplitEventData { * object instead. */ stack?: string; + cancelReason?: string; /** Structured executionContext, included verbatim in frame meta. */ executionContext?: Record; }; @@ -223,6 +224,7 @@ type MetaSourceField = // hook_created webhook flag (renamed to hookIsWebhook on the wire) | 'isWebhook' | 'errorCode' + | 'cancelReason' // step_failed / step_retrying error stack (sibling of the message string) | 'stack' | 'executionContext'; @@ -324,6 +326,11 @@ export function splitEventDataForV4(data: AnyEventRequest): SplitEventData { if (typeof eventData.stack === 'string') { meta.stack = eventData.stack; } + // 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 f35ad85e1c..1fd7636163 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -318,6 +318,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)