@voyantjs/workflows-orchestrator-cloudflare 0.28.3 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +23 -11
  2. package/dist/cloudflare-edge-driver.d.ts +49 -0
  3. package/dist/cloudflare-edge-driver.d.ts.map +1 -0
  4. package/dist/cloudflare-edge-driver.js +317 -0
  5. package/dist/dispatchers.d.ts +87 -0
  6. package/dist/dispatchers.d.ts.map +1 -0
  7. package/dist/dispatchers.js +83 -0
  8. package/dist/do-store.d.ts.map +1 -1
  9. package/dist/do-store.js +12 -0
  10. package/dist/durable-object.d.ts +13 -6
  11. package/dist/durable-object.d.ts.map +1 -1
  12. package/dist/durable-object.js +13 -4
  13. package/dist/event-handler.d.ts +23 -0
  14. package/dist/event-handler.d.ts.map +1 -0
  15. package/dist/event-handler.js +241 -0
  16. package/dist/index.d.ts +3 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +14 -6
  19. package/dist/manifest-handler.d.ts +16 -0
  20. package/dist/manifest-handler.d.ts.map +1 -0
  21. package/dist/manifest-handler.js +92 -0
  22. package/dist/manifest-kv-store.d.ts +59 -0
  23. package/dist/manifest-kv-store.d.ts.map +1 -0
  24. package/dist/manifest-kv-store.js +134 -0
  25. package/dist/types.d.ts +7 -15
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/worker.d.ts +19 -0
  28. package/dist/worker.d.ts.map +1 -1
  29. package/dist/worker.js +41 -0
  30. package/package.json +3 -3
  31. package/src/cloudflare-edge-driver.ts +435 -0
  32. package/src/dispatchers.ts +162 -0
  33. package/src/do-store.ts +13 -0
  34. package/src/durable-object.ts +30 -9
  35. package/src/event-handler.ts +302 -0
  36. package/src/index.ts +32 -8
  37. package/src/manifest-handler.ts +113 -0
  38. package/src/manifest-kv-store.ts +186 -0
  39. package/src/types.ts +7 -19
  40. package/src/worker.ts +64 -0
  41. package/dist/dispatch-handler.d.ts +0 -20
  42. package/dist/dispatch-handler.d.ts.map +0 -1
  43. package/dist/dispatch-handler.js +0 -31
  44. package/src/dispatch-handler.ts +0 -51
package/README.md CHANGED
@@ -1,21 +1,36 @@
1
1
  # @voyantjs/workflows-orchestrator-cloudflare
2
2
 
3
3
  Cloudflare Worker + Durable Object adapter for
4
- [`@voyantjs/workflows-orchestrator`](../workflows-orchestrator). Composes the
5
- protocol-agnostic state machine with DO-backed storage and a
6
- Workers-for-Platforms dispatch namespace that fans step requests out
7
- to tenant Workers.
4
+ [`@voyantjs/workflows-orchestrator`](../workflows-orchestrator). Composes
5
+ the protocol-agnostic state machine with DO-backed storage and a
6
+ pluggable **step dispatcher** that delivers step requests to wherever
7
+ workflow code lives.
8
8
 
9
9
  This package is the building block; the deployable artifact lives in
10
- [`apps/workflows-orchestrator-worker`](../../apps/workflows-orchestrator-worker), which
11
- wires it into a `wrangler.jsonc` + default-exports.
10
+ [`apps/workflows-orchestrator-worker`](../../apps/workflows-orchestrator-worker),
11
+ which wires it into a `wrangler.jsonc` + default-exports.
12
+
13
+ ## Picking a dispatcher
14
+
15
+ The orchestrator forwards step requests through a `StepDispatcher`. Pick
16
+ the factory that matches your deployment:
17
+
18
+ | Factory | Use case | Bindings needed |
19
+ |---|---|---|
20
+ | `createInlineDispatcher` | Single-Worker (workflows + API in same isolate) | None |
21
+ | `createServiceBindingDispatcher` | Two-Worker (orchestrator + sibling workflows Worker) | Service binding |
22
+ | `createHttpDispatcher` | Cross-host (e.g. CF orchestrator → Node-side workflows) | HTTP endpoint |
23
+
24
+ Hosted multi-tenant providers implement custom `StepDispatcher`s in
25
+ their own deployment code — multi-tenancy is a deployment concern, not
26
+ a runtime one, so it doesn't ship here.
12
27
 
13
28
  ```ts
14
29
  import {
15
30
  handleWorkerRequest,
16
31
  handleDurableObjectRequest,
17
32
  handleDurableObjectAlarm,
18
- createDispatchStepHandler,
33
+ createServiceBindingDispatcher,
19
34
  } from "@voyantjs/workflows-orchestrator-cloudflare";
20
35
 
21
36
  export default {
@@ -38,10 +53,7 @@ export class WorkflowRunDO implements DurableObject {
38
53
  private deps() {
39
54
  return {
40
55
  storage: this.state.storage,
41
- resolveStepHandler: (tenantScript: string) =>
42
- createDispatchStepHandler(tenantScript, {
43
- dispatcher: this.env.DISPATCHER,
44
- }),
56
+ dispatcher: createServiceBindingDispatcher({ binding: this.env.WORKFLOWS }),
45
57
  };
46
58
  }
47
59
  }
@@ -0,0 +1,49 @@
1
+ import type { EnvironmentName } from "@voyantjs/workflows";
2
+ import type { DriverFactory } from "@voyantjs/workflows/driver";
3
+ import { type KvNamespaceLike } from "./manifest-kv-store.js";
4
+ import type { DurableObjectNamespaceLike } from "./worker.js";
5
+ export interface CloudflareEdgeDriverOptions {
6
+ /** Durable Object namespace holding one DO per run. */
7
+ orchestratorNamespace: DurableObjectNamespaceLike;
8
+ /** KV namespace storing serialized manifests. */
9
+ manifestKv: KvNamespaceLike;
10
+ /**
11
+ * Adapter-specific tenant identifier stamped onto every triggered
12
+ * run as `tenantMeta.tenantScript`. Opaque to the OSS runtime —
13
+ * surfaces on `StepDispatcherContext` for custom dispatchers that
14
+ * need a routing key. Built-in dispatchers (inline, service-binding,
15
+ * HTTP) ignore it.
16
+ */
17
+ tenantScript?: string;
18
+ /** Default environment for `trigger()` calls without an explicit one. */
19
+ defaultEnvironment?: EnvironmentName;
20
+ /** Tenant metadata stamped onto every triggered run. Defaults to "default" tripled. */
21
+ tenantMeta?: {
22
+ tenantId: string;
23
+ projectId: string;
24
+ organizationId: string;
25
+ };
26
+ /** Injectable clock; defaults to Date.now. */
27
+ now?: () => number;
28
+ /** id generator for runs; defaults to `run_<random>`. */
29
+ idGenerator?: () => string;
30
+ /** Optional structured logger; falls back to the framework logger. */
31
+ logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void;
32
+ }
33
+ /**
34
+ * Build the Cloudflare-edge driver factory. The returned `DriverFactory`
35
+ * is invoked once by `createApp()` with `DriverFactoryDeps`.
36
+ *
37
+ * Usage in a Worker template:
38
+ *
39
+ * createApp({
40
+ * workflows: {
41
+ * driver: createCloudflareEdgeDriver({
42
+ * orchestratorNamespace: env.WORKFLOW_RUN_DO,
43
+ * manifestKv: env.WORKFLOW_MANIFESTS,
44
+ * }),
45
+ * },
46
+ * })
47
+ */
48
+ export declare function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): DriverFactory;
49
+ //# sourceMappingURL=cloudflare-edge-driver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cloudflare-edge-driver.d.ts","sourceRoot":"","sources":["../src/cloudflare-edge-driver.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EACV,eAAe,EAMhB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EACV,aAAa,EAOd,MAAM,4BAA4B,CAAA;AAKnC,OAAO,EAGL,KAAK,eAAe,EACrB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAA;AAI7D,MAAM,WAAW,2BAA2B;IAC1C,uDAAuD;IACvD,qBAAqB,EAAE,0BAA0B,CAAA;IACjD,iDAAiD;IACjD,UAAU,EAAE,eAAe,CAAA;IAC3B;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,yEAAyE;IACzE,kBAAkB,CAAC,EAAE,eAAe,CAAA;IACpC,uFAAuF;IACvF,UAAU,CAAC,EAAE;QACX,QAAQ,EAAE,MAAM,CAAA;QAChB,SAAS,EAAE,MAAM,CAAA;QACjB,cAAc,EAAE,MAAM,CAAA;KACvB,CAAA;IACD,8CAA8C;IAC9C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,MAAM,CAAA;IAC1B,sEAAsE;IACtE,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAChF;AAUD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,2BAA2B,GAAG,aAAa,CA8T3F"}
@@ -0,0 +1,317 @@
1
+ // Mode 1 driver — Cloudflare edge composition.
2
+ //
3
+ // `createApp({ workflows: { driver: createCloudflareEdgeDriver({ ... }) } })`
4
+ // is the entry point for any deployment that runs the orchestrator on
5
+ // Cloudflare Workers + Durable Objects. Composes:
6
+ //
7
+ // * `voyant_run_DO` (Durable Object namespace) — primary state
8
+ // * `WORKFLOW_MANIFESTS` KV namespace — manifest store
9
+ //
10
+ // Step delivery is configured separately on the run DO via a
11
+ // `StepDispatcher` — see `./dispatchers.ts` for built-in factories
12
+ // (inline / service binding / HTTP).
13
+ //
14
+ // The factory is invoked by `createApp()` after the framework's
15
+ // `ModuleContainer` is built — see architecture doc §6.3 for the
16
+ // `DriverFactory` contract.
17
+ //
18
+ // See architecture doc §8.
19
+ import { deriveStableEventId } from "@voyantjs/workflows/events";
20
+ import { routeEvent } from "@voyantjs/workflows-orchestrator";
21
+ import { createKvManifestStore, } from "./manifest-kv-store.js";
22
+ const DEFAULT_TENANT_META = {
23
+ tenantId: "default",
24
+ projectId: "default",
25
+ organizationId: "default",
26
+ };
27
+ // ---- Public factory ----
28
+ /**
29
+ * Build the Cloudflare-edge driver factory. The returned `DriverFactory`
30
+ * is invoked once by `createApp()` with `DriverFactoryDeps`.
31
+ *
32
+ * Usage in a Worker template:
33
+ *
34
+ * createApp({
35
+ * workflows: {
36
+ * driver: createCloudflareEdgeDriver({
37
+ * orchestratorNamespace: env.WORKFLOW_RUN_DO,
38
+ * manifestKv: env.WORKFLOW_MANIFESTS,
39
+ * }),
40
+ * },
41
+ * })
42
+ */
43
+ export function createCloudflareEdgeDriver(opts) {
44
+ return (deps) => {
45
+ const manifestStore = createKvManifestStore({ kv: opts.manifestKv });
46
+ const now = opts.now ?? deps.now ?? (() => Date.now());
47
+ const tenantMeta = {
48
+ ...DEFAULT_TENANT_META,
49
+ ...(opts.tenantMeta ?? {}),
50
+ ...(opts.tenantScript ? { tenantScript: opts.tenantScript } : {}),
51
+ };
52
+ const defaultEnv = opts.defaultEnvironment ?? "development";
53
+ const logger = opts.logger ?? deps.logger;
54
+ let shuttingDown = false;
55
+ // ---- Helpers ----
56
+ function assertNotShutdown() {
57
+ if (shuttingDown) {
58
+ throw new Error("CloudflareEdgeDriver: shutdown() has been called; new operations are refused.");
59
+ }
60
+ }
61
+ async function forwardToRunDO(runId, request) {
62
+ const id = opts.orchestratorNamespace.idFromName(runId);
63
+ const stub = opts.orchestratorNamespace.get(id);
64
+ return stub.fetch(request);
65
+ }
66
+ function genRunId(seed) {
67
+ if (seed !== undefined)
68
+ return seed;
69
+ if (opts.idGenerator)
70
+ return opts.idGenerator();
71
+ const ts = now().toString(36);
72
+ const rand = Math.floor(Math.random() * 1_000_000)
73
+ .toString(36)
74
+ .padStart(4, "0");
75
+ return `run_${ts}_${rand}`;
76
+ }
77
+ // ---- WorkflowDriver implementation ----
78
+ async function registerManifest(args) {
79
+ assertNotShutdown();
80
+ return manifestStore.registerManifest({
81
+ environment: args.environment,
82
+ versionId: args.manifest.versionId,
83
+ manifest: args.manifest,
84
+ });
85
+ }
86
+ async function getManifest(args) {
87
+ const envelope = await manifestStore.getCurrent(args.environment);
88
+ if (!envelope)
89
+ return null;
90
+ return envelope.manifest;
91
+ }
92
+ async function trigger(workflow, input, triggerOpts) {
93
+ assertNotShutdown();
94
+ const workflowId = typeof workflow === "string" ? workflow : workflow.id;
95
+ const env = triggerOpts?.environment ?? defaultEnv;
96
+ const runId = triggerOpts?.idempotencyKey !== undefined
97
+ ? `idem-${workflowId}-${triggerOpts.idempotencyKey}`
98
+ : genRunId();
99
+ const payload = {
100
+ runId,
101
+ workflowId,
102
+ workflowVersion: triggerOpts?.lockToVersion ?? "v1",
103
+ input: input,
104
+ tenantMeta,
105
+ environment: env,
106
+ tags: triggerOpts?.tags,
107
+ idempotencyKey: triggerOpts?.idempotencyKey,
108
+ triggeredBy: { kind: "api" },
109
+ };
110
+ const resp = await forwardToRunDO(runId, new Request("https://do-internal/trigger", {
111
+ method: "POST",
112
+ headers: { "content-type": "application/json" },
113
+ body: JSON.stringify(payload),
114
+ }));
115
+ if (!resp.ok) {
116
+ const body = await safeText(resp);
117
+ throw new Error(`CloudflareEdgeDriver: trigger DO returned ${resp.status}: ${body.slice(0, 256)}`);
118
+ }
119
+ const record = (await resp.json());
120
+ return {
121
+ id: record.id,
122
+ workflowId: record.workflowId,
123
+ status: record.status,
124
+ startedAt: record.startedAt,
125
+ };
126
+ }
127
+ async function ingestEvent(args) {
128
+ assertNotShutdown();
129
+ const stored = await manifestStore.getCurrent(args.environment);
130
+ if (!stored) {
131
+ return {
132
+ ok: false,
133
+ reason: "manifest_not_registered",
134
+ message: `No manifest is registered for environment "${args.environment}".`,
135
+ };
136
+ }
137
+ const manifest = stored.manifest;
138
+ const eventId = args.envelope.metadata?.eventId ?? (await deriveStableEventId(args.envelope));
139
+ const routed = routeEvent({
140
+ manifest,
141
+ envelope: {
142
+ name: args.envelope.name,
143
+ data: args.envelope.data,
144
+ metadata: args.envelope.metadata,
145
+ emittedAt: args.envelope.emittedAt,
146
+ },
147
+ eventId,
148
+ idempotencyOverride: args.idempotencyKey,
149
+ });
150
+ const matches = [];
151
+ let anyTriggered = false;
152
+ let anyFailed = false;
153
+ for (const entry of routed) {
154
+ if (entry.status === "skipped") {
155
+ matches.push({
156
+ filterId: entry.filterId,
157
+ status: "skipped",
158
+ reason: entry.reason,
159
+ details: entry.details,
160
+ });
161
+ continue;
162
+ }
163
+ const runId = `idem-${entry.targetWorkflowId}-${entry.idempotencyKey}`;
164
+ const payload = {
165
+ runId,
166
+ workflowId: entry.targetWorkflowId,
167
+ workflowVersion: "v1",
168
+ input: entry.input,
169
+ tenantMeta,
170
+ environment: args.environment,
171
+ idempotencyKey: entry.idempotencyKey,
172
+ triggeredBy: {
173
+ kind: "event",
174
+ eventId,
175
+ eventType: args.envelope.name,
176
+ filterId: entry.filterId,
177
+ },
178
+ };
179
+ try {
180
+ const resp = await forwardToRunDO(runId, new Request("https://do-internal/trigger", {
181
+ method: "POST",
182
+ headers: { "content-type": "application/json" },
183
+ body: JSON.stringify(payload),
184
+ }));
185
+ if (resp.ok) {
186
+ matches.push({
187
+ filterId: entry.filterId,
188
+ targetWorkflowId: entry.targetWorkflowId,
189
+ runId,
190
+ idempotencyKey: entry.idempotencyKey,
191
+ status: "queued",
192
+ });
193
+ anyTriggered = true;
194
+ }
195
+ else {
196
+ const body = await safeText(resp);
197
+ logger?.("error", "CloudflareEdgeDriver: trigger DO failed", {
198
+ status: resp.status,
199
+ body: body.slice(0, 256),
200
+ });
201
+ matches.push({
202
+ filterId: entry.filterId,
203
+ targetWorkflowId: entry.targetWorkflowId,
204
+ status: "error",
205
+ reason: `do_returned_${resp.status}`,
206
+ });
207
+ anyFailed = true;
208
+ }
209
+ }
210
+ catch (err) {
211
+ matches.push({
212
+ filterId: entry.filterId,
213
+ targetWorkflowId: entry.targetWorkflowId,
214
+ status: "error",
215
+ reason: err instanceof Error ? err.message : String(err),
216
+ });
217
+ anyFailed = true;
218
+ }
219
+ }
220
+ if (matches.length > 0 && !anyTriggered && anyFailed) {
221
+ return {
222
+ ok: false,
223
+ reason: "trigger_failed_for_all_matches",
224
+ message: "every matched filter failed to trigger",
225
+ };
226
+ }
227
+ return { ok: true, eventId, matches };
228
+ }
229
+ async function shutdown() {
230
+ shuttingDown = true;
231
+ }
232
+ // ---- WorkflowAdmin (partial — Mode 1 has no native cross-run query
233
+ // layer; getRun + cancelRun are direct DO RPC; listRuns +
234
+ // streamRun are explicitly unsupported per architecture
235
+ // doc §8.3) ----
236
+ const admin = {
237
+ async getRun(runId) {
238
+ try {
239
+ const resp = await forwardToRunDO(runId, new Request("https://do-internal/get", { method: "GET" }));
240
+ if (resp.status === 404)
241
+ return null;
242
+ if (!resp.ok)
243
+ return null;
244
+ const rec = (await resp.json());
245
+ return {
246
+ id: rec.id,
247
+ workflowId: rec.workflowId,
248
+ status: rec.status,
249
+ startedAt: rec.startedAt,
250
+ completedAt: rec.completedAt,
251
+ tags: [...rec.tags],
252
+ environment: rec.environment,
253
+ version: rec.workflowVersion,
254
+ input: rec.input,
255
+ output: rec.output,
256
+ error: rec.error,
257
+ durationMs: rec.completedAt !== undefined
258
+ ? Math.max(0, rec.completedAt - rec.startedAt)
259
+ : undefined,
260
+ };
261
+ }
262
+ catch {
263
+ return null;
264
+ }
265
+ },
266
+ async cancelRun(runId, cancelOpts) {
267
+ // Per architecture doc §21.21, cancel does NOT run compensations
268
+ // by default; the `compensate` flag is accepted but no-op in v1.
269
+ void cancelOpts?.compensate;
270
+ await forwardToRunDO(runId, new Request("https://do-internal/cancel", {
271
+ method: "POST",
272
+ headers: { "content-type": "application/json" },
273
+ body: JSON.stringify({ reason: cancelOpts?.reason }),
274
+ }));
275
+ },
276
+ async listRuns(_listOpts) {
277
+ // Self-host Mode 1 has no native cross-run query layer; voyant-cloud
278
+ // provides one in its index repo. Surface the limit to consumers
279
+ // (the dashboard) so they can fall back gracefully.
280
+ return { runs: [], nextCursor: undefined };
281
+ },
282
+ streamRun(_runId) {
283
+ // Live journal-event streaming is a follow-up; return an
284
+ // immediately-exhausted iterable so probes see a clean empty
285
+ // stream rather than undefined.
286
+ return {
287
+ [Symbol.asyncIterator]() {
288
+ return {
289
+ next: async () => ({ value: undefined, done: true }),
290
+ };
291
+ },
292
+ };
293
+ },
294
+ };
295
+ return {
296
+ registerManifest,
297
+ trigger,
298
+ ingestEvent,
299
+ getManifest,
300
+ shutdown,
301
+ admin,
302
+ };
303
+ };
304
+ }
305
+ // ---- Internal helpers ----
306
+ // Fallback id derivation lives in `@voyantjs/workflows/events`'s
307
+ // `deriveStableEventId` and is used inline at the call site above —
308
+ // content-derived so external callers without a forwarder still get
309
+ // dedup across retries (architecture doc §15.2).
310
+ async function safeText(resp) {
311
+ try {
312
+ return await resp.text();
313
+ }
314
+ catch {
315
+ return "";
316
+ }
317
+ }
@@ -0,0 +1,87 @@
1
+ import { type StepHandler } from "@voyantjs/workflows-orchestrator";
2
+ /**
3
+ * Context the run DO supplies when asking a dispatcher for a
4
+ * StepHandler. Most dispatchers ignore it; it carries the run's
5
+ * adapter-specific tenant identifier (when set) and the workflow id
6
+ * for logging / per-tenant routing in custom dispatchers.
7
+ */
8
+ export interface StepDispatcherContext {
9
+ /**
10
+ * Adapter-specific tenant identifier from the run's `tenantMeta`.
11
+ * Opaque to the OSS runtime — interpretation is up to whichever
12
+ * dispatcher consumes it (e.g. a custom multi-tenant dispatcher
13
+ * may use it as a routing key).
14
+ */
15
+ tenantScript?: string;
16
+ /** Workflow id, useful for label / logging. */
17
+ workflowId?: string;
18
+ }
19
+ /**
20
+ * Pluggable step-dispatch primitive. The run DO calls the dispatcher
21
+ * once per drive and forwards step requests through the handler it
22
+ * returns.
23
+ *
24
+ * Pick a factory below based on where workflow code lives in your
25
+ * deployment, or implement the type directly for custom transports.
26
+ */
27
+ export type StepDispatcher = (ctx: StepDispatcherContext) => StepHandler;
28
+ /**
29
+ * Subset of a Cloudflare service-binding interface (`env.SOMETHING`
30
+ * declared as `services: [{ binding, service }]` in wrangler.jsonc).
31
+ * `fetch(req)` delivers to the bound Worker.
32
+ */
33
+ export interface ServiceBindingLike {
34
+ fetch(request: Request): Promise<Response>;
35
+ }
36
+ export interface ServiceBindingDispatcherOptions {
37
+ /** Service binding to a sibling Worker that hosts the workflow code. */
38
+ binding: ServiceBindingLike;
39
+ /** Optional HMAC signer. */
40
+ sign?: (body: string) => Promise<string> | string;
41
+ /** Optional structured logger. */
42
+ logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void;
43
+ /** URL presented to the bound Worker. Defaults to `https://tenant.voyant.internal`. */
44
+ baseUrl?: string;
45
+ /** Optional label for logs. Defaults to `"service-binding"`. */
46
+ label?: string;
47
+ }
48
+ /**
49
+ * Service-binding dispatcher. Routes step requests to a sibling Worker
50
+ * via `services: [{ binding, service }]` in the orchestrator's
51
+ * wrangler.jsonc. Use this for self-host two-Worker deployments
52
+ * (orchestrator + workflows are separate Workers in the same account).
53
+ * No WfP needed; works on the standard Workers paid plan.
54
+ */
55
+ export declare function createServiceBindingDispatcher(opts: ServiceBindingDispatcherOptions): StepDispatcher;
56
+ /**
57
+ * Inline dispatcher. Returns the supplied StepHandler directly — used
58
+ * when workflow code lives in the SAME Worker as the orchestrator
59
+ * (single-Worker self-host). No HTTP, no DO traversal, just a function
60
+ * call. Pair with `handleStepRequest` from `@voyantjs/workflows/handler`
61
+ * for the typical setup.
62
+ */
63
+ export declare function createInlineDispatcher(handler: StepHandler): StepDispatcher;
64
+ export interface HttpDispatcherOptions {
65
+ /** Absolute URL of the workflow-step endpoint. */
66
+ url: string;
67
+ /** Optional HMAC signer. */
68
+ sign?: (body: string) => Promise<string> | string;
69
+ /** Optional structured logger. */
70
+ logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void;
71
+ /**
72
+ * Optional fetch override (e.g. `env.SOMETHING.fetch.bind(env.SOMETHING)`
73
+ * for typed bindings, or a custom client for testing). Defaults to
74
+ * `globalThis.fetch`.
75
+ */
76
+ fetch?: (request: Request) => Promise<Response>;
77
+ /** Optional label for logs; defaults to the URL host. */
78
+ label?: string;
79
+ }
80
+ /**
81
+ * HTTP dispatcher. Routes step requests to a configurable URL via
82
+ * `globalThis.fetch` (or a supplied fetch). Use for cross-host setups
83
+ * (e.g. a CF orchestrator forwarding to a Node-side workflows Worker)
84
+ * or test fakes.
85
+ */
86
+ export declare function createHttpDispatcher(opts: HttpDispatcherOptions): StepDispatcher;
87
+ //# sourceMappingURL=dispatchers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dispatchers.d.ts","sourceRoot":"","sources":["../src/dispatchers.ts"],"names":[],"mappings":"AAiBA,OAAO,EAAyB,KAAK,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAE1F;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,+CAA+C;IAC/C,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,GAAG,EAAE,qBAAqB,KAAK,WAAW,CAAA;AAIxE;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAC3C;AAED,MAAM,WAAW,+BAA+B;IAC9C,wEAAwE;IACxE,OAAO,EAAE,kBAAkB,CAAA;IAC3B,4BAA4B;IAC5B,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAA;IACjD,kCAAkC;IAClC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/E,uFAAuF;IACvF,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;;;;GAMG;AACH,wBAAgB,8BAA8B,CAC5C,IAAI,EAAE,+BAA+B,GACpC,cAAc,CAiBhB;AAID;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,WAAW,GAAG,cAAc,CAE3E;AAID,MAAM,WAAW,qBAAqB;IACpC,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAA;IACX,4BAA4B;IAC5B,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAA;IACjD,kCAAkC;IAClC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/E;;;;OAIG;IACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IAC/C,yDAAyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,qBAAqB,GAAG,cAAc,CAwBhF"}
@@ -0,0 +1,83 @@
1
+ // Step dispatchers — abstract "given a run's context, produce a
2
+ // StepHandler that delivers step requests to whatever Worker (or
3
+ // isolate) hosts the workflow code."
4
+ //
5
+ // The OSS runtime ships three universal factories:
6
+ // * createInlineDispatcher — same isolate as the orchestrator
7
+ // * createServiceBindingDispatcher — sibling Worker via service binding
8
+ // * createHttpDispatcher — arbitrary HTTP endpoint
9
+ //
10
+ // Hosted multi-tenant providers (Voyant Cloud, etc.) implement their
11
+ // own dispatchers in their private deployment code — Workers-for-
12
+ // Platforms is one such option, but it doesn't belong in the OSS
13
+ // runtime because it bakes in a CF-specific multi-tenancy story that
14
+ // most self-host users don't need or want.
15
+ //
16
+ // See issue #528 + docs/architecture/workflows-runtime-architecture.md §8.
17
+ import { createHttpStepHandler } from "@voyantjs/workflows-orchestrator";
18
+ /**
19
+ * Service-binding dispatcher. Routes step requests to a sibling Worker
20
+ * via `services: [{ binding, service }]` in the orchestrator's
21
+ * wrangler.jsonc. Use this for self-host two-Worker deployments
22
+ * (orchestrator + workflows are separate Workers in the same account).
23
+ * No WfP needed; works on the standard Workers paid plan.
24
+ */
25
+ export function createServiceBindingDispatcher(opts) {
26
+ const baseUrl = opts.baseUrl ?? "https://tenant.voyant.internal";
27
+ const label = opts.label ?? "service-binding";
28
+ return (_ctx) => createHttpStepHandler({
29
+ sign: opts.sign ? (body) => opts.sign(body) : undefined,
30
+ logger: opts.logger,
31
+ resolveTarget() {
32
+ return {
33
+ url: `${baseUrl}/__voyant/workflow-step`,
34
+ label,
35
+ fetch(request) {
36
+ return opts.binding.fetch(request);
37
+ },
38
+ };
39
+ },
40
+ });
41
+ }
42
+ // ---- Factory: Inline ----
43
+ /**
44
+ * Inline dispatcher. Returns the supplied StepHandler directly — used
45
+ * when workflow code lives in the SAME Worker as the orchestrator
46
+ * (single-Worker self-host). No HTTP, no DO traversal, just a function
47
+ * call. Pair with `handleStepRequest` from `@voyantjs/workflows/handler`
48
+ * for the typical setup.
49
+ */
50
+ export function createInlineDispatcher(handler) {
51
+ return () => handler;
52
+ }
53
+ /**
54
+ * HTTP dispatcher. Routes step requests to a configurable URL via
55
+ * `globalThis.fetch` (or a supplied fetch). Use for cross-host setups
56
+ * (e.g. a CF orchestrator forwarding to a Node-side workflows Worker)
57
+ * or test fakes.
58
+ */
59
+ export function createHttpDispatcher(opts) {
60
+ const fetchImpl = opts.fetch ?? ((req) => globalThis.fetch(req));
61
+ let label = opts.label;
62
+ if (!label) {
63
+ try {
64
+ label = new URL(opts.url).host;
65
+ }
66
+ catch {
67
+ label = opts.url;
68
+ }
69
+ }
70
+ return (_ctx) => createHttpStepHandler({
71
+ sign: opts.sign ? (body) => opts.sign(body) : undefined,
72
+ logger: opts.logger,
73
+ resolveTarget() {
74
+ return {
75
+ url: opts.url,
76
+ label,
77
+ fetch(request) {
78
+ return fetchImpl(request);
79
+ },
80
+ };
81
+ },
82
+ });
83
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"do-store.d.ts","sourceRoot":"","sources":["../src/do-store.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAa,cAAc,EAAE,MAAM,kCAAkC,CAAA;AACjF,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAA;AAI1D,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,wBAAwB,GAAG,cAAc,CAqB7F"}
1
+ {"version":3,"file":"do-store.d.ts","sourceRoot":"","sources":["../src/do-store.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAa,cAAc,EAAE,MAAM,kCAAkC,CAAA;AACjF,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAA;AAI1D,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,wBAAwB,GAAG,cAAc,CAkC7F"}
package/dist/do-store.js CHANGED
@@ -20,6 +20,18 @@ export function createDurableObjectRunStore(storage) {
20
20
  await storage.put(RECORD_KEY, record);
21
21
  return record;
22
22
  },
23
+ async tryInsert(record) {
24
+ // DO storage is single-threaded per DO instance: concurrent fetches
25
+ // to `idFromName(runId)` are serialized inside the DO's request
26
+ // queue, so get-then-put is naturally atomic. This is just the
27
+ // contract-shape implementation.
28
+ const existing = await storage.get(RECORD_KEY);
29
+ if (existing && existing.id === record.id) {
30
+ return { record: existing, created: false };
31
+ }
32
+ await storage.put(RECORD_KEY, record);
33
+ return { record, created: true };
34
+ },
23
35
  async list(filter = {}) {
24
36
  const r = await storage.get(RECORD_KEY);
25
37
  if (!r)
@@ -1,14 +1,21 @@
1
- import { applyWaitpointInjection, driveUntilPaused, type RunRecord, type StepHandler } from "@voyantjs/workflows-orchestrator";
1
+ import { applyWaitpointInjection, driveUntilPaused, type RunRecord } from "@voyantjs/workflows-orchestrator";
2
+ import type { StepDispatcher } from "./dispatchers.js";
2
3
  import type { DurableObjectStorageLike } from "./types.js";
3
4
  export interface DurableObjectDeps {
4
5
  storage: DurableObjectStorageLike;
5
6
  /**
6
- * Resolve the StepHandler to use for a given tenant script. Called
7
- * with the tenantScript (from the trigger payload) so the DO can
8
- * route to the correct tenant Worker. In production this closes
9
- * over the dispatch namespace; in tests it returns a mock.
7
+ * Pluggable dispatcher producing a StepHandler for a given run's
8
+ * context. The DO calls `dispatcher({ tenantScript, workflowId })`
9
+ * once per drive; the returned handler delivers step requests to
10
+ * whatever Worker (or isolate) hosts the workflow code.
11
+ *
12
+ * Pick a factory from `./dispatchers.ts`:
13
+ * - `createWfpDispatcher` — multi-tenant via dispatch namespace
14
+ * - `createServiceBindingDispatcher` — sibling Worker via service binding
15
+ * - `createInlineDispatcher` — same Worker / direct call
16
+ * - `createHttpDispatcher` — arbitrary HTTP endpoint
10
17
  */
11
- resolveStepHandler: (tenantScript: string) => StepHandler;
18
+ dispatcher: StepDispatcher;
12
19
  now?: () => number;
13
20
  }
14
21
  export declare function handleDurableObjectRequest(req: Request, deps: DurableObjectDeps): Promise<Response>;
@@ -1 +1 @@
1
- {"version":3,"file":"durable-object.d.ts","sourceRoot":"","sources":["../src/durable-object.ts"],"names":[],"mappings":"AAmBA,OAAO,EACL,uBAAuB,EACvB,gBAAgB,EAKhB,KAAK,SAAS,EACd,KAAK,WAAW,EACjB,MAAM,kCAAkC,CAAA;AAEzC,OAAO,KAAK,EAEV,wBAAwB,EAGzB,MAAM,YAAY,CAAA;AAEnB,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,wBAAwB,CAAA;IACjC;;;;;OAKG;IACH,kBAAkB,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,WAAW,CAAA;IACzD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,wBAAsB,0BAA0B,CAC9C,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,iBAAiB,GACtB,OAAO,CAAC,QAAQ,CAAC,CAgEnB;AAED;;;;;GAKG;AACH,wBAAsB,wBAAwB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwCrF;AAyDD,YAAY,EAAE,SAAS,EAAE,CAAA;AAEzB,OAAO,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,CAAA"}
1
+ {"version":3,"file":"durable-object.d.ts","sourceRoot":"","sources":["../src/durable-object.ts"],"names":[],"mappings":"AAmBA,OAAO,EACL,uBAAuB,EACvB,gBAAgB,EAKhB,KAAK,SAAS,EAEf,MAAM,kCAAkC,CAAA;AAEzC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAEtD,OAAO,KAAK,EAEV,wBAAwB,EAGzB,MAAM,YAAY,CAAA;AAEnB,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,wBAAwB,CAAA;IACjC;;;;;;;;;;;OAWG;IACH,UAAU,EAAE,cAAc,CAAA;IAC1B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAYD,wBAAsB,0BAA0B,CAC9C,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,iBAAiB,GACtB,OAAO,CAAC,QAAQ,CAAC,CAmEnB;AAED;;;;;GAKG;AACH,wBAAsB,wBAAwB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwCrF;AAyDD,YAAY,EAAE,SAAS,EAAE,CAAA;AAEzB,OAAO,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,CAAA"}