@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.
- package/README.md +23 -11
- package/dist/cloudflare-edge-driver.d.ts +49 -0
- package/dist/cloudflare-edge-driver.d.ts.map +1 -0
- package/dist/cloudflare-edge-driver.js +317 -0
- package/dist/dispatchers.d.ts +87 -0
- package/dist/dispatchers.d.ts.map +1 -0
- package/dist/dispatchers.js +83 -0
- package/dist/do-store.d.ts.map +1 -1
- package/dist/do-store.js +12 -0
- package/dist/durable-object.d.ts +13 -6
- package/dist/durable-object.d.ts.map +1 -1
- package/dist/durable-object.js +13 -4
- package/dist/event-handler.d.ts +23 -0
- package/dist/event-handler.d.ts.map +1 -0
- package/dist/event-handler.js +241 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/manifest-handler.d.ts +16 -0
- package/dist/manifest-handler.d.ts.map +1 -0
- package/dist/manifest-handler.js +92 -0
- package/dist/manifest-kv-store.d.ts +59 -0
- package/dist/manifest-kv-store.d.ts.map +1 -0
- package/dist/manifest-kv-store.js +134 -0
- package/dist/types.d.ts +7 -15
- package/dist/types.d.ts.map +1 -1
- package/dist/worker.d.ts +19 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +41 -0
- package/package.json +3 -3
- package/src/cloudflare-edge-driver.ts +435 -0
- package/src/dispatchers.ts +162 -0
- package/src/do-store.ts +13 -0
- package/src/durable-object.ts +30 -9
- package/src/event-handler.ts +302 -0
- package/src/index.ts +32 -8
- package/src/manifest-handler.ts +113 -0
- package/src/manifest-kv-store.ts +186 -0
- package/src/types.ts +7 -19
- package/src/worker.ts +64 -0
- package/dist/dispatch-handler.d.ts +0 -20
- package/dist/dispatch-handler.d.ts.map +0 -1
- package/dist/dispatch-handler.js +0 -31
- 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 []
|
package/src/durable-object.ts
CHANGED
|
@@ -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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|