Compute
Workflows
Run Cloudflare Workflows with OpenOrange on Cloudflare's own Workflows engine.
Status: Partial. Orange runs Cloudflare’s own Workflows engine, with one Rivet Actor for each instance. A few APIs are missing. See What is partial.
Configuration
OpenOrange reads the standard Wrangler Workflow binding. Export the class from the Worker. No extra setup is needed:
{
"workflows": [
{ "binding": "MY_WORKFLOW", "name": "my-workflow", "class_name": "MyWorkflow" }
]
}
import { WorkflowEntrypoint } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";
export class MyWorkflow extends WorkflowEntrypoint {
async run(event, step) {
const order = await step.do("charge", { retries: { limit: 3, delay: "1 second" } }, async () => {
return { charged: event.payload.amount };
});
await step.sleep("cool down", "10 minutes");
const approval = await step.waitForEvent("approval", { type: "approved", timeout: "1 hour" });
return { order, approver: approval.payload.by };
}
}
Bindings with the same name share instances. A binding with a script_name
of another Worker runs the Workflow in that Worker. Run both Workers in one
orange dev. See Multiple Workers.
How it works
The engine is Cloudflare’s. Orange did not write its own:
- The code in
packages/runtime-node/src/bindings/workflows/engine/is copied from Cloudflare’s@cloudflare/workflows-shared0.15.1. This is the engine thatwrangler devruns. - The
LICENSEfile in that folder names the source commit and the license. Each Orange change has anOPEN-ORANGEcomment. - The Orange changes connect the engine to Rivet. They keep its storage in the actor, wake it with a Durable Object alarm, and use Rivet’s limits in place of Cloudflare’s quotas.
When you call env.MY_WORKFLOW.create({ id, params }):
- Orange sends a Durable Object RPC call to the engine object of
[workflow name, id]. This is one Rivet Actor of the internal classopenorange:workflow-engine. - The engine stores the instance and sets an alarm for now.
- The alarm runs
MyWorkflow.run(event, step). - The engine keeps step results, sleeps, and events in the actor’s SQLite storage. The earliest timer is the actor’s alarm.
- The actor can sleep during
step.sleep(), a retry delay, orstep.waitForEvent(). The alarm or asendEvent()starts the run again. - The run replays: completed steps return their stored results, and a sleep waits only for the time that is left.
While a step runs, or while run() runs code between steps, the run keeps its
actor awake. The alarm is then at most 5 seconds away. So if a forced sleep, a
crash, or a restart stops a run in the middle of a step, the run starts again
in about 5 seconds. The step runs again, as Cloudflare retries a step whose
engine stopped.
The binding implements create(), createBatch() (both forms), get(),
deleteBatch(), and on an instance status(), pause(), resume(),
terminate(), restart() (also { from: { name, count, type } }),
delete(), and sendEvent(). All 8 statuses occur: queued, running,
paused, waitingForPause, waiting, errored, terminated, and
complete. Cloudflare’s open engine never sets waiting, so OpenOrange sets
it while every step call in flight is a sleep or an event wait, as Cloudflare
reports it while an instance hibernates.
The step argument implements step.do() with retries, backoff, and
timeouts, and step.sleep(), step.sleepUntil(), and step.waitForEvent().
Steps awaited together, for example with Promise.all(), run at the same
time. There is no step limit. A retry delay function, sensitive: "output",
rollback handlers, and a ReadableStream step output come with the engine, but
no OpenOrange test covers them yet.
Errors keep their names, as workerd’s RPC keeps them with
enhanced_error_serialization, which Orange’s fixed compatibility profile
turns on. For example, a step that
throws a CustomError("boom") errors the instance with { name: "CustomError", message: "boom" }, and a waitForEvent() timeout throws a
WorkflowTimeoutError.
Conformance
src/runtime/workflows.test.ts drives a fixture Worker through orange dev.
The expected values are what the same Worker shows under wrangler dev, apart
from the differences below. It covers:
- Terminate during a step and after the actor sleeps
- Pause during a sleep and during a step, and resume
- Restart from the start, from a step, and during a step
- Parallel steps
- An event sent before its wait, and an event larger than Cloudflare’s 1 MiB
- Wait timeouts
- Two instances with the same step names
- A sleep that ends from the alarm with no requests
- A step longer than the idle sleep timeout
- Failed steps
- Delete and
deleteBatch() - Duplicate and concurrent IDs
- A sleep across an
orange devrestart
The upstream corpus runs Cloudflare’s workflows-starter template.
What is partial
The engine runs every step API. These parts of the Workflows API are missing:
instance.subscribe()is not implemented. The engine has it, but the binding does not expose it.limits.stepsin the Wrangler configuration is not read. Orange has no step limit. Cloudflare stops an instance at 10,000 steps by default.retentionincreate()is validated but not applied. An instance stays until you calldelete().status()has no__LOCAL_DEV_STEP_OUTPUTSfield, which onlywrangler devadds.- The test helpers of
vitest-pool-workers(introspectWorkflow()and the instance modifier) are not available. - There is no
orange workflowscommand to list or manage instances.
Limits and deviations
- Rivet’s limits apply, not Cloudflare’s 1 MiB:
create()params and asendEvent()payload cross one Durable Object RPC call. One call is at most 32 MiB on the wire, and binary data counts 4/3 of its size. A larger call throws aRangeError, and the instance stays as it was.- A step output and the instance output are stored in one SQLite commit of
at most 128 MiB. A larger step output errors the instance with
Step <name> output is too large to store.
- Errors keep their names.
wrangler devruns its engine on the 2024-10-22 compatibility date, so there a custom name becomesError, and an error that Workflow code throws gets its name in front of its message. create()with an ID that exists throwsWorkflowError: (instance.already_exists) Workflow instance with id "<id>" already exists, also when two creates of one ID run at the same time. Thewrangler devof the pinned Wrangler does not reject a duplicate.- A
NonRetryableErrorthatrun()does not catch errors the instance with its own name and message. Orange’s fixed compatibility profile turns onworkflows_preserve_non_retryable_error_message.wrangler devalways shows the genericWorkflowFatalError. terminate(),restart(), anddelete()cannot stop a step callback that is already running, because the Worker and the engine share one process. Its result is discarded, and the run does not reach its next step.- The actor can sleep while every step call in flight is a sleep or an event
wait. Code that runs at the same time outside steps, for example a
fetch()awaited together withstep.sleep(), can be cut off and runs again on the next replay. - A Durable Object response larger than one tunnel message, such as a
status()with an output of hundreds of KiB, can be cut off under load withTypeError: terminated. This is a RivetKit bug.
Compatibility
From the latest conformance run. Scores on the compatibility page; every binding on the status page.
Workflows
workflows Runs on Cloudflare's own Workflows engine, copied from @cloudflare/workflows-shared 0.15.1, with one Rivet Actor per instance.
create, get, status, sendEvent, steps, terminate, pause, resume, restart, and delete work, also with script_name across Workers in one session. terminate cannot stop a step callback that is already running.
Scales by Instance. One actor per instance.
Recorded results
WorksWorkflowsAvailable.Steps, retries, sleeps, events, terminate, pause, resume, and restart work. terminate cannot stop a step callback that is already running. 6/6 pass
- Results
- example: workflows Pass
- lifecycle: Workflows: create, step, status, complete Pass workerd pass
- lifecycle: Workflows: sendEvent releases a waiting instance Pass workerd pass
- lifecycle: Workflows: terminate() ends a waiting instance Pass workerd pass
- lifecycle: Workflows: pause() and resume() Pass workerd pass
- lifecycle: Workflows: restart() runs a finished instance again Pass workerd pass
In the feature matrix
Alternativeworkflows (workflow)Creates and controls Workflow instances that run durable steps, sleeps, and event waits.Each instance is a Durable Object of the Orange-internal class openorange:workflow-engine on a Rivet Actor, running Cloudflare's Workflows engine from workers-sdk (workflows-shared at 4a5377fc), the engine of wrangler dev. create(), createBatch(), get(), deleteBatch(), and status(), pause(), resume(), terminate(), restart() (also from a step), delete(), and sendEvent() work. Steps awaited together run concurrently. The earliest timer is the object's alarm, so instances continue after their actor sleeps and across orange dev restarts. 7 files
- Notes
- Step output is bounded by Rivet's 128 MiB commit, not Cloudflare's 1 MiB. A duplicate ID is rejected, also for concurrent creates. Errors keep their names, as workerd's RPC keeps them from compatibility date 2026-04-21; wrangler dev's engine runs on 2024-10-22 and renames them. The status waiting is reported whenever the run only waits. limits.steps and subscribe() are not implemented. terminate() cannot stop a step callback that is already running. A binding with another Worker's script_name runs the Workflow in that Worker of the orange dev session. See docs/modules/workflows.mdx.
- Source
- src/config/load-wrangler.ts:842-851packages/runtime-node/src/runner.ts:599-645packages/runtime-node/src/bindings/workflows/binding.ts:92-346packages/runtime-node/src/bindings/workflows/engine-object.ts:222-542packages/runtime-node/src/bindings/workflows/engine-wake.ts:1-35src/runtime/workflows.test.ts:56-579tests/examples.test.ts:175-215