diff --git a/.claude/skills/doc-check/SKILL.md b/.claude/skills/doc-check/SKILL.md index bc35c5a7..d7618509 100644 --- a/.claude/skills/doc-check/SKILL.md +++ b/.claude/skills/doc-check/SKILL.md @@ -60,6 +60,17 @@ These are the primary references for AI coding agents. | `website/guide/background-sync.md` | Background sync example | | `website/guide/offline-app.md` | Offline app example | +### Example Apps + +Grep for the changed symbol name in `examples/` to find usage. Each example demonstrates a different deployment pattern: + +| Directory | Pattern | Key files | +| ----------------------------------- | ------------------------------ | ------------------------------------------------------------------- | +| `examples/server-node` | Node.js server (core API only) | `jobs/*.ts`, `lib/durably.ts`, `basic.ts` | +| `examples/browser-vite-react` | Browser SPA (Vite + React) | `src/jobs/*.ts`, `src/lib/durably.ts`, `src/components/*.tsx` | +| `examples/browser-react-router-spa` | Browser SPA (React Router) | `app/jobs/*.ts`, `app/lib/durably.ts`, `app/routes/**/*.tsx` | +| `examples/fullstack-react-router` | Fullstack (React Router + SSE) | `app/jobs/*.ts`, `app/lib/durably.server.ts`, `app/routes/**/*.tsx` | + ## 3. Generated Files These are derived from package docs and must be regenerated: @@ -74,15 +85,18 @@ pnpm --filter durably-website generate:llms Use this table to quickly determine which docs to check based on what changed: -| Change Type | Docs to Check | -| -------------------------------- | ------------------------------------------------------------------------------------------ | -| New field on `Run` / `RunFilter` | llms.md (core), create-durably.md, index.md, http-handler.md, react browser.md + client.md | -| New event field | llms.md (core), events.md, index.md | -| New step method | llms.md (core), step.md, index.md | -| New trigger option | llms.md (core), index.md, http-handler.md, create-durably.md | -| React hook change | llms.md (react), browser.md, client.md, index.md (react section) | -| HTTP handler change | llms.md (core), http-handler.md, client.md | -| New config option | llms.md (core), create-durably.md, index.md | +| Change Type | Docs to Check | +| -------------------------------- | ----------------------------------------------------------------------------------------------------- | +| New field on `Run` / `RunFilter` | llms.md (core), create-durably.md, index.md, http-handler.md, react browser.md + client.md | +| New event field | llms.md (core), events.md, index.md | +| New step method | llms.md (core), step.md, index.md | +| New trigger option | llms.md (core), index.md, http-handler.md, create-durably.md | +| React hook change | llms.md (react), browser.md, client.md, index.md (react section) | +| HTTP handler change | llms.md (core), http-handler.md, client.md | +| New config option | llms.md (core), create-durably.md, index.md | +| Job/step API change | All example apps (`examples/`) | +| Event type change | `examples/fullstack-react-router` (SSE), `examples/browser-*` (direct events) | +| React hook change | `examples/browser-vite-react`, `examples/browser-react-router-spa`, `examples/fullstack-react-router` | ## 5. Common Oversights @@ -92,6 +106,7 @@ Use this table to quickly determine which docs to check based on what changed: - **Type definitions** in `website/api/durably-react/types.md` may need new type exports - **`website/public/llms.txt`** is generated — don't edit directly, regenerate instead - **Code examples** in guides may use the changed API — grep for the symbol name in `website/guide/` +- **Example apps** in `examples/` are working apps that use the public API — grep for the changed symbol in all 4 examples ## 6. Verification diff --git a/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx b/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx index 674b6db3..cc86fbb1 100644 --- a/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx +++ b/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx @@ -361,7 +361,9 @@ export function Dashboard() { className={`rounded-full px-2 py-0.5 text-xs font-medium ${ s.status === 'completed' ? 'bg-green-100 text-green-800' - : 'bg-red-100 text-red-800' + : s.status === 'cancelled' + ? 'bg-gray-100 text-gray-800' + : 'bg-red-100 text-red-800' }`} > {s.status} diff --git a/examples/browser-vite-react/src/components/dashboard.tsx b/examples/browser-vite-react/src/components/dashboard.tsx index 1cf3de8e..b78c67bc 100644 --- a/examples/browser-vite-react/src/components/dashboard.tsx +++ b/examples/browser-vite-react/src/components/dashboard.tsx @@ -358,7 +358,9 @@ export function Dashboard() { className={`rounded-full px-2 py-0.5 text-xs font-medium ${ s.status === 'completed' ? 'bg-green-100 text-green-800' - : 'bg-red-100 text-red-800' + : s.status === 'cancelled' + ? 'bg-gray-100 text-gray-800' + : 'bg-red-100 text-red-800' }`} > {s.status} diff --git a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx index 2021f724..1d6d58ec 100644 --- a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx +++ b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx @@ -374,7 +374,9 @@ export function Dashboard() { className={`rounded-full px-2 py-0.5 text-xs font-medium ${ s.status === 'completed' ? 'bg-green-100 text-green-800' - : 'bg-red-100 text-red-800' + : s.status === 'cancelled' + ? 'bg-gray-100 text-gray-800' + : 'bg-red-100 text-red-800' }`} > {s.status} diff --git a/examples/server-node/basic.ts b/examples/server-node/basic.ts index 6f8224ba..bc92a3ee 100644 --- a/examples/server-node/basic.ts +++ b/examples/server-node/basic.ts @@ -16,6 +16,10 @@ durably.on('step:complete', (event) => { console.log(`[step:complete] ${event.stepName}`) }) +durably.on('step:cancel', (event) => { + console.log(`[step:cancel] ${event.stepName}`) +}) + durably.on('run:complete', (event) => { console.log( `[run:complete] output=${JSON.stringify(event.output)} duration=${event.duration}ms`, diff --git a/packages/durably-react/src/client/use-run-actions.ts b/packages/durably-react/src/client/use-run-actions.ts index 26f80f30..03f06ef4 100644 --- a/packages/durably-react/src/client/use-run-actions.ts +++ b/packages/durably-react/src/client/use-run-actions.ts @@ -6,7 +6,7 @@ import type { ClientRun } from '../types' */ export interface StepRecord { name: string - status: 'completed' | 'failed' + status: 'completed' | 'failed' | 'cancelled' output: unknown } diff --git a/packages/durably-react/src/client/use-runs.ts b/packages/durably-react/src/client/use-runs.ts index 531beda6..b8b890c9 100644 --- a/packages/durably-react/src/client/use-runs.ts +++ b/packages/durably-react/src/client/use-runs.ts @@ -42,6 +42,13 @@ type RunUpdateEvent = stepIndex: number error: string } + | { + type: 'step:cancel' + runId: string + jobName: string + stepName: string + stepIndex: number + } | { type: 'log:write' runId: string @@ -321,7 +328,11 @@ export function useRuns< ) } // On step start or fail, refresh to get latest state - if (data.type === 'step:start' || data.type === 'step:fail') { + if ( + data.type === 'step:start' || + data.type === 'step:fail' || + data.type === 'step:cancel' + ) { refresh() } // log:write is handled by useJobLogs, not useRuns diff --git a/packages/durably-react/src/hooks/use-runs.ts b/packages/durably-react/src/hooks/use-runs.ts index 7f610956..9d266ee3 100644 --- a/packages/durably-react/src/hooks/use-runs.ts +++ b/packages/durably-react/src/hooks/use-runs.ts @@ -208,6 +208,7 @@ export function useRuns< durably.on('run:progress', refresh), durably.on('step:start', refresh), durably.on('step:complete', refresh), + durably.on('step:cancel', refresh), ] return () => { diff --git a/packages/durably-react/src/types.ts b/packages/durably-react/src/types.ts index 69a17062..f31c6ef5 100644 --- a/packages/durably-react/src/types.ts +++ b/packages/durably-react/src/types.ts @@ -93,6 +93,14 @@ export type DurablyEvent = stepIndex: number output: unknown } + | { + type: 'step:cancel' + runId: string + jobName: string + stepName: string + stepIndex: number + labels: Record + } | { type: 'log:write' runId: string diff --git a/packages/durably/docs/llms.md b/packages/durably/docs/llms.md index cb4f9169..4df683a3 100644 --- a/packages/durably/docs/llms.md +++ b/packages/durably/docs/llms.md @@ -236,6 +236,7 @@ durably.on('run:progress', (e) => durably.on('step:start', (e) => console.log('Step:', e.stepName)) durably.on('step:complete', (e) => console.log('Step done:', e.stepName)) durably.on('step:fail', (e) => console.error('Step failed:', e.stepName)) +durably.on('step:cancel', (e) => console.log('Step cancelled:', e.stepName)) // Log events durably.on('log:write', (e) => console.log(`[${e.level}]`, e.message)) diff --git a/packages/durably/src/context.ts b/packages/durably/src/context.ts index 3cb370ac..5e56fcd8 100644 --- a/packages/durably/src/context.ts +++ b/packages/durably/src/context.ts @@ -106,7 +106,7 @@ export function createStepContext( return result } catch (error) { - // Save failed step + const isCancelled = controller.signal.aborted const errorMessage = error instanceof Error ? error.message : String(error) @@ -114,22 +114,25 @@ export function createStepContext( runId: run.id, name, index: stepIndex, - status: 'failed', + status: isCancelled ? 'cancelled' : 'failed', error: errorMessage, startedAt, }) - // Emit step:fail event eventEmitter.emit({ - type: 'step:fail', + ...(isCancelled + ? { type: 'step:cancel' as const } + : { type: 'step:fail' as const, error: errorMessage }), runId: run.id, jobName, stepName: name, stepIndex, - error: errorMessage, labels: run.labels, }) + if (isCancelled) { + throw new CancelledError(run.id) + } throw error } finally { // Clear current step after execution diff --git a/packages/durably/src/events.ts b/packages/durably/src/events.ts index fa933014..71cc035f 100644 --- a/packages/durably/src/events.ts +++ b/packages/durably/src/events.ts @@ -133,6 +133,15 @@ export interface StepFailEvent extends BaseEvent { labels: Record } +export interface StepCancelEvent extends BaseEvent { + type: 'step:cancel' + runId: string + jobName: string + stepName: string + stepIndex: number + labels: Record +} + /** * Log write event */ @@ -172,6 +181,7 @@ export type DurablyEvent = | StepStartEvent | StepCompleteEvent | StepFailEvent + | StepCancelEvent | LogWriteEvent | WorkerErrorEvent @@ -211,6 +221,7 @@ export type AnyEventInput = | EventInput<'step:start'> | EventInput<'step:complete'> | EventInput<'step:fail'> + | EventInput<'step:cancel'> | EventInput<'log:write'> | EventInput<'worker:error'> diff --git a/packages/durably/src/index.ts b/packages/durably/src/index.ts index 1c0b81f3..0a1f4561 100644 --- a/packages/durably/src/index.ts +++ b/packages/durably/src/index.ts @@ -25,6 +25,7 @@ export type { RunFailEvent, RunProgressEvent, RunStartEvent, + StepCancelEvent, StepCompleteEvent, StepFailEvent, StepStartEvent, diff --git a/packages/durably/src/schema.ts b/packages/durably/src/schema.ts index b53e450c..e77d9e06 100644 --- a/packages/durably/src/schema.ts +++ b/packages/durably/src/schema.ts @@ -26,7 +26,7 @@ export interface StepsTable { run_id: string name: string index: number - status: 'completed' | 'failed' + status: 'completed' | 'failed' | 'cancelled' output: string | null // JSON error: string | null started_at: string // ISO8601 diff --git a/packages/durably/src/server.ts b/packages/durably/src/server.ts index 618b95dc..d73bc434 100644 --- a/packages/durably/src/server.ts +++ b/packages/durably/src/server.ts @@ -515,6 +515,19 @@ export function createDurablyHandler( } }), + durably.on('step:cancel', (event) => { + if (matchesFilter(event.jobName, event.labels)) { + ctrl.enqueue({ + type: 'step:cancel', + runId: event.runId, + jobName: event.jobName, + stepName: event.stepName, + stepIndex: event.stepIndex, + labels: event.labels, + }) + } + }), + durably.on('log:write', (event) => { if (matchesFilter(event.jobName, event.labels)) { ctrl.enqueue({ diff --git a/packages/durably/src/storage.ts b/packages/durably/src/storage.ts index 062091ef..7530f177 100644 --- a/packages/durably/src/storage.ts +++ b/packages/durably/src/storage.ts @@ -72,7 +72,7 @@ export interface CreateStepInput { runId: string name: string index: number - status: 'completed' | 'failed' + status: 'completed' | 'failed' | 'cancelled' output?: unknown error?: string startedAt: string // ISO8601 timestamp when step execution started @@ -86,7 +86,7 @@ export interface Step { runId: string name: string index: number - status: 'completed' | 'failed' + status: 'completed' | 'failed' | 'cancelled' output: unknown | null error: string | null startedAt: string diff --git a/packages/durably/tests/shared/step.shared.ts b/packages/durably/tests/shared/step.shared.ts index f6468fa3..faa173df 100644 --- a/packages/durably/tests/shared/step.shared.ts +++ b/packages/durably/tests/shared/step.shared.ts @@ -6,7 +6,9 @@ import { defineJob, type Durably, type RunProgressEvent, + type StepCancelEvent, type StepCompleteEvent, + type StepFailEvent, } from '../../src' export function createStepTests(createDialect: () => Dialect) { @@ -456,6 +458,86 @@ export function createStepTests(createDialect: () => Dialect) { ) }) + it('emits step:cancel event when step is cancelled', async () => { + const cancelEvents: StepCancelEvent[] = [] + const failEvents: StepFailEvent[] = [] + let stepStartedResolve!: () => void + const stepStartedPromise = new Promise((resolve) => { + stepStartedResolve = resolve + }) + + const stepCancelEventDef = defineJob({ + name: 'step-cancel-event-test', + input: z.object({}), + run: async (step) => { + await step.run('cancellable-step', async (signal) => { + stepStartedResolve() + await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + reject( + new DOMException('The operation was aborted.', 'AbortError'), + ) + }) + }) + }) + }, + }) + const d = durably.register({ job: stepCancelEventDef }) + + d.on('step:cancel', (event) => cancelEvents.push(event)) + d.on('step:fail', (event) => failEvents.push(event)) + + const run = await d.jobs.job.trigger({}) + d.start() + + await stepStartedPromise + await d.cancel(run.id) + + await vi.waitFor( + () => { + expect(cancelEvents).toHaveLength(1) + }, + { timeout: 2000 }, + ) + + expect(cancelEvents[0].stepName).toBe('cancellable-step') + expect(cancelEvents[0].runId).toBe(run.id) + expect(failEvents).toHaveLength(0) + }) + + it('emits step:fail event for non-cancellation errors', async () => { + const cancelEvents: StepCancelEvent[] = [] + const failEvents: StepFailEvent[] = [] + + const stepFailEventDef = defineJob({ + name: 'step-fail-event-test', + input: z.object({}), + run: async (step) => { + await step.run('failing-step', async () => { + throw new Error('intentional error') + }) + }, + }) + const d = durably.register({ job: stepFailEventDef }) + + d.on('step:cancel', (event) => cancelEvents.push(event)) + d.on('step:fail', (event) => failEvents.push(event)) + + await d.jobs.job.trigger({}) + d.start() + + await vi.waitFor( + () => { + expect(failEvents).toHaveLength(1) + }, + { timeout: 2000 }, + ) + + expect(failEvents[0].stepName).toBe('failing-step') + expect(failEvents[0].error).toBe('intentional error') + expect(cancelEvents).toHaveLength(0) + }) + it('signal is aborted when cancellation is detected at step boundary', async () => { let step2Called = false let step1StartedResolve!: () => void diff --git a/website/api/durably-react/types.md b/website/api/durably-react/types.md index 35222a48..7fbe9bbf 100644 --- a/website/api/durably-react/types.md +++ b/website/api/durably-react/types.md @@ -99,7 +99,7 @@ interface ClientRun { ```ts interface StepRecord { name: string - status: 'completed' | 'failed' + status: 'completed' | 'failed' | 'cancelled' output: unknown error: string | null startedAt: string @@ -107,11 +107,11 @@ interface StepRecord { } ``` -| Property | Type | Description | -| ------------- | ------------------------- | --------------------------- | -| `name` | `string` | Step name | -| `status` | `'completed' \| 'failed'` | Step result | -| `output` | `unknown` | Step return value | -| `error` | `string \| null` | Error message (when failed) | -| `startedAt` | `string` | ISO timestamp of start | -| `completedAt` | `string \| null` | ISO timestamp of completion | +| Property | Type | Description | +| ------------- | ---------------------------------------- | --------------------------- | +| `name` | `string` | Step name | +| `status` | `'completed' \| 'failed' \| 'cancelled'` | Step result | +| `output` | `unknown` | Step return value | +| `error` | `string \| null` | Error message (when failed) | +| `startedAt` | `string` | ISO timestamp of start | +| `completedAt` | `string \| null` | ISO timestamp of completion | diff --git a/website/api/events.md b/website/api/events.md index 33203562..9bc88d0a 100644 --- a/website/api/events.md +++ b/website/api/events.md @@ -217,6 +217,25 @@ durably.on('step:fail', (event) => { }) ``` +#### `step:cancel` + +Fired when a step is cancelled (run was cancelled while step was executing). + +```ts +durably.on('step:cancel', (event) => { + // event: { + // type: 'step:cancel', + // runId: string, + // jobName: string, + // stepName: string, + // stepIndex: number, + // labels: Record, + // timestamp: string, + // sequence: number + // } +}) +``` + ### Log Events #### `log:write` @@ -291,6 +310,7 @@ type DurablyEvent = | StepStartEvent | StepCompleteEvent | StepFailEvent + | StepCancelEvent | LogWriteEvent | WorkerErrorEvent ``` diff --git a/website/guide/background-sync.md b/website/guide/background-sync.md index 1f3a3890..b96330a9 100644 --- a/website/guide/background-sync.md +++ b/website/guide/background-sync.md @@ -142,6 +142,10 @@ durably.on('step:complete', (event) => { console.log(`[step:complete] ${event.stepName}`) }) +durably.on('step:cancel', (event) => { + console.log(`[step:cancel] ${event.stepName}`) +}) + durably.on('run:complete', (event) => { console.log( `[run:complete] output=${JSON.stringify(event.output)} duration=${event.duration}ms`, diff --git a/website/guide/concepts.md b/website/guide/concepts.md index 843dc142..c61cf64d 100644 --- a/website/guide/concepts.md +++ b/website/guide/concepts.md @@ -198,6 +198,8 @@ durably.on('run:complete', ({ runId, output }) => { ... }) durably.on('run:fail', ({ runId, error }) => { ... }) durably.on('step:start', ({ runId, stepName }) => { ... }) durably.on('step:complete', ({ runId, stepName, output }) => { ... }) +durably.on('step:fail', ({ runId, stepName, error }) => { ... }) +durably.on('step:cancel', ({ runId, stepName }) => { ... }) ``` See [Events API](/api/events) for the full list. diff --git a/website/public/llms.txt b/website/public/llms.txt index 78d21308..45415140 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -236,6 +236,7 @@ durably.on('run:progress', (e) => durably.on('step:start', (e) => console.log('Step:', e.stepName)) durably.on('step:complete', (e) => console.log('Step done:', e.stepName)) durably.on('step:fail', (e) => console.error('Step failed:', e.stepName)) +durably.on('step:cancel', (e) => console.log('Step cancelled:', e.stepName)) // Log events durably.on('log:write', (e) => console.log(`[${e.level}]`, e.message))