From c5b7ab595072243e271d3930e5796b719c18fc21 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Jul 2026 22:23:14 +0000 Subject: [PATCH 1/9] Fix workflows.create defaults for inline MCP runs Co-authored-by: Kent C. Dodds --- .../src/mcp/run-kody-registry.node.test.ts | 113 +++++++++++++++++- .../src/package-runtime/package-workflows.ts | 40 ++++--- 2 files changed, 131 insertions(+), 22 deletions(-) diff --git a/packages/worker/src/mcp/run-kody-registry.node.test.ts b/packages/worker/src/mcp/run-kody-registry.node.test.ts index ade9d1deb6..e0b0bcd6d9 100644 --- a/packages/worker/src/mcp/run-kody-registry.node.test.ts +++ b/packages/worker/src/mcp/run-kody-registry.node.test.ts @@ -12,10 +12,7 @@ import { runBundledModuleWithRegistry, runModuleWithRegistry, } from './run-kody-registry.ts' -import { - createKodyProviderProxySource, - createToolDispatchers, -} from '#mcp/executor.ts' +import * as mcpExecutor from '#mcp/executor.ts' import { type KodyResolvedProvider } from '#mcp/kody-remote-types.ts' import { PackageSecretMountError } from '#mcp/secrets/package-access.ts' import * as packageAccess from '#mcp/secrets/package-access.ts' @@ -223,6 +220,108 @@ export default async function run() { } }) +test('runModuleWithRegistry queues inline workflows.create calls without runAt or idempotencyKey', async () => { + const created: Array> = [] + const env = { + APP_DB: { + prepare(query: string) { + return { + bind() { + return { + async first() { + if (query.includes('COUNT(*) AS count')) return { count: 0 } + return null + }, + async run() { + return { success: true } + }, + } + }, + } + }, + } as unknown as D1Database, + DYNAMIC_CALLABLE_WORKFLOWS: { + get: async () => { + throw new Error('not found') + }, + create: async (options?: WorkflowInstanceCreateOptions) => { + if (!options) throw new Error('missing options') + created.push(options) + return { + id: options.id ?? 'generated', + status: async () => ({ status: 'queued' }), + } as WorkflowInstance + }, + } as Workflow, + } as Env + const callerContext = createMcpCallerContext({ + baseUrl: 'https://app.example.com', + user: { + userId: 'user-1', + email: 'me@example.com', + displayName: 'Me', + }, + storageContext: null, + }) + let wrappedSource = '' + const createExecuteExecutorSpy = vi + .spyOn(mcpExecutor, 'createExecuteExecutor') + .mockReturnValue({ + async execute(_wrapped, providers) { + wrappedSource = String(_wrapped) + const provider = providers[0] as { + fns: Record Promise> + } + return { + result: await provider.fns.package_workflow_create({ + code: 'export default async function main() { return { ok: true } }', + }), + logs: [], + } + }, + } as never) + + try { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-03T12:34:56.000Z')) + const result = await runModuleWithRegistry( + env, + callerContext, + `import { workflows } from 'kody:runtime' +export default async function main() { + return await workflows.create({ code: \`export default async function main() { return { ok: true } }\` }) +}`, + ) + + expect(result.result).toMatchObject({ + ok: true, + source_type: 'inline', + workflow_name: 'inline-code', + export_name: null, + run_at: '2026-05-03T12:34:56.000Z', + plan_date: '2026-05-03', + status: 'queued', + }) + expect(wrappedSource).toContain('const workflows = {') + expect(wrappedSource).toContain('kody.package_workflow_create') + expect(created).toHaveLength(1) + expect(created[0]?.params).toEqual( + expect.objectContaining({ + sourceType: 'inline', + userId: 'user-1', + workflowName: 'inline-code', + code: 'export default async function main() { return { ok: true } }', + idempotencyKey: expect.stringMatching(/^generated:/), + runAt: '2026-05-03T12:34:56.000Z', + planDate: '2026-05-03', + }), + ) + } finally { + vi.useRealTimers() + createExecuteExecutorSpy.mockRestore() + } +}) + vi.mock('#worker/package-runtime/module-graph.ts', async () => { const actual = await vi.importActual< typeof import('#worker/package-runtime/module-graph.ts') @@ -1006,8 +1105,10 @@ test('remote connector calls round-trip through sanitized ToolDispatcher names', dispatchName: 'remotelightinglutron_set_credentials', }), ]) - const dispatchers = createToolDispatchers([provider], { active: true }) - const source = createKodyProviderProxySource({ + const dispatchers = mcpExecutor.createToolDispatchers([provider], { + active: true, + }) + const source = mcpExecutor.createKodyProviderProxySource({ providerName: provider.name, remoteConnectors, }) diff --git a/packages/worker/src/package-runtime/package-workflows.ts b/packages/worker/src/package-runtime/package-workflows.ts index 9d9dd97faf..3ab911035d 100644 --- a/packages/worker/src/package-runtime/package-workflows.ts +++ b/packages/worker/src/package-runtime/package-workflows.ts @@ -28,8 +28,8 @@ export type PackageWorkflowParams = Record type WorkflowCreateBaseInput = { workflowName?: string - runAt: string | Date - idempotencyKey: string + runAt?: string | Date + idempotencyKey?: string params?: PackageWorkflowParams } @@ -234,8 +234,13 @@ function normalizeOptionalWorkflowName( return workflowName?.trim() || fallback } -function normalizeRunAt(runAt: string | Date) { - const date = typeof runAt === 'string' ? new Date(runAt) : runAt +function normalizeRunAt(runAt: string | Date | undefined) { + const date = + runAt === undefined + ? new Date() + : typeof runAt === 'string' + ? new Date(runAt) + : runAt if (Number.isNaN(date.getTime())) { throw new Error('runAt must be a valid date or ISO string.') } @@ -293,16 +298,24 @@ export function createPackageWorkflowPlanDate(runAt: string | Date) { return normalizeRunAt(runAt).slice(0, 'YYYY-MM-DD'.length) } +function normalizeWorkflowIdempotencyKey(idempotencyKey: string | undefined) { + if (idempotencyKey !== undefined) { + return normalizeNonEmptyString(idempotencyKey, 'idempotencyKey') + } + return `generated:${crypto.randomUUID()}` +} + function createInlineWorkflowPayload(input: { userId: string workflowName?: string code: string - idempotencyKey: string - runAt: string | Date + idempotencyKey?: string + runAt?: string | Date params?: PackageWorkflowParams | null planDate?: string | null }): DynamicCallableWorkflowPayload { const runAt = normalizeRunAt(input.runAt) + const idempotencyKey = normalizeWorkflowIdempotencyKey(input.idempotencyKey) const params = normalizePackageWorkflowParams(input.params) return { version: 2, @@ -313,10 +326,7 @@ function createInlineWorkflowPayload(input: { 'inline-code', ), code: normalizeNonEmptyString(input.code, 'code'), - idempotencyKey: normalizeNonEmptyString( - input.idempotencyKey, - 'idempotencyKey', - ), + idempotencyKey, runAt, planDate: input.planDate?.trim() || createPackageWorkflowPlanDate(runAt), ...(params === undefined ? {} : { params }), @@ -330,12 +340,13 @@ function createDynamicPackageWorkflowPayload(input: { sourceId: string workflowName?: string exportName: string - idempotencyKey: string - runAt: string | Date + idempotencyKey?: string + runAt?: string | Date params?: PackageWorkflowParams | null planDate?: string | null }): DynamicCallableWorkflowPayload { const runAt = normalizeRunAt(input.runAt) + const idempotencyKey = normalizeWorkflowIdempotencyKey(input.idempotencyKey) const params = normalizePackageWorkflowParams(input.params) const workflowName = normalizeNonEmptyString( normalizeOptionalWorkflowName(input.workflowName, input.exportName), @@ -350,10 +361,7 @@ function createDynamicPackageWorkflowPayload(input: { sourceId: normalizeNonEmptyString(input.sourceId, 'sourceId'), workflowName, exportName: normalizeWorkflowExportName(input.exportName), - idempotencyKey: normalizeNonEmptyString( - input.idempotencyKey, - 'idempotencyKey', - ), + idempotencyKey, runAt, planDate: input.planDate?.trim() || createPackageWorkflowPlanDate(runAt), ...(params === undefined ? {} : { params }), From c6b31b4aaf7e01927379bf67fdb7f2c76629f37e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Jul 2026 22:34:00 +0000 Subject: [PATCH 2/9] Allow package app workflow default scheduling Co-authored-by: Kent C. Dodds --- .../package-runtime/package-app.node.test.ts | 18 ++++++++++++++++++ .../worker/src/package-runtime/package-app.ts | 14 +++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/worker/src/package-runtime/package-app.node.test.ts b/packages/worker/src/package-runtime/package-app.node.test.ts index 2c2d1296cb..42286c3bfb 100644 --- a/packages/worker/src/package-runtime/package-app.node.test.ts +++ b/packages/worker/src/package-runtime/package-app.node.test.ts @@ -118,6 +118,14 @@ test('package app workflows proxy validates required workflow input fields', asy runAt: new Date('2026-05-03T12:00:00.000Z'), idempotencyKey: 'event-key', }) + await expect( + workflows.create({ + exportName: './run-event', + code: '', + }), + ).resolves.toEqual({ + exportName: './run-event', + }) await expect( workflows.create({ workflowName: 'shade-event', @@ -158,6 +166,16 @@ test('package app workflows proxy forwards inline code workflow input', async () idempotencyKey: 'event-key', params: { eventId: 'event-1' }, }) + + await expect( + workflows.create({ + code, + params: { eventId: 'event-2' }, + }), + ).resolves.toEqual({ + code, + params: { eventId: 'event-2' }, + }) }) test('package app workflows proxy forwards validated input to runtime bridge', async () => { diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index 70a87fa7b9..5c0c83b3b4 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -266,13 +266,11 @@ function createWorkflowsProxy(runtimeBridge) { const value = input?.[fieldName]; return typeof value === 'string' && value.trim() ? value : null; }; - const normalizeRequiredString = (input, fieldName) => { - const value = normalizeOptionalString(input, fieldName); - if (!value) throw new Error('workflows.create requires a non-empty ' + fieldName + '.'); - return value; - }; const normalizeRunAt = (input) => { const value = input?.runAt; + if (value === undefined || value === null || value === '') { + return null; + } const date = value instanceof Date ? value @@ -300,9 +298,11 @@ function createWorkflowsProxy(runtimeBridge) { } const workflowName = normalizeOptionalString(input, 'workflowName'); const packageId = normalizeOptionalString(input, 'packageId'); + const runAt = normalizeRunAt(input); + const idempotencyKey = normalizeOptionalString(input, 'idempotencyKey'); const payload = { - runAt: normalizeRunAt(input), - idempotencyKey: normalizeRequiredString(input, 'idempotencyKey'), + ...(runAt ? { runAt } : {}), + ...(idempotencyKey ? { idempotencyKey } : {}), ...(input.params === undefined ? {} : { params: input.params }), ...(workflowName ? { workflowName } : {}), ...(packageId ? { packageId } : {}), From 4674f61aaf39beedee9b574f48d1e87794aa02f8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Jul 2026 22:41:38 +0000 Subject: [PATCH 3/9] Stabilize explicit workflow idempotency keys Co-authored-by: Kent C. Dodds --- .../package-workflows.node.test.ts | 35 +++++++++++++++++++ .../src/package-runtime/package-workflows.ts | 27 +++++++++++--- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/worker/src/package-runtime/package-workflows.node.test.ts b/packages/worker/src/package-runtime/package-workflows.node.test.ts index 4671e8d015..755f99769e 100644 --- a/packages/worker/src/package-runtime/package-workflows.node.test.ts +++ b/packages/worker/src/package-runtime/package-workflows.node.test.ts @@ -684,6 +684,41 @@ test('createDynamicCallableWorkflow dedupes queued runs by user and idempotency }), ]) + const preProjectionBinding = createStatefulWorkflowBinding() + const preProjectionDb = createWorkflowRunsDatabase() + const preProjectionEnv = { + APP_DB: preProjectionDb, + DYNAMIC_CALLABLE_WORKFLOWS: preProjectionBinding.workflow, + } as Env + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-05-08T19:30:00.000Z')) + const firstPreProjection = await createDynamicCallableWorkflow({ + env: preProjectionEnv, + userId: 'user-1', + body: { + code: 'export default async function main() { return { ok: true } }', + idempotencyKey: 'inline-pre-projection-key', + }, + }) + preProjectionDb.workflowRuns.clear() + vi.setSystemTime(new Date('2026-05-08T19:31:00.000Z')) + const replayPreProjection = await createDynamicCallableWorkflow({ + env: preProjectionEnv, + userId: 'user-1', + body: { + code: 'export default async function main() { return { ok: true } }', + idempotencyKey: 'inline-pre-projection-key', + }, + }) + + expect(replayPreProjection.id).toBe(firstPreProjection.id) + expect(preProjectionBinding.create).toHaveBeenCalledTimes(1) + expect(preProjectionBinding.instances.size).toBe(1) + } finally { + vi.useRealTimers() + } + const perUserBinding = createStatefulWorkflowBinding() const userOne = await createDynamicCallableWorkflow({ env: { diff --git a/packages/worker/src/package-runtime/package-workflows.ts b/packages/worker/src/package-runtime/package-workflows.ts index 3ab911035d..79ff3f524c 100644 --- a/packages/worker/src/package-runtime/package-workflows.ts +++ b/packages/worker/src/package-runtime/package-workflows.ts @@ -280,6 +280,9 @@ export async function createPackageWorkflowInstanceId(input: { workflowName: string idempotencyKey: string runAt: string | Date + options?: { + includeRunAt?: boolean + } }) { const canonical = canonicalJsonStringify({ userId: normalizeNonEmptyString(input.userId, 'userId'), @@ -289,7 +292,9 @@ export async function createPackageWorkflowInstanceId(input: { input.idempotencyKey, 'idempotencyKey', ), - runAt: normalizeRunAt(input.runAt), + ...(input.options?.includeRunAt === false + ? {} + : { runAt: normalizeRunAt(input.runAt) }), }) return `pkgwf-${(await sha256Base64Url(canonical)).slice(0, 43)}` } @@ -521,6 +526,9 @@ async function createInlineWorkflowInstanceId(input: { workflowName: string idempotencyKey: string runAt: string | Date + options?: { + includeRunAt?: boolean + } }) { const canonical = canonicalJsonStringify({ userId: normalizeNonEmptyString(input.userId, 'userId'), @@ -530,18 +538,23 @@ async function createInlineWorkflowInstanceId(input: { input.idempotencyKey, 'idempotencyKey', ), - runAt: normalizeRunAt(input.runAt), + ...(input.options?.includeRunAt === false + ? {} + : { runAt: normalizeRunAt(input.runAt) }), }) return `dynwf-${(await sha256Base64Url(canonical)).slice(0, 43)}` } async function createDynamicCallableWorkflowInstanceId( payload: DynamicCallableWorkflowPayload, + options?: { + includeRunAt?: boolean + }, ) { if (payload.sourceType === 'package') { - return await createPackageWorkflowInstanceId(payload) + return await createPackageWorkflowInstanceId({ ...payload, options }) } - return await createInlineWorkflowInstanceId(payload) + return await createInlineWorkflowInstanceId({ ...payload, options }) } function mapWorkflowRunRow( @@ -762,7 +775,11 @@ export async function createDynamicCallableWorkflow(input: { } } const payload = await resolveWorkflowPayload(input) - const id = await createDynamicCallableWorkflowInstanceId(payload) + const id = await createDynamicCallableWorkflowInstanceId(payload, { + // An explicit idempotency key must single-flight even before the + // workflow_runs projection row is written. + includeRunAt: !idempotencyKeyInput, + }) const existing = await getExistingWorkflowInstance( input.env.DYNAMIC_CALLABLE_WORKFLOWS, id, From 20a7614a73c731d7c8ac64fd4b8a9c2a36abb3fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Jul 2026 22:49:51 +0000 Subject: [PATCH 4/9] Preserve workflow runAt for idempotent replays Co-authored-by: Kent C. Dodds --- .../package-workflows.node.test.ts | 38 ++++++++++++-- .../src/package-runtime/package-workflows.ts | 49 +++++++++++++++---- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/packages/worker/src/package-runtime/package-workflows.node.test.ts b/packages/worker/src/package-runtime/package-workflows.node.test.ts index 755f99769e..ab66c7c4bf 100644 --- a/packages/worker/src/package-runtime/package-workflows.node.test.ts +++ b/packages/worker/src/package-runtime/package-workflows.node.test.ts @@ -684,11 +684,39 @@ test('createDynamicCallableWorkflow dedupes queued runs by user and idempotency }), ]) - const preProjectionBinding = createStatefulWorkflowBinding() const preProjectionDb = createWorkflowRunsDatabase() + const preProjectionInstances = new Map() + const preProjectionCreate = vi.fn( + async (input: WorkflowInstanceCreateOptions) => { + expect([...preProjectionDb.workflowRuns.values()]).toEqual([ + expect.objectContaining({ + id: input.id, + idempotency_key: 'inline-pre-projection-key', + run_at: '2026-05-08T19:30:00.000Z', + }), + ]) + preProjectionInstances.set(input.id, input) + return { + id: input.id, + status: async () => ({ status: 'queued' }), + } as WorkflowInstance + }, + ) + const preProjectionGet = vi.fn(async (id: string) => { + if (!preProjectionInstances.has(id)) { + throw new Error('workflow instance does not exist') + } + return { + id, + status: async () => ({ status: 'waiting' }), + } as WorkflowInstance + }) const preProjectionEnv = { APP_DB: preProjectionDb, - DYNAMIC_CALLABLE_WORKFLOWS: preProjectionBinding.workflow, + DYNAMIC_CALLABLE_WORKFLOWS: { + get: preProjectionGet, + create: preProjectionCreate, + } as unknown as Workflow, } as Env vi.useFakeTimers() try { @@ -701,7 +729,6 @@ test('createDynamicCallableWorkflow dedupes queued runs by user and idempotency idempotencyKey: 'inline-pre-projection-key', }, }) - preProjectionDb.workflowRuns.clear() vi.setSystemTime(new Date('2026-05-08T19:31:00.000Z')) const replayPreProjection = await createDynamicCallableWorkflow({ env: preProjectionEnv, @@ -713,8 +740,9 @@ test('createDynamicCallableWorkflow dedupes queued runs by user and idempotency }) expect(replayPreProjection.id).toBe(firstPreProjection.id) - expect(preProjectionBinding.create).toHaveBeenCalledTimes(1) - expect(preProjectionBinding.instances.size).toBe(1) + expect(replayPreProjection.run_at).toBe(firstPreProjection.run_at) + expect(preProjectionCreate).toHaveBeenCalledTimes(1) + expect(preProjectionInstances.size).toBe(1) } finally { vi.useRealTimers() } diff --git a/packages/worker/src/package-runtime/package-workflows.ts b/packages/worker/src/package-runtime/package-workflows.ts index 79ff3f524c..656bb0aa11 100644 --- a/packages/worker/src/package-runtime/package-workflows.ts +++ b/packages/worker/src/package-runtime/package-workflows.ts @@ -649,6 +649,20 @@ async function recordWorkflowRun(input: { .run() } +async function deleteWorkflowRun(input: { + db: D1Database + id: string + userId: string +}) { + await input.db + .prepare( + `DELETE FROM workflow_runs + WHERE id = ? AND user_id = ?`, + ) + .bind(input.id, input.userId) + .run() +} + async function updateWorkflowRunStatus(input: { env: Env id: string @@ -780,6 +794,25 @@ export async function createDynamicCallableWorkflow(input: { // workflow_runs projection row is written. includeRunAt: !idempotencyKeyInput, }) + if (input.env.APP_DB) { + await assertWithinEntitlement({ + db: input.env.APP_DB, + userId: payload.userId, + email: input.userEmail, + resource: 'concurrent_workflows', + fallbackLimit: getWorkflowConcurrencyBackstop(input.env as Env), + }) + } + let preRecordedWorkflowRun = false + if (input.env.APP_DB && idempotencyKeyInput) { + await recordWorkflowRun({ + db: input.env.APP_DB, + id, + payload, + status: 'queued', + }) + preRecordedWorkflowRun = true + } const existing = await getExistingWorkflowInstance( input.env.DYNAMIC_CALLABLE_WORKFLOWS, id, @@ -795,15 +828,6 @@ export async function createDynamicCallableWorkflow(input: { } return createWorkflowCreateResult({ summary: existing, payload }) } - if (input.env.APP_DB) { - await assertWithinEntitlement({ - db: input.env.APP_DB, - userId: payload.userId, - email: input.userEmail, - resource: 'concurrent_workflows', - fallbackLimit: getWorkflowConcurrencyBackstop(input.env as Env), - }) - } let instance: WorkflowInstance try { instance = await input.env.DYNAMIC_CALLABLE_WORKFLOWS.create({ @@ -827,6 +851,13 @@ export async function createDynamicCallableWorkflow(input: { }) } } + if (preRecordedWorkflowRun && input.env.APP_DB) { + await deleteWorkflowRun({ + db: input.env.APP_DB, + id, + userId: payload.userId, + }) + } throw error } if (input.env.APP_DB) { From ab3b7ae057fd39491e7ee0515d879116f062002a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Jul 2026 22:52:45 +0000 Subject: [PATCH 5/9] Format workflow idempotency regression test Co-authored-by: Kent C. Dodds --- .../src/package-runtime/package-workflows.node.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/package-runtime/package-workflows.node.test.ts b/packages/worker/src/package-runtime/package-workflows.node.test.ts index ab66c7c4bf..d29ba122fe 100644 --- a/packages/worker/src/package-runtime/package-workflows.node.test.ts +++ b/packages/worker/src/package-runtime/package-workflows.node.test.ts @@ -685,7 +685,10 @@ test('createDynamicCallableWorkflow dedupes queued runs by user and idempotency ]) const preProjectionDb = createWorkflowRunsDatabase() - const preProjectionInstances = new Map() + const preProjectionInstances = new Map< + string, + WorkflowInstanceCreateOptions + >() const preProjectionCreate = vi.fn( async (input: WorkflowInstanceCreateOptions) => { expect([...preProjectionDb.workflowRuns.values()]).toEqual([ From 0c1473dcca9e0a0167b5ebde9cd095b905807b17 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Jul 2026 22:59:51 +0000 Subject: [PATCH 6/9] Rejoin workflow idempotency before limits Co-authored-by: Kent C. Dodds --- .../package-workflows.node.test.ts | 19 +++++++ .../src/package-runtime/package-workflows.ts | 53 ++++++------------- 2 files changed, 34 insertions(+), 38 deletions(-) diff --git a/packages/worker/src/package-runtime/package-workflows.node.test.ts b/packages/worker/src/package-runtime/package-workflows.node.test.ts index d29ba122fe..0342cec440 100644 --- a/packages/worker/src/package-runtime/package-workflows.node.test.ts +++ b/packages/worker/src/package-runtime/package-workflows.node.test.ts @@ -750,6 +750,25 @@ test('createDynamicCallableWorkflow dedupes queued runs by user and idempotency vi.useRealTimers() } + const existingOverLimitBinding = createWorkflowBinding({}) + await expect( + createDynamicCallableWorkflow({ + env: { + APP_DB: createWorkflowRunsDatabase({ activeCount: 100 }), + DYNAMIC_CALLABLE_WORKFLOWS: existingOverLimitBinding.workflow, + } as Env, + userId: 'user-1', + body: { + code: 'export default async function main() { return { ok: true } }', + idempotencyKey: 'existing-over-limit-key', + }, + }), + ).resolves.toMatchObject({ + ok: true, + status: 'waiting', + }) + expect(existingOverLimitBinding.create).not.toHaveBeenCalled() + const perUserBinding = createStatefulWorkflowBinding() const userOne = await createDynamicCallableWorkflow({ env: { diff --git a/packages/worker/src/package-runtime/package-workflows.ts b/packages/worker/src/package-runtime/package-workflows.ts index 656bb0aa11..1345cada34 100644 --- a/packages/worker/src/package-runtime/package-workflows.ts +++ b/packages/worker/src/package-runtime/package-workflows.ts @@ -649,20 +649,6 @@ async function recordWorkflowRun(input: { .run() } -async function deleteWorkflowRun(input: { - db: D1Database - id: string - userId: string -}) { - await input.db - .prepare( - `DELETE FROM workflow_runs - WHERE id = ? AND user_id = ?`, - ) - .bind(input.id, input.userId) - .run() -} - async function updateWorkflowRunStatus(input: { env: Env id: string @@ -794,6 +780,21 @@ export async function createDynamicCallableWorkflow(input: { // workflow_runs projection row is written. includeRunAt: !idempotencyKeyInput, }) + const existing = await getExistingWorkflowInstance( + input.env.DYNAMIC_CALLABLE_WORKFLOWS, + id, + ) + if (existing) { + if (input.env.APP_DB) { + await recordWorkflowRun({ + db: input.env.APP_DB, + id, + payload, + status: existing.status ?? null, + }) + } + return createWorkflowCreateResult({ summary: existing, payload }) + } if (input.env.APP_DB) { await assertWithinEntitlement({ db: input.env.APP_DB, @@ -803,7 +804,6 @@ export async function createDynamicCallableWorkflow(input: { fallbackLimit: getWorkflowConcurrencyBackstop(input.env as Env), }) } - let preRecordedWorkflowRun = false if (input.env.APP_DB && idempotencyKeyInput) { await recordWorkflowRun({ db: input.env.APP_DB, @@ -811,22 +811,6 @@ export async function createDynamicCallableWorkflow(input: { payload, status: 'queued', }) - preRecordedWorkflowRun = true - } - const existing = await getExistingWorkflowInstance( - input.env.DYNAMIC_CALLABLE_WORKFLOWS, - id, - ) - if (existing) { - if (input.env.APP_DB) { - await recordWorkflowRun({ - db: input.env.APP_DB, - id, - payload, - status: existing.status ?? null, - }) - } - return createWorkflowCreateResult({ summary: existing, payload }) } let instance: WorkflowInstance try { @@ -851,13 +835,6 @@ export async function createDynamicCallableWorkflow(input: { }) } } - if (preRecordedWorkflowRun && input.env.APP_DB) { - await deleteWorkflowRun({ - db: input.env.APP_DB, - id, - userId: payload.userId, - }) - } throw error } if (input.env.APP_DB) { From 16188108d05b2af16e4785d8491d50ecab41283e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Jul 2026 23:06:18 +0000 Subject: [PATCH 7/9] Allow workflow retries after create failures Co-authored-by: Kent C. Dodds --- .../package-workflows.node.test.ts | 128 +++++++++++++++--- .../src/package-runtime/package-workflows.ts | 40 +++++- 2 files changed, 147 insertions(+), 21 deletions(-) diff --git a/packages/worker/src/package-runtime/package-workflows.node.test.ts b/packages/worker/src/package-runtime/package-workflows.node.test.ts index 0342cec440..5b1159a51d 100644 --- a/packages/worker/src/package-runtime/package-workflows.node.test.ts +++ b/packages/worker/src/package-runtime/package-workflows.node.test.ts @@ -147,11 +147,17 @@ function createWorkflowRunsDatabase(options?: { ) { const userId = params[0] const idempotencyKey = params[1] + const ignoredStatus = query.includes( + "COALESCE(status, '') != ?", + ) + ? params[2] + : null const matches = [...workflowRuns.values()] .filter( (row) => row['user_id'] === userId && - row['idempotency_key'] === idempotencyKey, + row['idempotency_key'] === idempotencyKey && + row['status'] !== ignoredStatus, ) .sort((left, right) => String(left['created_at']).localeCompare( @@ -178,24 +184,34 @@ function createWorkflowRunsDatabase(options?: { }, async run() { if (query.includes('INSERT INTO workflow_runs')) { - workflowRuns.set(String(params[0]), { - id: params[0], - user_id: params[1], - source_type: params[2], - package_id: params[3], - kody_id: params[4], - source_id: params[5], - workflow_name: params[6], - export_name: params[7], - idempotency_key: params[8], - run_at: params[9], - plan_date: params[10], - status: params[11], - created_at: params[12], - updated_at: params[13], - completed_at: params[14], - last_error: params[15], - }) + const id = String(params[0]) + const existing = workflowRuns.get(id) + if (existing) { + existing['status'] = params[11] + existing['updated_at'] = params[13] + existing['completed_at'] = + params[14] ?? existing['completed_at'] + existing['last_error'] = params[15] ?? existing['last_error'] + } else { + workflowRuns.set(id, { + id: params[0], + user_id: params[1], + source_type: params[2], + package_id: params[3], + kody_id: params[4], + source_id: params[5], + workflow_name: params[6], + export_name: params[7], + idempotency_key: params[8], + run_at: params[9], + plan_date: params[10], + status: params[11], + created_at: params[12], + updated_at: params[13], + completed_at: params[14], + last_error: params[15], + }) + } } if (query.includes('UPDATE workflow_runs')) { const row = workflowRuns.get(String(params[2])) @@ -696,6 +712,7 @@ test('createDynamicCallableWorkflow dedupes queued runs by user and idempotency id: input.id, idempotency_key: 'inline-pre-projection-key', run_at: '2026-05-08T19:30:00.000Z', + status: 'creating', }), ]) preProjectionInstances.set(input.id, input) @@ -769,6 +786,79 @@ test('createDynamicCallableWorkflow dedupes queued runs by user and idempotency }) expect(existingOverLimitBinding.create).not.toHaveBeenCalled() + const failedCreateDb = createWorkflowRunsDatabase() + const failedCreateInstances = new Map() + let shouldFailCreate = true + const retryCreate = vi.fn(async (input: WorkflowInstanceCreateOptions) => { + if (shouldFailCreate) { + shouldFailCreate = false + throw new Error('transient workflow create failure') + } + failedCreateInstances.set(input.id, input) + return { + id: input.id, + status: async () => ({ status: 'queued' }), + } as WorkflowInstance + }) + const retryGet = vi.fn(async (id: string) => { + if (!failedCreateInstances.has(id)) { + throw new Error('workflow instance does not exist') + } + return { + id, + status: async () => ({ status: 'waiting' }), + } as WorkflowInstance + }) + const failedCreateEnv = { + APP_DB: failedCreateDb, + DYNAMIC_CALLABLE_WORKFLOWS: { + get: retryGet, + create: retryCreate, + } as unknown as Workflow, + } as Env + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-05-08T19:30:00.000Z')) + await expect( + createDynamicCallableWorkflow({ + env: failedCreateEnv, + userId: 'user-1', + body: { + code: 'export default async function main() { return { ok: true } }', + idempotencyKey: 'failed-create-retry-key', + }, + }), + ).rejects.toThrow('transient workflow create failure') + expect([...failedCreateDb.workflowRuns.values()]).toEqual([ + expect.objectContaining({ + idempotency_key: 'failed-create-retry-key', + run_at: '2026-05-08T19:30:00.000Z', + status: 'creating', + }), + ]) + vi.setSystemTime(new Date('2026-05-08T19:31:00.000Z')) + const retryAfterFailure = await createDynamicCallableWorkflow({ + env: failedCreateEnv, + userId: 'user-1', + body: { + code: 'export default async function main() { return { ok: true } }', + idempotencyKey: 'failed-create-retry-key', + }, + }) + + expect(retryAfterFailure.run_at).toBe('2026-05-08T19:30:00.000Z') + expect(retryCreate).toHaveBeenCalledTimes(2) + expect([...failedCreateDb.workflowRuns.values()]).toEqual([ + expect.objectContaining({ + idempotency_key: 'failed-create-retry-key', + run_at: '2026-05-08T19:30:00.000Z', + status: 'queued', + }), + ]) + } finally { + vi.useRealTimers() + } + const perUserBinding = createStatefulWorkflowBinding() const userOne = await createDynamicCallableWorkflow({ env: { diff --git a/packages/worker/src/package-runtime/package-workflows.ts b/packages/worker/src/package-runtime/package-workflows.ts index 1345cada34..7bcf584890 100644 --- a/packages/worker/src/package-runtime/package-workflows.ts +++ b/packages/worker/src/package-runtime/package-workflows.ts @@ -142,6 +142,7 @@ type DynamicCallableWorkflowStep = { const packageWorkflowTokenId = 'internal:package-workflows' const maxPackageWorkflowParamsJsonBytes = 16 * 1024 const workflowStatusRefreshTtlMs = 30_000 +const creatingWorkflowRunStatus = 'creating' const knownWorkflowStatusValues = [ ...activeWorkflowStatusValues, ...terminalWorkflowStatusValues, @@ -512,10 +513,11 @@ async function findWorkflowRunByIdempotencyKey(input: { `SELECT * FROM workflow_runs WHERE user_id = ? AND idempotency_key = ? + AND COALESCE(status, '') != ? ORDER BY created_at ASC LIMIT 1`, ) - .bind(input.userId, trimmedKey) + .bind(input.userId, trimmedKey, creatingWorkflowRunStatus) .first>() if (!result) return null return mapWorkflowRunRow(result) @@ -792,6 +794,14 @@ export async function createDynamicCallableWorkflow(input: { payload, status: existing.status ?? null, }) + if (idempotencyKeyInput) { + const projectedRun = await findWorkflowRunByIdempotencyKey({ + db: input.env.APP_DB, + userId: input.userId, + idempotencyKey: idempotencyKeyInput, + }) + if (projectedRun) return createWorkflowCreateResultFromRow(projectedRun) + } } return createWorkflowCreateResult({ summary: existing, payload }) } @@ -809,7 +819,7 @@ export async function createDynamicCallableWorkflow(input: { db: input.env.APP_DB, id, payload, - status: 'queued', + status: creatingWorkflowRunStatus, }) } let instance: WorkflowInstance @@ -829,6 +839,24 @@ export async function createDynamicCallableWorkflow(input: { id, ) if (concurrent) { + if (input.env.APP_DB) { + await recordWorkflowRun({ + db: input.env.APP_DB, + id, + payload, + status: concurrent.status ?? null, + }) + if (idempotencyKeyInput) { + const projectedRun = await findWorkflowRunByIdempotencyKey({ + db: input.env.APP_DB, + userId: input.userId, + idempotencyKey: idempotencyKeyInput, + }) + if (projectedRun) { + return createWorkflowCreateResultFromRow(projectedRun) + } + } + } return createWorkflowCreateResult({ summary: concurrent, payload, @@ -854,6 +882,14 @@ export async function createDynamicCallableWorkflow(input: { status: summary.status, }) } + if (input.env.APP_DB && idempotencyKeyInput) { + const projectedRun = await findWorkflowRunByIdempotencyKey({ + db: input.env.APP_DB, + userId: input.userId, + idempotencyKey: idempotencyKeyInput, + }) + if (projectedRun) return createWorkflowCreateResultFromRow(projectedRun) + } return createWorkflowCreateResult({ summary, payload }) } From d5e5930c5b3aee48773a2dfdcde12ecebb255a6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Jul 2026 23:14:07 +0000 Subject: [PATCH 8/9] Guide agents toward workflows for long-running work Co-authored-by: Kent C. Dodds --- docs/contributing/cloudflare-agents-sdk.md | 3 ++ docs/use/execute.md | 10 ++++- docs/use/workflows.md | 43 +++++++++++-------- .../mcp/capabilities/jobs/workflow-list.ts | 4 +- .../worker/src/mcp/server-instructions.ts | 4 +- packages/worker/src/mcp/tools/execute.ts | 1 + 6 files changed, 43 insertions(+), 22 deletions(-) diff --git a/docs/contributing/cloudflare-agents-sdk.md b/docs/contributing/cloudflare-agents-sdk.md index 0d6b1cd7b3..d919197743 100644 --- a/docs/contributing/cloudflare-agents-sdk.md +++ b/docs/contributing/cloudflare-agents-sdk.md @@ -186,6 +186,9 @@ Use this section when you want to answer "can Agents do X?" quickly. - Run Cloudflare Workflows from an Agent (`this.runWorkflow(...)`) and handle progress/completion callbacks. Docs: https://developers.cloudflare.com/agents/api-reference/run-workflows/ +- In Kody runtime code, use `workflows.create` for durable batch sweeps, + migrations, polling loops, or retryable work that may outlive execute's + timeout; inspect runs with `workflow_run_list`. ### LLM integration (bring your model/provider) diff --git a/docs/use/execute.md b/docs/use/execute.md index 980b6011a0..9e2da3e40e 100644 --- a/docs/use/execute.md +++ b/docs/use/execute.md @@ -32,8 +32,9 @@ helpers are runtime exports: bound to a storage id - use **`import { workflows } from 'kody:runtime'`** to queue Cloudflare Workflows from execute calls, ad hoc jobs, package jobs, package - subscriptions, package services, and package exports. See - [Workflows](./workflows.md) + subscriptions, package services, and package exports. Prefer workflows for + durable batch sweeps, migrations, polling loops, retryable steps, or work that + may run longer than execute's timeout. See [Workflows](./workflows.md) - use **`import { packageContext } from 'kody:runtime'`** inside saved package code when you need package metadata; it is **`null`** for ad hoc execute calls - use **`import { packages } from 'kody:runtime'`** inside saved package runtime @@ -96,6 +97,11 @@ several capabilities or package exports, branch on results, and return the final structured result. Split into multiple **execute** calls only when you need new user input, confirmation, or a result that changes the plan. +Plain **execute** has a hard timeout (~90s by default). For multi-step or +long-running work (>~60s, batch sweeps, migrations, polling loops), use one +**execute** call to submit `workflows.create({ code, params })`, then inspect +progress with `workflow_run_list` instead of chaining many MCP tool calls. + To read field shapes while coding, use **search** with **`entity: "{name}:capability"`** for builtin capability type definitions, or inspect the relevant saved package with **`entity: "{kody_id}:package"`**. diff --git a/docs/use/workflows.md b/docs/use/workflows.md index 229ae5fc2b..0135964287 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -4,19 +4,32 @@ Kody exposes Cloudflare Workflows through `kody:runtime` in every server-side runtime context: `execute`, ad hoc scheduled jobs, saved package jobs, package subscriptions, package exports, and package services. +Use workflows instead of plain `execute` for durable batch sweeps, migrations, +polling loops, retryable steps, or work that may run longer than execute's +timeout. The initial `execute` call only queues the workflow; inspect it later +with `workflow_run_list`. + ```ts import { workflows } from 'kody:runtime' -export default async function main() { +export default async function main(input = {}) { return await workflows.create({ - runAt: new Date(Date.now() + 60_000).toISOString(), - idempotencyKey: `execute-smoke-${Date.now()}`, code: 'export default async function main(p) { return { ok: true, p } }', - params: { greeting: 'hello' }, + params: { greeting: input.greeting ?? 'hello' }, }) } ``` +Check status from a later MCP call: + +```ts +import { kody } from 'kody:runtime' + +export default async function main() { + return await kody.workflow_run_list({ limit: 10 }) +} +``` + `workflows.create` accepts one durable workflow request with two source shapes: - `code`: a complete ESM module string with a default export. Kody runs it later @@ -25,22 +38,18 @@ export default async function main() { Kody resolves `packageId` from `packageContext`. Outside a package runtime, pass `packageId` explicitly. -Both shapes require: +Both shapes accept: -- `runAt`: ISO date-time string or `Date` -- `idempotencyKey`: caller-chosen dedupe key +- `runAt`: optional ISO date-time string or `Date`; defaults to now +- `idempotencyKey`: optional caller-chosen dedupe key; omitted keys create a + fresh run - `params`: optional JSON object passed to the workflow body -Calling `create` again with the same dedupe fields returns the existing workflow -instead of starting a duplicate. For inline code, repeat the same signed-in -user, source type (`code`), workflow name (or the default `inline-code`), -`idempotencyKey`, and `runAt`. For package exports, repeat the same signed-in -user, package, workflow name (or export name fallback), `idempotencyKey`, and -`runAt`. For example, retrying `code` with `idempotencyKey: "execute-smoke-123"` -and `runAt: "2026-05-08T18:00:00.000Z"` returns the same workflow only when -those dedupe fields are repeated. Kody enforces a per-user concurrent workflow -limit (default: 100); if the cap is reached, `workflows.create` returns a clear -quota error. +Calling `create` again with the same explicit `idempotencyKey` for the same user +returns the existing workflow instead of starting a duplicate. Choose keys that +include the logical job identity, for example `storage-sweep:2026-05-08`. Kody +enforces a per-user concurrent workflow limit (default: 100); if the cap is +reached, `workflows.create` returns a clear quota error. Use `workflow_run_list` to inspect recent workflow runs and statuses. diff --git a/packages/worker/src/mcp/capabilities/jobs/workflow-list.ts b/packages/worker/src/mcp/capabilities/jobs/workflow-list.ts index ce7a38668f..b4352db23a 100644 --- a/packages/worker/src/mcp/capabilities/jobs/workflow-list.ts +++ b/packages/worker/src/mcp/capabilities/jobs/workflow-list.ts @@ -38,7 +38,7 @@ export const workflowListCapability = defineDomainCapability( { name: 'workflow_run_list', description: - 'List recent Cloudflare Workflow runs created through kody:runtime workflows.create, including source, status, and completion/error metadata.', + 'List recent Cloudflare Workflow runs created through kody:runtime workflows.create for durable work that may outlive execute timeouts, including source, status, and completion/error metadata.', keywords: [ 'workflow', 'workflows', @@ -47,6 +47,8 @@ export const workflowListCapability = defineDomainCapability( 'debug', 'status', 'background', + 'timeout', + 'migration', ], readOnly: true, idempotent: true, diff --git a/packages/worker/src/mcp/server-instructions.ts b/packages/worker/src/mcp/server-instructions.ts index dd5be13f35..d5c8c213b3 100644 --- a/packages/worker/src/mcp/server-instructions.ts +++ b/packages/worker/src/mcp/server-instructions.ts @@ -72,7 +72,7 @@ Conventions - \`job_update\`: update an existing scheduled job by id. Supports safe mutable fields such as name, code, params, schedule, timezone, enabled, and kill-switch state. Providing \`code\` publishes a new commit on the job's repo-backed source (the simplest way to change the job module for non-package jobs); the replacement must default export a function and read \`params\` from its first argument, not from \`kody:runtime\`. - \`job_delete\`: delete one of the signed-in user's scheduled jobs by id. - \`job_run_now\`: run an existing scheduled job immediately by id and return the updated job view plus execution result for debugging. -- \`workflow_run_list\`: inspect recent Cloudflare Workflow runs created through \`kody:runtime\` \`workflows.create\`, including inline-code and saved-package export workflows. +- \`workflow_run_list\`: inspect recent Cloudflare Workflow runs created through \`kody:runtime\` \`workflows.create\`; use it after durable long-running work such as batch sweeps, migrations, and polling loops. - Package jobs are schedules owned by a package. For ad hoc work that is not tied to a package, use \`job_schedule\`. Package apps are optional UI surfaces declared by the package, and packages may also declare headless package services for long-lived background runtimes. - Package apps can also use Kody-managed realtime websocket sessions through \`session_list\`, \`session_emit\`, and \`session_broadcast\`, and package jobs can emit to those sessions when running under the same package caller context. - Package services are inspected and controlled through \`service_list\`, \`service_get\`, \`service_start\`, and \`service_stop\`. @@ -97,7 +97,7 @@ search - search({ entity: 'coding_guide_get:capability' }) execute -- Single ESM module string with a default export such as \`export default async function main(input = {}) { ... }\`; \`params\` are passed as the first argument. Import runtime APIs from \`kody:runtime\`. Example: \`import { kody, refreshAccessToken, createAuthenticatedFetch, oauthClientCredentials, secretHeaders, workflows } from 'kody:runtime'\`. Built-in capabilities returned by \`search\` are available through \`kody\`: use \`await kody.capability_id(input)\` for valid identifier names or \`await kody["capability-id"](input)\` for non-identifier ids. Remote connector capabilities are deliberately separate: use \`await kody.remote["name"].capability_name(input)\` (for example \`kody.remote["home"].set_pin({ pin })\`), never a flat \`kody.kind_instance_capability(...)\` call. \`workflows.create\` can queue either inline \`code\` or a saved-package \`exportName\` through the shared Workflow hub. Use \`secretHeaders.basic(...)\` or \`oauthClientCredentials(...)\` for client-credentials Basic Auth instead of asking users to precompute a Basic header. Prefer one \`execute\` when the plan is clear. Full rules for \`fetch\`, placeholders, \`secret_list\` / \`value_get\`, and \`x-kody-secret\`: see the \`execute\` tool description. +- Single ESM module string with a default export such as \`export default async function main(input = {}) { ... }\`; \`params\` are passed as the first argument. Import runtime APIs from \`kody:runtime\`. Example: \`import { kody, refreshAccessToken, createAuthenticatedFetch, oauthClientCredentials, secretHeaders, workflows } from 'kody:runtime'\`. Built-in capabilities returned by \`search\` are available through \`kody\`: use \`await kody.capability_id(input)\` for valid identifier names or \`await kody["capability-id"](input)\` for non-identifier ids. Remote connector capabilities are deliberately separate: use \`await kody.remote["name"].capability_name(input)\` (for example \`kody.remote["home"].set_pin({ pin })\`), never a flat \`kody.kind_instance_capability(...)\` call. \`workflows.create\` can queue inline \`code\` or a saved-package \`exportName\`; prefer it for durable, retryable, inspectable work that may outlive execute's timeout, while plain execute is for quick single operations. Use \`secretHeaders.basic(...)\` or \`oauthClientCredentials(...)\` for client-credentials Basic Auth instead of asking users to precompute a Basic header. Prefer one \`execute\` when the plan is clear. Full rules for \`fetch\`, placeholders, \`secret_list\` / \`value_get\`, and \`x-kody-secret\`: see the \`execute\` tool description. - Cross-package imports use specifiers such as \`kody:@scope/my-package/export-name\`. For dynamic current-version calls inside package runtime code or authenticated execute calls, prefer \`packages.invokeChecked({ kodyId, exportName, params })\` or \`packages.check(...)\` followed by \`packages.invoke(check.invoke)\`; use static imports for library-like bundled snapshots. Saved package names must be scoped (\`@scope/\`) and the leaf segment must match \`kody.id\`. Package jobs are owned by packages, ad hoc jobs can be scheduled with \`job_schedule\`, and package apps are optional package surfaces. - Official how-to guides from the Kody repo: when creating or materially changing a package, call \`coding_guide_get\` with \`guide: "package_authoring"\`. If the package, package app, or workflow depends on a third-party integration, secrets, or OAuth, call \`guide: "integration_bootstrap"\` before building the dependent package. If unsure, \`search\` for this capability and load the right guide before implementing. - Do not save or present an auth-dependent package as complete until \`search\` shows the required integration or secret reference exists and a minimal authenticated \`execute\` smoke test succeeds. diff --git a/packages/worker/src/mcp/tools/execute.ts b/packages/worker/src/mcp/tools/execute.ts index 793b4241f8..d33557c7b9 100644 --- a/packages/worker/src/mcp/tools/execute.ts +++ b/packages/worker/src/mcp/tools/execute.ts @@ -52,6 +52,7 @@ Sandbox surface: - \`import { storage } from 'kody:runtime'\` for durable storage helpers on the bound \`storageId\`, including \`storage.sql(query, params?)\`. \`storage.sql\` returns \`{ columns, rows, rowCount, rowsRead, rowsWritten }\`; read query rows from \`.rows\`. - \`import { refreshAccessToken, createAuthenticatedFetch, oauthClientCredentials, secretHeaders } from 'kody:runtime'\` for OAuth integrations and secret-derived auth headers. Integration \`name\` may be account-specific (e.g. \`google-personal\`, \`google-business\`); call \`integration_list\` first when the task involves a provider that may have multiple accounts connected. For APIs such as PayPal that require client-credentials Basic Auth, save the client id and client secret separately and use \`secretHeaders.basic({ usernameSecret: 'paypalClientId', passwordSecret: 'paypalClientSecret', scope: 'user' })\` in the Authorization header, or \`oauthClientCredentials(...)\` for the token request. Do not ask users to precompute or save a derived Basic header. - \`import { workflows } from 'kody:runtime'\` for durable Cloudflare Workflows. \`workflows.create\` accepts either inline \`code\` or a saved-package \`exportName\`; use \`workflow_run_list\` to inspect recent runs. +- Execute has a hard timeout (~90s by default). For batch sweeps, migrations, polling loops, or work likely to run >~60s, submit one durable \`workflows.create({ code, params })\` from a single execute call instead of chaining many MCP round-trips. - Optional \`params\` are passed as the first argument to the module default export. Prefer \`export default async function main(input = {}) { ... }\`; pass \`input\` to shared helpers explicitly. - \`import { packageContext } from 'kody:runtime'\` in saved package code when you need package metadata; it is \`null\` for ad hoc execute calls. - \`import { packages } from 'kody:runtime'\` exposes \`packages.check(...)\`, \`packages.invoke(...)\`, and \`packages.invokeChecked(...)\` in saved package runtime contexts and authenticated ad hoc execute calls. Prefer \`invokeChecked\` for dynamic current-version package calls unless you already called \`check\` and pass \`check.invoke\` to \`invoke\`. From c0f5618e5ce9228d44f1f7ee96e8ce54b33b0e1d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Jul 2026 23:24:24 +0000 Subject: [PATCH 9/9] Clarify workflow guidance wording Co-authored-by: Kent C. Dodds --- docs/use/workflows.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/use/workflows.md b/docs/use/workflows.md index 0135964287..14256815af 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -6,8 +6,8 @@ subscriptions, package exports, and package services. Use workflows instead of plain `execute` for durable batch sweeps, migrations, polling loops, retryable steps, or work that may run longer than execute's -timeout. The initial `execute` call only queues the workflow; inspect it later -with `workflow_run_list`. +timeout. The initial `execute` call should submit one `workflows.create`; +inspect that workflow later with `workflow_run_list`. ```ts import { workflows } from 'kody:runtime' @@ -45,11 +45,12 @@ Both shapes accept: fresh run - `params`: optional JSON object passed to the workflow body -Calling `create` again with the same explicit `idempotencyKey` for the same user -returns the existing workflow instead of starting a duplicate. Choose keys that -include the logical job identity, for example `storage-sweep:2026-05-08`. Kody -enforces a per-user concurrent workflow limit (default: 100); if the cap is -reached, `workflows.create` returns a clear quota error. +Calling `create` again with the same explicit `idempotencyKey` and matching +workflow identity for the same user returns the existing workflow instead of +starting a duplicate. Choose keys that include the logical job identity, for +example `storage-sweep:2026-05-08`. Kody enforces a per-user concurrent workflow +limit (default: 100); if the cap is reached, `workflows.create` returns a clear +quota error. Use `workflow_run_list` to inspect recent workflow runs and statuses.