@odla-ai/workflows
v0.1.1
Published
Effect-safe helpers for Cloudflare Workflows: deterministic identities, idempotency boundaries, checkpoint validation, and a portable outbox contract and reconciler.
Maintainers
Readme
@odla-ai/workflows
⚠️ Experimental — an agentic-coding experiment. APIs can change before 1.0. Exercise the failure paths of your recipient and storage adapter before relying on this package for production mutations.
Effect-safety primitives for
Cloudflare Workflows: replay-
stable workflow/effect identities, canonical input fingerprints, an explicit
idempotency boundary, checkpoint validation, and a portable outbox reconciler.
The package uses only Web-standard APIs and does not import Node built-ins or
cloudflare:workers.
The guarantee, precisely
This package does not promise exactly-once execution. Cloudflare can retry a step, a Worker can stop after a remote service accepts a request, and an outbox lease can expire before the success checkpoint is written. Those cases can all produce another delivery attempt.
What the package provides is:
- deterministic IDs and SHA-256 fingerprints for plain JSON-like inputs;
- the same idempotency key on every retry of one logical effect;
- an adapter boundary that always supplies that key alongside the input;
- bounded Workflow retry/timeout policy;
- runtime rejection of lossy checkpoint values (
undefined,NaN, dates, class instances, sparse arrays, accessors, cycles, and so on); - enforcement of Cloudflare's 1 MiB non-stream checkpoint ceiling;
- atomic-lease semantics for portable outbox storage adapters; and
- bounded reconciliation with retry scheduling and dead-letter state.
Your side-effect adapter must propagate idempotencyKey using the remote
provider's idempotency header or field, and that provider must actually dedupe
it. Without recipient-side deduplication, retries are at-least-once and can
duplicate the mutation.
Install
npm view @odla-ai/[email protected] version
npm install --save-exact @odla-ai/[email protected]Require the exact-version lookup to succeed first. An E404 means this release
is unavailable; it does not prove the package name itself is absent.
A replay-safe Workflow effect
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
import {
asWorkflowStep,
createWorkflowIdentity,
runIdempotentEffect,
} from "@odla-ai/workflows";
interface SignupPayload {
userId: string;
email: string;
}
export class SignupWorkflow extends WorkflowEntrypoint<Env, SignupPayload> {
async run(event: WorkflowEvent<SignupPayload>, step: WorkflowStep) {
const identity = await createWorkflowIdentity("signup", event.payload, 1);
return runIdempotentEffect(
asWorkflowStep(step),
{
workflowId: identity.workflowId,
name: "welcome-email",
input: { userId: event.payload.userId, email: event.payload.email },
config: {
retries: { limit: 4, delay: "5 seconds", backoff: "exponential" },
timeout: "1 minute",
},
},
{
execute: async ({ input, idempotencyKey, attempt }) => {
const response = await fetch("https://mailer.example/send", {
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key": idempotencyKey, // required recipient boundary
},
body: JSON.stringify(input),
});
if (!response.ok) throw new Error(`mailer rejected request (${response.status})`);
console.log("welcome email accepted", { attempt });
const body = await response.json<{ requestId: string }>();
return { requestId: body.requestId }; // plain checkpoint-safe data
},
},
);
}
}asWorkflowStep is a type-only bridge. Cloudflare's recursive serializability
generic can otherwise overflow TypeScript when composed with rich application
types; no runtime wrapper is introduced.
The effect config intentionally accepts a fixed retry delay rather than a
dynamic delay callback. Numeric delays/timeouts are milliseconds. Retry limits
are checked against Cloudflare's 10,000 ceiling, timeout values above 30 minutes
are rejected, and the optional sensitive: "output" flag is passed through.
name + version + canonical input identifies a workflow. workflowId + effect
name + canonical effect input identifies an effect. Increment the workflow
version when a semantic change must create new identities. Do not put random
values or replay-time timestamps into identity inputs unless they are part of
the original durable request.
Checkpoint-safe data
The accepted data model is finite JSON data: null, booleans, finite numbers,
strings, dense arrays, and plain objects with enumerable data properties. Its
serialized UTF-8 representation must fit within 1 MiB. Named TypeScript
interfaces are accepted; validation happens before a side effect and again on
the entire receipt before its result is checkpointed.
Invalid data throws CheckpointValueError, whose path identifies the failing
value. Use assertCheckpointValue at additional persistence boundaries when a
value does not pass through the built-in effect or outbox helpers.
import { checkpoint, fingerprint } from "@odla-ai/workflows";
const value = checkpoint({ z: 2, a: { ok: true } });
await fingerprint(value); // same hash if object keys arrive in another orderThis is deliberately narrower than Cloudflare's full structured-clone and stream support so identities remain portable and canonical. Store large data in R2 and checkpoint a reference. See Cloudflare's Workflow limits.
The hash prevents cleartext payloads from appearing in IDs, but it is not an encryption or secrecy mechanism. Low-entropy inputs can be guessed. An idempotency key is an identifier, not an authorization credential.
Retry defaults
DEFAULT_EFFECT_CONFIG permits three total attempts with five-second
exponential delay and a two-minute timeout. Pass an explicit config to
runIdempotentEffect when the recipient needs a different bound.
DEFAULT_OUTBOX_RETRY permits eight delivery attempts, beginning at five
seconds and doubling to a one-hour cap. retryDelay(attempt, policy) exposes
the same deterministic, jitter-free calculation for storage adapters and
operational tooling. Jitter, if desired, belongs in a durable schedule chosen
once—not random data recomputed during Workflow replay.
Durable intent and reconciliation
When a mutation also changes local state, persist the outbox intent before
delivery. For a true transactional outbox, your OutboxStore implementation
must insert the intent in the same D1/SQL transaction as the corresponding
domain change. Passing a transaction-scoped store to enqueueOutbox makes that
possible; the library cannot create a cross-service transaction for you.
import {
enqueueOutbox,
reconcileOutbox,
type IdempotentDispatcher,
type OutboxStore,
} from "@odla-ai/workflows";
type EmailIntent = { to: string; template: string };
type EmailReceipt = { providerId: string };
declare const store: OutboxStore<EmailIntent, EmailReceipt>; // D1/DO adapter
await enqueueOutbox(store, {
workflowId,
effectName: "welcome-email",
topic: "email.send",
payload: { to: "[email protected]", template: "welcome" },
now: requestedAtMs, // explicit durable input, not a hidden identity component
});
const dispatcher: IdempotentDispatcher<EmailIntent, EmailReceipt> = {
async deliver({ payload, idempotencyKey, leaseExpiresAt }) {
const remainingMs = leaseExpiresAt - Date.now() - 1_000; // safety margin
if (remainingMs <= 0) throw new Error("outbox lease budget exhausted");
return mailer.send(payload, {
idempotencyKey, // required recipient boundary
signal: AbortSignal.timeout(remainingMs),
});
},
};
const report = await reconcileOutbox({
store,
dispatcher,
owner: `worker:${colo}`,
leaseMs: 60_000,
retry: {
maxAttempts: 8,
initialDelayMs: 5_000,
maxDelayMs: 3_600_000,
multiplier: 2,
},
});Run reconciliation from a scheduled Worker, queue consumer, alarm, or separate Workflow. A delivery crash window looks like this:
claim intent → remote accepts key K → worker stops → lease expires
→ claim again → remote receives K again → recipient dedupes KThe result is eventually recorded as delivered only after complete wins the
current lease. After maxAttempts, the record becomes dead-letter; operators
must inspect and explicitly replay or resolve it. Production adapters can
implement RecoverableOutboxStore.requeueDeadLetter for an atomic replay that
preserves the original recipient idempotency key and resets the attempt budget.
Audit the failure before requeueing; the active row's lastError is cleared.
Implementing OutboxStore
The interface is intentionally storage-neutral. A D1, Durable Object, or SQL adapter must provide these atomic operations:
putIfAbsentinserts by deterministic effect ID, returning the existing record on conflict.claimatomically selects duependingrecords (or expiredin-flightrecords), incrementsattempts, and assigns a unique lease token.complete,retry, anddeadLetteruse compare-and-set semantics on(id, leaseToken)and reject an expired lease by returningfalse.- Every state transition and its new timestamps are committed atomically.
- If supported,
requeueDeadLetteratomically checks terminal state, preserves the identity/idempotency key, clears the active error, and resets attempts.
InMemoryOutboxStore implements those state rules for unit tests and local
single-process use, including the optional dead-letter requeue contract. It is
not durable, not distributed, and must not be used as a production outbox.
import { InMemoryOutboxStore } from "@odla-ai/workflows";
const testStore = new InMemoryOutboxStore<EmailIntent, EmailReceipt>();Operational guidance
- Alert on
deadLettered > 0, sustained retries, andleaseLost > 0. - Keep dead-letter recovery on an authenticated, audited operator plane; never
expose
requeueDeadLetterto untrusted application callers. - The safe default claims one record. With an explicitly larger sequential batch, set the lease longer than the worst-case duration of the entire batch, not just one recipient request.
- Bound recipient I/O against
leaseExpiresAtwith a safety margin. A request that outlives its lease can overlap a later delivery of the same stable key. - Keep batches bounded; the hard interface limit is 1000.
- Return only checkpoint-safe receipts, never SDK response/class instances.
- A storage exception rejects the reconciliation call. Already claimed rows
remain
in-flightuntil their leases expire; alert, retry the reconciler, and keep recipient-side idempotency enabled during recovery. - The default stored error is deliberately generic. Provide
describeErroronly for a scrubbed, low-cardinality error code; never persist raw provider errors that may contain tokens or customer data. - Preserve outbox rows long enough for audit and manual recovery; define your own delivered/dead-letter retention policy in the storage adapter.
Relationship to @odla-ai/kg
@odla-ai/kg retains lightweight step policies for its existing pipelines.
Use this package at mutation boundaries that need deterministic identities,
recipient idempotency, or durable outbox recovery. It is deliberately generic
and has no dependency on the graph, database, AI, or observability packages.
