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
33 changes: 24 additions & 9 deletions .claude/skills/doc-check/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
4 changes: 3 additions & 1 deletion examples/browser-vite-react/src/components/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
4 changes: 4 additions & 0 deletions examples/server-node/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion packages/durably-react/src/client/use-run-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ClientRun } from '../types'
*/
export interface StepRecord {
name: string
status: 'completed' | 'failed'
status: 'completed' | 'failed' | 'cancelled'
output: unknown
}

Expand Down
13 changes: 12 additions & 1 deletion packages/durably-react/src/client/use-runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/durably-react/src/hooks/use-runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/durably-react/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ export type DurablyEvent =
stepIndex: number
output: unknown
}
| {
type: 'step:cancel'
runId: string
jobName: string
stepName: string
stepIndex: number
labels: Record<string, string>
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| {
type: 'log:write'
runId: string
Expand Down
1 change: 1 addition & 0 deletions packages/durably/docs/llms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
13 changes: 8 additions & 5 deletions packages/durably/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,30 +106,33 @@ export function createStepContext(

return result
} catch (error) {
// Save failed step
const isCancelled = controller.signal.aborted
const errorMessage =
error instanceof Error ? error.message : String(error)

await storage.createStep({
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
Expand Down
11 changes: 11 additions & 0 deletions packages/durably/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ export interface StepFailEvent extends BaseEvent {
labels: Record<string, string>
}

export interface StepCancelEvent extends BaseEvent {
type: 'step:cancel'
runId: string
jobName: string
stepName: string
stepIndex: number
labels: Record<string, string>
}

/**
* Log write event
*/
Expand Down Expand Up @@ -172,6 +181,7 @@ export type DurablyEvent =
| StepStartEvent
| StepCompleteEvent
| StepFailEvent
| StepCancelEvent
| LogWriteEvent
| WorkerErrorEvent

Expand Down Expand Up @@ -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'>

Expand Down
1 change: 1 addition & 0 deletions packages/durably/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type {
RunFailEvent,
RunProgressEvent,
RunStartEvent,
StepCancelEvent,
StepCompleteEvent,
StepFailEvent,
StepStartEvent,
Expand Down
2 changes: 1 addition & 1 deletion packages/durably/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/durably/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
4 changes: 2 additions & 2 deletions packages/durably/src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
82 changes: 82 additions & 0 deletions packages/durably/tests/shared/step.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
defineJob,
type Durably,
type RunProgressEvent,
type StepCancelEvent,
type StepCompleteEvent,
type StepFailEvent,
} from '../../src'

export function createStepTests(createDialect: () => Dialect) {
Expand Down Expand Up @@ -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<void>((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<void>((_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
Expand Down
Loading