@voyantjs/workflows-orchestrator-cloudflare 0.28.3 → 0.30.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
@@ -0,0 +1,435 @@
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
+
20
+ import type {
21
+ EnvironmentName,
22
+ ListRunsOptions,
23
+ Run,
24
+ RunDetail,
25
+ RunSummary,
26
+ TriggerOptions,
27
+ } from "@voyantjs/workflows"
28
+ import type {
29
+ DriverFactory,
30
+ DriverFactoryDeps,
31
+ IngestEventArgs,
32
+ IngestEventResponse,
33
+ IngestMatch,
34
+ WorkflowAdmin,
35
+ WorkflowDriver,
36
+ } from "@voyantjs/workflows/driver"
37
+ import { deriveStableEventId } from "@voyantjs/workflows/events"
38
+ import type { WorkflowManifest } from "@voyantjs/workflows/protocol"
39
+ import { routeEvent } from "@voyantjs/workflows-orchestrator"
40
+
41
+ import {
42
+ type CfManifestStore,
43
+ createKvManifestStore,
44
+ type KvNamespaceLike,
45
+ } from "./manifest-kv-store.js"
46
+ import type { DurableObjectNamespaceLike } from "./worker.js"
47
+
48
+ // ---- Public factory options ----
49
+
50
+ export interface CloudflareEdgeDriverOptions {
51
+ /** Durable Object namespace holding one DO per run. */
52
+ orchestratorNamespace: DurableObjectNamespaceLike
53
+ /** KV namespace storing serialized manifests. */
54
+ manifestKv: KvNamespaceLike
55
+ /**
56
+ * Adapter-specific tenant identifier stamped onto every triggered
57
+ * run as `tenantMeta.tenantScript`. Opaque to the OSS runtime —
58
+ * surfaces on `StepDispatcherContext` for custom dispatchers that
59
+ * need a routing key. Built-in dispatchers (inline, service-binding,
60
+ * HTTP) ignore it.
61
+ */
62
+ tenantScript?: string
63
+ /** Default environment for `trigger()` calls without an explicit one. */
64
+ defaultEnvironment?: EnvironmentName
65
+ /** Tenant metadata stamped onto every triggered run. Defaults to "default" tripled. */
66
+ tenantMeta?: {
67
+ tenantId: string
68
+ projectId: string
69
+ organizationId: string
70
+ }
71
+ /** Injectable clock; defaults to Date.now. */
72
+ now?: () => number
73
+ /** id generator for runs; defaults to `run_<random>`. */
74
+ idGenerator?: () => string
75
+ /** Optional structured logger; falls back to the framework logger. */
76
+ logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void
77
+ }
78
+
79
+ const DEFAULT_TENANT_META = {
80
+ tenantId: "default",
81
+ projectId: "default",
82
+ organizationId: "default",
83
+ }
84
+
85
+ // ---- Public factory ----
86
+
87
+ /**
88
+ * Build the Cloudflare-edge driver factory. The returned `DriverFactory`
89
+ * is invoked once by `createApp()` with `DriverFactoryDeps`.
90
+ *
91
+ * Usage in a Worker template:
92
+ *
93
+ * createApp({
94
+ * workflows: {
95
+ * driver: createCloudflareEdgeDriver({
96
+ * orchestratorNamespace: env.WORKFLOW_RUN_DO,
97
+ * manifestKv: env.WORKFLOW_MANIFESTS,
98
+ * }),
99
+ * },
100
+ * })
101
+ */
102
+ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): DriverFactory {
103
+ return (deps: DriverFactoryDeps): WorkflowDriver => {
104
+ const manifestStore: CfManifestStore = createKvManifestStore({ kv: opts.manifestKv })
105
+ const now = opts.now ?? deps.now ?? (() => Date.now())
106
+ const tenantMeta = {
107
+ ...DEFAULT_TENANT_META,
108
+ ...(opts.tenantMeta ?? {}),
109
+ ...(opts.tenantScript ? { tenantScript: opts.tenantScript } : {}),
110
+ }
111
+ const defaultEnv = opts.defaultEnvironment ?? "development"
112
+ const logger = opts.logger ?? deps.logger
113
+
114
+ let shuttingDown = false
115
+
116
+ // ---- Helpers ----
117
+
118
+ function assertNotShutdown(): void {
119
+ if (shuttingDown) {
120
+ throw new Error(
121
+ "CloudflareEdgeDriver: shutdown() has been called; new operations are refused.",
122
+ )
123
+ }
124
+ }
125
+
126
+ async function forwardToRunDO(runId: string, request: Request): Promise<Response> {
127
+ const id = opts.orchestratorNamespace.idFromName(runId)
128
+ const stub = opts.orchestratorNamespace.get(id)
129
+ return stub.fetch(request)
130
+ }
131
+
132
+ function genRunId(seed?: string): string {
133
+ if (seed !== undefined) return seed
134
+ if (opts.idGenerator) return opts.idGenerator()
135
+ const ts = now().toString(36)
136
+ const rand = Math.floor(Math.random() * 1_000_000)
137
+ .toString(36)
138
+ .padStart(4, "0")
139
+ return `run_${ts}_${rand}`
140
+ }
141
+
142
+ // ---- WorkflowDriver implementation ----
143
+
144
+ async function registerManifest(args: {
145
+ environment: EnvironmentName
146
+ manifest: WorkflowManifest
147
+ }): Promise<{ versionId: string }> {
148
+ assertNotShutdown()
149
+ return manifestStore.registerManifest({
150
+ environment: args.environment,
151
+ versionId: args.manifest.versionId,
152
+ manifest: args.manifest as unknown as Record<string, unknown>,
153
+ })
154
+ }
155
+
156
+ async function getManifest(args: {
157
+ environment: EnvironmentName
158
+ }): Promise<WorkflowManifest | null> {
159
+ const envelope = await manifestStore.getCurrent(args.environment)
160
+ if (!envelope) return null
161
+ return envelope.manifest as unknown as WorkflowManifest
162
+ }
163
+
164
+ async function trigger<TIn, TOut>(
165
+ workflow: { id: string } | string,
166
+ input: TIn,
167
+ triggerOpts?: TriggerOptions,
168
+ ): Promise<Run<TOut>> {
169
+ assertNotShutdown()
170
+ const workflowId = typeof workflow === "string" ? workflow : workflow.id
171
+ const env = triggerOpts?.environment ?? defaultEnv
172
+ const runId =
173
+ triggerOpts?.idempotencyKey !== undefined
174
+ ? `idem-${workflowId}-${triggerOpts.idempotencyKey}`
175
+ : genRunId()
176
+
177
+ const payload = {
178
+ runId,
179
+ workflowId,
180
+ workflowVersion: triggerOpts?.lockToVersion ?? "v1",
181
+ input: input as unknown,
182
+ tenantMeta,
183
+ environment: env,
184
+ tags: triggerOpts?.tags,
185
+ idempotencyKey: triggerOpts?.idempotencyKey,
186
+ triggeredBy: { kind: "api" as const },
187
+ }
188
+ const resp = await forwardToRunDO(
189
+ runId,
190
+ new Request("https://do-internal/trigger", {
191
+ method: "POST",
192
+ headers: { "content-type": "application/json" },
193
+ body: JSON.stringify(payload),
194
+ }),
195
+ )
196
+ if (!resp.ok) {
197
+ const body = await safeText(resp)
198
+ throw new Error(
199
+ `CloudflareEdgeDriver: trigger DO returned ${resp.status}: ${body.slice(0, 256)}`,
200
+ )
201
+ }
202
+ const record = (await resp.json()) as {
203
+ id: string
204
+ workflowId: string
205
+ status: Run["status"]
206
+ startedAt: number
207
+ }
208
+ return {
209
+ id: record.id,
210
+ workflowId: record.workflowId,
211
+ status: record.status,
212
+ startedAt: record.startedAt,
213
+ }
214
+ }
215
+
216
+ async function ingestEvent(args: IngestEventArgs): Promise<IngestEventResponse> {
217
+ assertNotShutdown()
218
+ const stored = await manifestStore.getCurrent(args.environment)
219
+ if (!stored) {
220
+ return {
221
+ ok: false,
222
+ reason: "manifest_not_registered",
223
+ message: `No manifest is registered for environment "${args.environment}".`,
224
+ }
225
+ }
226
+ const manifest = stored.manifest as unknown as WorkflowManifest
227
+ const eventId = args.envelope.metadata?.eventId ?? (await deriveStableEventId(args.envelope))
228
+ const routed = routeEvent({
229
+ manifest,
230
+ envelope: {
231
+ name: args.envelope.name,
232
+ data: args.envelope.data,
233
+ metadata: args.envelope.metadata,
234
+ emittedAt: args.envelope.emittedAt,
235
+ },
236
+ eventId,
237
+ idempotencyOverride: args.idempotencyKey,
238
+ })
239
+
240
+ const matches: IngestMatch[] = []
241
+ let anyTriggered = false
242
+ let anyFailed = false
243
+
244
+ for (const entry of routed) {
245
+ if (entry.status === "skipped") {
246
+ matches.push({
247
+ filterId: entry.filterId,
248
+ status: "skipped",
249
+ reason: entry.reason,
250
+ details: entry.details,
251
+ })
252
+ continue
253
+ }
254
+ const runId = `idem-${entry.targetWorkflowId}-${entry.idempotencyKey}`
255
+ const payload = {
256
+ runId,
257
+ workflowId: entry.targetWorkflowId,
258
+ workflowVersion: "v1",
259
+ input: entry.input,
260
+ tenantMeta,
261
+ environment: args.environment,
262
+ idempotencyKey: entry.idempotencyKey,
263
+ triggeredBy: {
264
+ kind: "event" as const,
265
+ eventId,
266
+ eventType: args.envelope.name,
267
+ filterId: entry.filterId,
268
+ },
269
+ }
270
+ try {
271
+ const resp = await forwardToRunDO(
272
+ runId,
273
+ new Request("https://do-internal/trigger", {
274
+ method: "POST",
275
+ headers: { "content-type": "application/json" },
276
+ body: JSON.stringify(payload),
277
+ }),
278
+ )
279
+ if (resp.ok) {
280
+ matches.push({
281
+ filterId: entry.filterId,
282
+ targetWorkflowId: entry.targetWorkflowId,
283
+ runId,
284
+ idempotencyKey: entry.idempotencyKey,
285
+ status: "queued",
286
+ })
287
+ anyTriggered = true
288
+ } else {
289
+ const body = await safeText(resp)
290
+ logger?.("error", "CloudflareEdgeDriver: trigger DO failed", {
291
+ status: resp.status,
292
+ body: body.slice(0, 256),
293
+ })
294
+ matches.push({
295
+ filterId: entry.filterId,
296
+ targetWorkflowId: entry.targetWorkflowId,
297
+ status: "error",
298
+ reason: `do_returned_${resp.status}`,
299
+ })
300
+ anyFailed = true
301
+ }
302
+ } catch (err) {
303
+ matches.push({
304
+ filterId: entry.filterId,
305
+ targetWorkflowId: entry.targetWorkflowId,
306
+ status: "error",
307
+ reason: err instanceof Error ? err.message : String(err),
308
+ })
309
+ anyFailed = true
310
+ }
311
+ }
312
+
313
+ if (matches.length > 0 && !anyTriggered && anyFailed) {
314
+ return {
315
+ ok: false,
316
+ reason: "trigger_failed_for_all_matches",
317
+ message: "every matched filter failed to trigger",
318
+ }
319
+ }
320
+ return { ok: true, eventId, matches }
321
+ }
322
+
323
+ async function shutdown(): Promise<void> {
324
+ shuttingDown = true
325
+ }
326
+
327
+ // ---- WorkflowAdmin (partial — Mode 1 has no native cross-run query
328
+ // layer; getRun + cancelRun are direct DO RPC; listRuns +
329
+ // streamRun are explicitly unsupported per architecture
330
+ // doc §8.3) ----
331
+
332
+ const admin: Partial<WorkflowAdmin> = {
333
+ async getRun(runId: string): Promise<RunDetail | null> {
334
+ try {
335
+ const resp = await forwardToRunDO(
336
+ runId,
337
+ new Request("https://do-internal/get", { method: "GET" }),
338
+ )
339
+ if (resp.status === 404) return null
340
+ if (!resp.ok) return null
341
+ const rec = (await resp.json()) as {
342
+ id: string
343
+ workflowId: string
344
+ workflowVersion: string
345
+ status: RunSummary["status"]
346
+ startedAt: number
347
+ completedAt?: number
348
+ tags: string[]
349
+ environment: EnvironmentName
350
+ input: unknown
351
+ output?: unknown
352
+ error?: unknown
353
+ }
354
+ return {
355
+ id: rec.id,
356
+ workflowId: rec.workflowId,
357
+ status: rec.status,
358
+ startedAt: rec.startedAt,
359
+ completedAt: rec.completedAt,
360
+ tags: [...rec.tags],
361
+ environment: rec.environment,
362
+ version: rec.workflowVersion,
363
+ input: rec.input,
364
+ output: rec.output,
365
+ error: rec.error,
366
+ durationMs:
367
+ rec.completedAt !== undefined
368
+ ? Math.max(0, rec.completedAt - rec.startedAt)
369
+ : undefined,
370
+ }
371
+ } catch {
372
+ return null
373
+ }
374
+ },
375
+
376
+ async cancelRun(runId: string, cancelOpts?: { reason?: string; compensate?: boolean }) {
377
+ // Per architecture doc §21.21, cancel does NOT run compensations
378
+ // by default; the `compensate` flag is accepted but no-op in v1.
379
+ void cancelOpts?.compensate
380
+ await forwardToRunDO(
381
+ runId,
382
+ new Request("https://do-internal/cancel", {
383
+ method: "POST",
384
+ headers: { "content-type": "application/json" },
385
+ body: JSON.stringify({ reason: cancelOpts?.reason }),
386
+ }),
387
+ )
388
+ },
389
+
390
+ async listRuns(_listOpts?: ListRunsOptions) {
391
+ // Self-host Mode 1 has no native cross-run query layer; voyant-cloud
392
+ // provides one in its index repo. Surface the limit to consumers
393
+ // (the dashboard) so they can fall back gracefully.
394
+ return { runs: [], nextCursor: undefined }
395
+ },
396
+
397
+ streamRun(_runId: string) {
398
+ // Live journal-event streaming is a follow-up; return an
399
+ // immediately-exhausted iterable so probes see a clean empty
400
+ // stream rather than undefined.
401
+ return {
402
+ [Symbol.asyncIterator]() {
403
+ return {
404
+ next: async () => ({ value: undefined as never, done: true as const }),
405
+ }
406
+ },
407
+ }
408
+ },
409
+ }
410
+
411
+ return {
412
+ registerManifest,
413
+ trigger,
414
+ ingestEvent,
415
+ getManifest,
416
+ shutdown,
417
+ admin,
418
+ }
419
+ }
420
+ }
421
+
422
+ // ---- Internal helpers ----
423
+
424
+ // Fallback id derivation lives in `@voyantjs/workflows/events`'s
425
+ // `deriveStableEventId` and is used inline at the call site above —
426
+ // content-derived so external callers without a forwarder still get
427
+ // dedup across retries (architecture doc §15.2).
428
+
429
+ async function safeText(resp: Response): Promise<string> {
430
+ try {
431
+ return await resp.text()
432
+ } catch {
433
+ return ""
434
+ }
435
+ }
@@ -0,0 +1,162 @@
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
+
18
+ import { createHttpStepHandler, type StepHandler } from "@voyantjs/workflows-orchestrator"
19
+
20
+ /**
21
+ * Context the run DO supplies when asking a dispatcher for a
22
+ * StepHandler. Most dispatchers ignore it; it carries the run's
23
+ * adapter-specific tenant identifier (when set) and the workflow id
24
+ * for logging / per-tenant routing in custom dispatchers.
25
+ */
26
+ export interface StepDispatcherContext {
27
+ /**
28
+ * Adapter-specific tenant identifier from the run's `tenantMeta`.
29
+ * Opaque to the OSS runtime — interpretation is up to whichever
30
+ * dispatcher consumes it (e.g. a custom multi-tenant dispatcher
31
+ * may use it as a routing key).
32
+ */
33
+ tenantScript?: string
34
+ /** Workflow id, useful for label / logging. */
35
+ workflowId?: string
36
+ }
37
+
38
+ /**
39
+ * Pluggable step-dispatch primitive. The run DO calls the dispatcher
40
+ * once per drive and forwards step requests through the handler it
41
+ * returns.
42
+ *
43
+ * Pick a factory below based on where workflow code lives in your
44
+ * deployment, or implement the type directly for custom transports.
45
+ */
46
+ export type StepDispatcher = (ctx: StepDispatcherContext) => StepHandler
47
+
48
+ // ---- Factory: Service binding ----
49
+
50
+ /**
51
+ * Subset of a Cloudflare service-binding interface (`env.SOMETHING`
52
+ * declared as `services: [{ binding, service }]` in wrangler.jsonc).
53
+ * `fetch(req)` delivers to the bound Worker.
54
+ */
55
+ export interface ServiceBindingLike {
56
+ fetch(request: Request): Promise<Response>
57
+ }
58
+
59
+ export interface ServiceBindingDispatcherOptions {
60
+ /** Service binding to a sibling Worker that hosts the workflow code. */
61
+ binding: ServiceBindingLike
62
+ /** Optional HMAC signer. */
63
+ sign?: (body: string) => Promise<string> | string
64
+ /** Optional structured logger. */
65
+ logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void
66
+ /** URL presented to the bound Worker. Defaults to `https://tenant.voyant.internal`. */
67
+ baseUrl?: string
68
+ /** Optional label for logs. Defaults to `"service-binding"`. */
69
+ label?: string
70
+ }
71
+
72
+ /**
73
+ * Service-binding dispatcher. Routes step requests to a sibling Worker
74
+ * via `services: [{ binding, service }]` in the orchestrator's
75
+ * wrangler.jsonc. Use this for self-host two-Worker deployments
76
+ * (orchestrator + workflows are separate Workers in the same account).
77
+ * No WfP needed; works on the standard Workers paid plan.
78
+ */
79
+ export function createServiceBindingDispatcher(
80
+ opts: ServiceBindingDispatcherOptions,
81
+ ): StepDispatcher {
82
+ const baseUrl = opts.baseUrl ?? "https://tenant.voyant.internal"
83
+ const label = opts.label ?? "service-binding"
84
+ return (_ctx) =>
85
+ createHttpStepHandler({
86
+ sign: opts.sign ? (body) => opts.sign!(body) : undefined,
87
+ logger: opts.logger,
88
+ resolveTarget() {
89
+ return {
90
+ url: `${baseUrl}/__voyant/workflow-step`,
91
+ label,
92
+ fetch(request: Request) {
93
+ return opts.binding.fetch(request)
94
+ },
95
+ }
96
+ },
97
+ })
98
+ }
99
+
100
+ // ---- Factory: Inline ----
101
+
102
+ /**
103
+ * Inline dispatcher. Returns the supplied StepHandler directly — used
104
+ * when workflow code lives in the SAME Worker as the orchestrator
105
+ * (single-Worker self-host). No HTTP, no DO traversal, just a function
106
+ * call. Pair with `handleStepRequest` from `@voyantjs/workflows/handler`
107
+ * for the typical setup.
108
+ */
109
+ export function createInlineDispatcher(handler: StepHandler): StepDispatcher {
110
+ return () => handler
111
+ }
112
+
113
+ // ---- Factory: HTTP ----
114
+
115
+ export interface HttpDispatcherOptions {
116
+ /** Absolute URL of the workflow-step endpoint. */
117
+ url: string
118
+ /** Optional HMAC signer. */
119
+ sign?: (body: string) => Promise<string> | string
120
+ /** Optional structured logger. */
121
+ logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void
122
+ /**
123
+ * Optional fetch override (e.g. `env.SOMETHING.fetch.bind(env.SOMETHING)`
124
+ * for typed bindings, or a custom client for testing). Defaults to
125
+ * `globalThis.fetch`.
126
+ */
127
+ fetch?: (request: Request) => Promise<Response>
128
+ /** Optional label for logs; defaults to the URL host. */
129
+ label?: string
130
+ }
131
+
132
+ /**
133
+ * HTTP dispatcher. Routes step requests to a configurable URL via
134
+ * `globalThis.fetch` (or a supplied fetch). Use for cross-host setups
135
+ * (e.g. a CF orchestrator forwarding to a Node-side workflows Worker)
136
+ * or test fakes.
137
+ */
138
+ export function createHttpDispatcher(opts: HttpDispatcherOptions): StepDispatcher {
139
+ const fetchImpl = opts.fetch ?? ((req: Request) => globalThis.fetch(req))
140
+ let label = opts.label
141
+ if (!label) {
142
+ try {
143
+ label = new URL(opts.url).host
144
+ } catch {
145
+ label = opts.url
146
+ }
147
+ }
148
+ return (_ctx) =>
149
+ createHttpStepHandler({
150
+ sign: opts.sign ? (body) => opts.sign!(body) : undefined,
151
+ logger: opts.logger,
152
+ resolveTarget() {
153
+ return {
154
+ url: opts.url,
155
+ label,
156
+ fetch(request: Request) {
157
+ return fetchImpl(request)
158
+ },
159
+ }
160
+ },
161
+ })
162
+ }
package/src/do-store.ts CHANGED
@@ -26,6 +26,19 @@ export function createDurableObjectRunStore(storage: DurableObjectStorageLike):
26
26
  return record
27
27
  },
28
28
 
29
+ async tryInsert(record) {
30
+ // DO storage is single-threaded per DO instance: concurrent fetches
31
+ // to `idFromName(runId)` are serialized inside the DO's request
32
+ // queue, so get-then-put is naturally atomic. This is just the
33
+ // contract-shape implementation.
34
+ const existing = await storage.get<RunRecord>(RECORD_KEY)
35
+ if (existing && existing.id === record.id) {
36
+ return { record: existing, created: false }
37
+ }
38
+ await storage.put<RunRecord>(RECORD_KEY, record)
39
+ return { record, created: true }
40
+ },
41
+
29
42
  async list(filter = {}) {
30
43
  const r = await storage.get<RunRecord>(RECORD_KEY)
31
44
  if (!r) return []
@@ -27,6 +27,8 @@ import {
27
27
  type RunRecord,
28
28
  type StepHandler,
29
29
  } from "@voyantjs/workflows-orchestrator"
30
+
31
+ import type { StepDispatcher } from "./dispatchers.js"
30
32
  import { createDurableObjectRunStore } from "./do-store.js"
31
33
  import type {
32
34
  CancelPayload,
@@ -38,15 +40,31 @@ import type {
38
40
  export interface DurableObjectDeps {
39
41
  storage: DurableObjectStorageLike
40
42
  /**
41
- * Resolve the StepHandler to use for a given tenant script. Called
42
- * with the tenantScript (from the trigger payload) so the DO can
43
- * route to the correct tenant Worker. In production this closes
44
- * over the dispatch namespace; in tests it returns a mock.
43
+ * Pluggable dispatcher producing a StepHandler for a given run's
44
+ * context. The DO calls `dispatcher({ tenantScript, workflowId })`
45
+ * once per drive; the returned handler delivers step requests to
46
+ * whatever Worker (or isolate) hosts the workflow code.
47
+ *
48
+ * Pick a factory from `./dispatchers.ts`:
49
+ * - `createWfpDispatcher` — multi-tenant via dispatch namespace
50
+ * - `createServiceBindingDispatcher` — sibling Worker via service binding
51
+ * - `createInlineDispatcher` — same Worker / direct call
52
+ * - `createHttpDispatcher` — arbitrary HTTP endpoint
45
53
  */
46
- resolveStepHandler: (tenantScript: string) => StepHandler
54
+ dispatcher: StepDispatcher
47
55
  now?: () => number
48
56
  }
49
57
 
58
+ function resolve(
59
+ deps: DurableObjectDeps,
60
+ record: { tenantMeta: { tenantScript?: string }; workflowId: string },
61
+ ): StepHandler {
62
+ return deps.dispatcher({
63
+ tenantScript: record.tenantMeta.tenantScript,
64
+ workflowId: record.workflowId,
65
+ })
66
+ }
67
+
50
68
  export async function handleDurableObjectRequest(
51
69
  req: Request,
52
70
  deps: DurableObjectDeps,
@@ -56,7 +74,10 @@ export async function handleDurableObjectRequest(
56
74
 
57
75
  if (req.method === "POST" && url.pathname === "/trigger") {
58
76
  const payload = (await req.json()) as TriggerPayload
59
- const handler = deps.resolveStepHandler(payload.tenantMeta.tenantScript)
77
+ const handler = resolve(deps, {
78
+ tenantMeta: payload.tenantMeta,
79
+ workflowId: payload.workflowId,
80
+ })
60
81
  const record = await orchestratorTrigger(
61
82
  {
62
83
  workflowId: payload.workflowId,
@@ -77,7 +98,7 @@ export async function handleDurableObjectRequest(
77
98
  const payload = (await req.json()) as ResumePayload
78
99
  const existing = await store.get((await getStoredRunId(store)) ?? "")
79
100
  if (!existing) return json(404, { error: "not_found", message: "no run stored in this DO" })
80
- const handler = deps.resolveStepHandler(existing.tenantMeta.tenantScript ?? "")
101
+ const handler = resolve(deps, existing)
81
102
  const out = await orchestratorResume(
82
103
  { runId: existing.id, injection: payload.injection },
83
104
  { store, handler, now: deps.now },
@@ -94,7 +115,7 @@ export async function handleDurableObjectRequest(
94
115
  const payload = (await req.json()) as CancelPayload
95
116
  const existing = await store.get((await getStoredRunId(store)) ?? "")
96
117
  if (!existing) return json(404, { error: "not_found", message: "no run stored in this DO" })
97
- const handler = deps.resolveStepHandler(existing.tenantMeta.tenantScript ?? "")
118
+ const handler = resolve(deps, existing)
98
119
  const out = await orchestratorCancel(
99
120
  { runId: existing.id, reason: payload.reason },
100
121
  { store, handler, now: deps.now },
@@ -158,7 +179,7 @@ export async function handleDurableObjectAlarm(deps: DurableObjectDeps): Promise
158
179
  }
159
180
 
160
181
  record.status = "running"
161
- const handler = deps.resolveStepHandler(record.tenantMeta.tenantScript ?? "")
182
+ const handler = resolve(deps, record)
162
183
  await driveUntilPaused(record, { handler, now: deps.now })
163
184
  await store.save(record)
164
185
  await reconcileAlarm(record, store, deps)