@voyantjs/workflows-orchestrator-cloudflare 0.30.3 → 0.30.5

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.
@@ -5,6 +5,8 @@ import type { DurableObjectNamespaceLike } from "./worker.js";
5
5
  export interface CloudflareEdgeDriverOptions {
6
6
  /** Durable Object namespace holding one DO per run. */
7
7
  orchestratorNamespace: DurableObjectNamespaceLike;
8
+ /** Optional Durable Object namespace coordinating workflow concurrency across runs. */
9
+ concurrencyNamespace?: DurableObjectNamespaceLike;
8
10
  /** KV namespace storing serialized manifests. */
9
11
  manifestKv: KvNamespaceLike;
10
12
  /**
@@ -1 +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,CAmU3F"}
1
+ {"version":3,"file":"cloudflare-edge-driver.d.ts","sourceRoot":"","sources":["../src/cloudflare-edge-driver.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EACV,eAAe,EAOhB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EACV,aAAa,EAOd,MAAM,4BAA4B,CAAA;AAUnC,OAAO,EAGL,KAAK,eAAe,EACrB,MAAM,wBAAwB,CAAA;AAE/B,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAA;AAI7D,MAAM,WAAW,2BAA2B;IAC1C,uDAAuD;IACvD,qBAAqB,EAAE,0BAA0B,CAAA;IACjD,uFAAuF;IACvF,oBAAoB,CAAC,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,CAqa3F"}
@@ -17,7 +17,7 @@
17
17
  //
18
18
  // See architecture doc §8.
19
19
  import { deriveStableEventId } from "@voyantjs/workflows/events";
20
- import { routeEvent } from "@voyantjs/workflows-orchestrator";
20
+ import { resolveConcurrencyKey, routeEvent, WorkflowConcurrencyRejectedError, } from "@voyantjs/workflows-orchestrator";
21
21
  import { createKvManifestStore, } from "./manifest-kv-store.js";
22
22
  const DEFAULT_TENANT_META = {
23
23
  tenantId: "default",
@@ -63,6 +63,63 @@ export function createCloudflareEdgeDriver(opts) {
63
63
  const stub = opts.orchestratorNamespace.get(id);
64
64
  return stub.fetch(request);
65
65
  }
66
+ async function forwardToConcurrencyDO(environment, concurrencyKey, request) {
67
+ if (!opts.concurrencyNamespace) {
68
+ throw new Error("CloudflareEdgeDriver: concurrency namespace is not configured");
69
+ }
70
+ const id = opts.concurrencyNamespace.idFromName(`workflow-concurrency:${environment}:${concurrencyKey}`);
71
+ const stub = opts.concurrencyNamespace.get(id);
72
+ return stub.fetch(request);
73
+ }
74
+ function findManifestPolicy(manifest, workflowId) {
75
+ return manifest?.workflows.find((entry) => entry.id === workflowId)?.concurrency;
76
+ }
77
+ async function resolveConcurrencyPolicy(workflow, workflowId, environment, manifest) {
78
+ if (typeof workflow !== "string" && workflow.config?.concurrency) {
79
+ return workflow.config.concurrency;
80
+ }
81
+ return findManifestPolicy(manifest ?? (await getManifest({ environment })), workflowId);
82
+ }
83
+ async function forwardTrigger(payload, policy) {
84
+ if (!policy || !opts.concurrencyNamespace) {
85
+ return forwardToRunDO(payload.runId, new Request("https://do-internal/trigger", {
86
+ method: "POST",
87
+ headers: { "content-type": "application/json" },
88
+ body: JSON.stringify(payload),
89
+ }));
90
+ }
91
+ const concurrencyKey = resolveConcurrencyKey(payload.workflowId, payload.input, policy);
92
+ const resp = await forwardToConcurrencyDO(payload.environment, concurrencyKey, new Request("https://do-internal/trigger", {
93
+ method: "POST",
94
+ headers: { "content-type": "application/json" },
95
+ body: JSON.stringify({
96
+ concurrency: {
97
+ key: concurrencyKey,
98
+ limit: policy.limit,
99
+ strategy: policy.strategy,
100
+ },
101
+ trigger: payload,
102
+ }),
103
+ }));
104
+ if (resp.status === 409) {
105
+ const body = await safeJson(resp);
106
+ throw new WorkflowConcurrencyRejectedError(body?.concurrencyKey ?? concurrencyKey);
107
+ }
108
+ return resp;
109
+ }
110
+ async function releaseConcurrencySlot(detail) {
111
+ if (!detail || !opts.concurrencyNamespace)
112
+ return;
113
+ const policy = await resolveConcurrencyPolicy(detail.workflowId, detail.workflowId, detail.environment);
114
+ if (!policy)
115
+ return;
116
+ const concurrencyKey = resolveConcurrencyKey(detail.workflowId, detail.input, policy);
117
+ await forwardToConcurrencyDO(detail.environment, concurrencyKey, new Request("https://do-internal/release", {
118
+ method: "POST",
119
+ headers: { "content-type": "application/json" },
120
+ body: JSON.stringify({ runId: detail.id }),
121
+ })).catch(() => undefined);
122
+ }
66
123
  function genRunId(seed) {
67
124
  if (seed !== undefined)
68
125
  return seed;
@@ -111,11 +168,8 @@ export function createCloudflareEdgeDriver(opts) {
111
168
  priority: triggerOpts?.priority,
112
169
  triggeredBy: { kind: "api" },
113
170
  };
114
- const resp = await forwardToRunDO(runId, new Request("https://do-internal/trigger", {
115
- method: "POST",
116
- headers: { "content-type": "application/json" },
117
- body: JSON.stringify(payload),
118
- }));
171
+ const policy = await resolveConcurrencyPolicy(workflow, workflowId, env);
172
+ const resp = await forwardTrigger(payload, policy);
119
173
  if (!resp.ok) {
120
174
  const body = await safeText(resp);
121
175
  throw new Error(`CloudflareEdgeDriver: trigger DO returned ${resp.status}: ${body.slice(0, 256)}`);
@@ -181,11 +235,8 @@ export function createCloudflareEdgeDriver(opts) {
181
235
  },
182
236
  };
183
237
  try {
184
- const resp = await forwardToRunDO(runId, new Request("https://do-internal/trigger", {
185
- method: "POST",
186
- headers: { "content-type": "application/json" },
187
- body: JSON.stringify(payload),
188
- }));
238
+ const policy = await resolveConcurrencyPolicy(entry.targetWorkflowId, entry.targetWorkflowId, args.environment, manifest);
239
+ const resp = await forwardTrigger(payload, policy);
189
240
  if (resp.ok) {
190
241
  matches.push({
191
242
  filterId: entry.filterId,
@@ -237,45 +288,51 @@ export function createCloudflareEdgeDriver(opts) {
237
288
  // layer; getRun + cancelRun are direct DO RPC; listRuns +
238
289
  // streamRun are explicitly unsupported per architecture
239
290
  // doc §8.3) ----
291
+ async function getRunDetail(runId) {
292
+ try {
293
+ const resp = await forwardToRunDO(runId, new Request("https://do-internal/get", { method: "GET" }));
294
+ if (resp.status === 404)
295
+ return null;
296
+ if (!resp.ok)
297
+ return null;
298
+ const rec = (await resp.json());
299
+ return {
300
+ id: rec.id,
301
+ workflowId: rec.workflowId,
302
+ status: rec.status,
303
+ startedAt: rec.startedAt,
304
+ completedAt: rec.completedAt,
305
+ tags: [...rec.tags],
306
+ environment: rec.environment,
307
+ version: rec.workflowVersion,
308
+ input: rec.input,
309
+ output: rec.output,
310
+ error: rec.error,
311
+ durationMs: rec.completedAt !== undefined
312
+ ? Math.max(0, rec.completedAt - rec.startedAt)
313
+ : undefined,
314
+ };
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ }
240
320
  const admin = {
241
321
  async getRun(runId) {
242
- try {
243
- const resp = await forwardToRunDO(runId, new Request("https://do-internal/get", { method: "GET" }));
244
- if (resp.status === 404)
245
- return null;
246
- if (!resp.ok)
247
- return null;
248
- const rec = (await resp.json());
249
- return {
250
- id: rec.id,
251
- workflowId: rec.workflowId,
252
- status: rec.status,
253
- startedAt: rec.startedAt,
254
- completedAt: rec.completedAt,
255
- tags: [...rec.tags],
256
- environment: rec.environment,
257
- version: rec.workflowVersion,
258
- input: rec.input,
259
- output: rec.output,
260
- error: rec.error,
261
- durationMs: rec.completedAt !== undefined
262
- ? Math.max(0, rec.completedAt - rec.startedAt)
263
- : undefined,
264
- };
265
- }
266
- catch {
267
- return null;
268
- }
322
+ return getRunDetail(runId);
269
323
  },
270
324
  async cancelRun(runId, cancelOpts) {
271
325
  // Per architecture doc §21.21, cancel does NOT run compensations
272
326
  // by default; the `compensate` flag is accepted but no-op in v1.
273
327
  void cancelOpts?.compensate;
274
- await forwardToRunDO(runId, new Request("https://do-internal/cancel", {
328
+ const detail = opts.concurrencyNamespace ? await getRunDetail(runId) : null;
329
+ const resp = await forwardToRunDO(runId, new Request("https://do-internal/cancel", {
275
330
  method: "POST",
276
331
  headers: { "content-type": "application/json" },
277
332
  body: JSON.stringify({ reason: cancelOpts?.reason }),
278
333
  }));
334
+ if (resp.ok)
335
+ await releaseConcurrencySlot(detail);
279
336
  },
280
337
  async listRuns(_listOpts) {
281
338
  // Self-host Mode 1 has no native cross-run query layer; voyant-cloud
@@ -319,3 +376,11 @@ async function safeText(resp) {
319
376
  return "";
320
377
  }
321
378
  }
379
+ async function safeJson(resp) {
380
+ try {
381
+ return (await resp.json());
382
+ }
383
+ catch {
384
+ return undefined;
385
+ }
386
+ }
@@ -0,0 +1,14 @@
1
+ import type { DurableObjectStorageLike } from "./types.js";
2
+ import type { DurableObjectNamespaceLike } from "./worker.js";
3
+ export interface ConcurrencyCoordinatorDeps<Id = unknown> {
4
+ storage: DurableObjectStorageLike;
5
+ runDO: DurableObjectNamespaceLike<Id>;
6
+ }
7
+ export interface ConcurrencyCoordinator {
8
+ fetch(req: Request): Promise<Response>;
9
+ }
10
+ export declare function createConcurrencyCoordinator<Id>(deps: ConcurrencyCoordinatorDeps<Id>): ConcurrencyCoordinator;
11
+ export declare function handleConcurrencyCoordinatorRequest<Id>(req: Request, deps: ConcurrencyCoordinatorDeps<Id> & {
12
+ coordinator?: ConcurrencyCoordinator;
13
+ }): Promise<Response>;
14
+ //# sourceMappingURL=concurrency-coordinator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"concurrency-coordinator.d.ts","sourceRoot":"","sources":["../src/concurrency-coordinator.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,wBAAwB,EAAkB,MAAM,YAAY,CAAA;AAC1E,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAA;AAE7D,MAAM,WAAW,0BAA0B,CAAC,EAAE,GAAG,OAAO;IACtD,OAAO,EAAE,wBAAwB,CAAA;IACjC,KAAK,EAAE,0BAA0B,CAAC,EAAE,CAAC,CAAA;CACtC;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CACvC;AA0BD,wBAAgB,4BAA4B,CAAC,EAAE,EAC7C,IAAI,EAAE,0BAA0B,CAAC,EAAE,CAAC,GACnC,sBAAsB,CAgJxB;AAED,wBAAsB,mCAAmC,CAAC,EAAE,EAC1D,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,0BAA0B,CAAC,EAAE,CAAC,GAAG;IAAE,WAAW,CAAC,EAAE,sBAAsB,CAAA;CAAE,GAC9E,OAAO,CAAC,QAAQ,CAAC,CAGnB"}
@@ -0,0 +1,163 @@
1
+ import { WorkflowConcurrencyRejectedError, } from "@voyantjs/workflows-orchestrator";
2
+ const STATE_KEY = "state";
3
+ export function createConcurrencyCoordinator(deps) {
4
+ const waiters = [];
5
+ let stateLock = Promise.resolve();
6
+ async function withStateLock(fn) {
7
+ const previous = stateLock;
8
+ let release;
9
+ stateLock = new Promise((resolve) => {
10
+ release = resolve;
11
+ });
12
+ await previous;
13
+ try {
14
+ return await fn();
15
+ }
16
+ finally {
17
+ release();
18
+ }
19
+ }
20
+ async function loadState() {
21
+ return (await deps.storage.get(STATE_KEY)) ?? { active: [] };
22
+ }
23
+ async function saveState(state) {
24
+ await deps.storage.put(STATE_KEY, { active: [...state.active] });
25
+ }
26
+ async function acquire(payload) {
27
+ const runId = payload.trigger.runId;
28
+ let holdersToCancel;
29
+ let waitForSlot;
30
+ await withStateLock(async () => {
31
+ const state = await loadState();
32
+ if (state.active.includes(runId))
33
+ return;
34
+ const limit = normalizeLimit(payload.concurrency.limit);
35
+ if (state.active.length < limit) {
36
+ state.active.push(runId);
37
+ await saveState(state);
38
+ return;
39
+ }
40
+ const strategy = payload.concurrency.strategy ?? "queue";
41
+ if (strategy === "cancel-newest") {
42
+ throw new WorkflowConcurrencyRejectedError(payload.concurrency.key);
43
+ }
44
+ if (strategy === "cancel-in-progress") {
45
+ holdersToCancel = [...state.active];
46
+ state.active = [runId];
47
+ await saveState(state);
48
+ return;
49
+ }
50
+ waitForSlot = new Promise((resolve) => {
51
+ waiters.push({ runId, resolve });
52
+ });
53
+ });
54
+ if (holdersToCancel) {
55
+ await Promise.all(holdersToCancel.map((holder) => forwardToRunDO(deps.runDO, holder, "/cancel", {
56
+ reason: "cancelled by workflow concurrency policy",
57
+ }).catch(() => undefined)));
58
+ }
59
+ await waitForSlot;
60
+ }
61
+ async function release(runId) {
62
+ let next;
63
+ await withStateLock(async () => {
64
+ const state = await loadState();
65
+ const wasActive = state.active.includes(runId);
66
+ state.active = state.active.filter((holder) => holder !== runId);
67
+ next = wasActive ? waiters.shift() : undefined;
68
+ if (wasActive && next) {
69
+ state.active.push(next.runId);
70
+ }
71
+ await saveState(state);
72
+ });
73
+ next?.resolve();
74
+ }
75
+ return {
76
+ async fetch(req) {
77
+ const url = new URL(req.url);
78
+ if (req.method === "POST" && url.pathname === "/trigger") {
79
+ const payload = (await req.json());
80
+ try {
81
+ await acquire(payload);
82
+ }
83
+ catch (err) {
84
+ if (err instanceof WorkflowConcurrencyRejectedError) {
85
+ return json(409, {
86
+ error: err.code,
87
+ message: err.message,
88
+ concurrencyKey: err.concurrencyKey,
89
+ });
90
+ }
91
+ throw err;
92
+ }
93
+ let resp;
94
+ try {
95
+ resp = await forwardToRunDO(deps.runDO, payload.trigger.runId, "/trigger", {
96
+ ...payload.trigger,
97
+ concurrencyLease: {
98
+ environment: payload.trigger.environment ?? "development",
99
+ key: payload.concurrency.key,
100
+ runId: payload.trigger.runId,
101
+ },
102
+ });
103
+ if (resp.ok) {
104
+ const record = (await resp.clone().json());
105
+ if (isTerminal(record.status)) {
106
+ await release(record.id);
107
+ }
108
+ }
109
+ else {
110
+ await release(payload.trigger.runId);
111
+ }
112
+ return resp;
113
+ }
114
+ catch (err) {
115
+ await release(payload.trigger.runId);
116
+ throw err;
117
+ }
118
+ }
119
+ if (req.method === "POST" && url.pathname === "/release") {
120
+ const payload = (await req.json());
121
+ await release(payload.runId);
122
+ return json(200, { ok: true });
123
+ }
124
+ if (req.method === "GET" && url.pathname === "/state") {
125
+ return json(200, await loadState());
126
+ }
127
+ return json(404, { error: "route_not_found", path: url.pathname });
128
+ },
129
+ };
130
+ }
131
+ export async function handleConcurrencyCoordinatorRequest(req, deps) {
132
+ const coordinator = deps.coordinator ?? createConcurrencyCoordinator(deps);
133
+ return coordinator.fetch(req);
134
+ }
135
+ async function forwardToRunDO(runDO, runId, path, body) {
136
+ const id = runDO.idFromName(runId);
137
+ const stub = runDO.get(id);
138
+ return stub.fetch(new Request(`https://do-internal${path}`, {
139
+ method: "POST",
140
+ headers: { "content-type": "application/json" },
141
+ body: JSON.stringify(body),
142
+ }));
143
+ }
144
+ function normalizeLimit(limit) {
145
+ if (limit === undefined)
146
+ return 1;
147
+ if (!Number.isFinite(limit))
148
+ return 1;
149
+ return Math.max(1, Math.floor(limit));
150
+ }
151
+ function isTerminal(status) {
152
+ return (status === "completed" ||
153
+ status === "failed" ||
154
+ status === "cancelled" ||
155
+ status === "compensated" ||
156
+ status === "compensation_failed");
157
+ }
158
+ function json(status, body) {
159
+ return new Response(JSON.stringify(body), {
160
+ status,
161
+ headers: { "content-type": "application/json; charset=utf-8" },
162
+ });
163
+ }
@@ -1,6 +1,7 @@
1
1
  import { applyWaitpointInjection, driveUntilPaused, type RunRecord } from "@voyantjs/workflows-orchestrator";
2
2
  import type { StepDispatcher } from "./dispatchers.js";
3
3
  import type { DurableObjectStorageLike } from "./types.js";
4
+ import type { DurableObjectNamespaceLike } from "./worker.js";
4
5
  export interface DurableObjectDeps {
5
6
  storage: DurableObjectStorageLike;
6
7
  /**
@@ -16,6 +17,7 @@ export interface DurableObjectDeps {
16
17
  * - `createHttpDispatcher` — arbitrary HTTP endpoint
17
18
  */
18
19
  dispatcher: StepDispatcher;
20
+ concurrencyDO?: DurableObjectNamespaceLike;
19
21
  now?: () => number;
20
22
  }
21
23
  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,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,CAyEnB;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,EAGV,wBAAwB,EAGzB,MAAM,YAAY,CAAA;AACnB,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAA;AAE7D,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,wBAAwB,CAAA;IACjC;;;;;;;;;;;OAWG;IACH,UAAU,EAAE,cAAc,CAAA;IAC1B,aAAa,CAAC,EAAE,0BAA0B,CAAA;IAC1C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAcD,wBAAsB,0BAA0B,CAC9C,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,iBAAiB,GACtB,OAAO,CAAC,QAAQ,CAAC,CA+EnB;AAED;;;;;GAKG;AACH,wBAAsB,wBAAwB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyCrF;AAyFD,YAAY,EAAE,SAAS,EAAE,CAAA;AAEzB,OAAO,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,CAAA"}
@@ -18,6 +18,7 @@
18
18
  // the public surface is the outer Worker's /api/* routes.
19
19
  import { applyWaitpointInjection, driveUntilPaused, cancel as orchestratorCancel, resume as orchestratorResume, trigger as orchestratorTrigger, } from "@voyantjs/workflows-orchestrator";
20
20
  import { createDurableObjectRunStore } from "./do-store.js";
21
+ const CONCURRENCY_LEASE_KEY = "concurrencyLease";
21
22
  function resolve(deps, record) {
22
23
  return deps.dispatcher({
23
24
  tenantScript: record.tenantMeta.tenantScript,
@@ -29,6 +30,9 @@ export async function handleDurableObjectRequest(req, deps) {
29
30
  const store = createDurableObjectRunStore(deps.storage);
30
31
  if (req.method === "POST" && url.pathname === "/trigger") {
31
32
  const payload = (await req.json());
33
+ if (payload.concurrencyLease) {
34
+ await deps.storage.put(CONCURRENCY_LEASE_KEY, payload.concurrencyLease);
35
+ }
32
36
  const handler = resolve(deps, {
33
37
  tenantMeta: payload.tenantMeta,
34
38
  workflowId: payload.workflowId,
@@ -42,6 +46,7 @@ export async function handleDurableObjectRequest(req, deps) {
42
46
  tags: payload.tags,
43
47
  runId: payload.runId,
44
48
  idempotencyKey: payload.idempotencyKey,
49
+ triggeredBy: payload.triggeredBy,
45
50
  delay: typeof payload.delay === "object" && payload.delay !== null && "wakeAt" in payload.delay
46
51
  ? new Date(payload.delay.wakeAt)
47
52
  : payload.delay,
@@ -62,6 +67,7 @@ export async function handleDurableObjectRequest(req, deps) {
62
67
  return json(status, { error: out.status, message: out.message });
63
68
  }
64
69
  await reconcileAlarm(out.record, store, deps);
70
+ await releaseConcurrencyLeaseIfTerminal(out.record, deps);
65
71
  return json(200, out.record);
66
72
  }
67
73
  if (req.method === "POST" && url.pathname === "/cancel") {
@@ -76,6 +82,7 @@ export async function handleDurableObjectRequest(req, deps) {
76
82
  return json(status, { error: out.status, message: out.message });
77
83
  }
78
84
  await reconcileAlarm(out.record, store, deps);
85
+ await releaseConcurrencyLeaseIfTerminal(out.record, deps);
79
86
  return json(200, out.record);
80
87
  }
81
88
  if (req.method === "GET" && url.pathname === "/get") {
@@ -133,6 +140,7 @@ export async function handleDurableObjectAlarm(deps) {
133
140
  await driveUntilPaused(record, { handler, now: deps.now });
134
141
  await store.save(record);
135
142
  await reconcileAlarm(record, store, deps);
143
+ await releaseConcurrencyLeaseIfTerminal(record, deps);
136
144
  }
137
145
  /**
138
146
  * Look at the record's pending waitpoints; if any are DATETIME,
@@ -177,6 +185,30 @@ async function getStoredRunId(store) {
177
185
  const all = await store.list();
178
186
  return all[0]?.id;
179
187
  }
188
+ async function releaseConcurrencyLeaseIfTerminal(record, deps) {
189
+ if (!deps.concurrencyDO || !isTerminal(record.status))
190
+ return;
191
+ const lease = await deps.storage.get(CONCURRENCY_LEASE_KEY);
192
+ if (!lease)
193
+ return;
194
+ const id = deps.concurrencyDO.idFromName(`workflow-concurrency:${lease.environment}:${lease.key}`);
195
+ const stub = deps.concurrencyDO.get(id);
196
+ await stub
197
+ .fetch(new Request("https://do-internal/release", {
198
+ method: "POST",
199
+ headers: { "content-type": "application/json" },
200
+ body: JSON.stringify({ runId: lease.runId }),
201
+ }))
202
+ .catch(() => undefined);
203
+ await deps.storage.delete(CONCURRENCY_LEASE_KEY);
204
+ }
205
+ function isTerminal(status) {
206
+ return (status === "completed" ||
207
+ status === "failed" ||
208
+ status === "cancelled" ||
209
+ status === "compensated" ||
210
+ status === "compensation_failed");
211
+ }
180
212
  function json(status, body) {
181
213
  return new Response(JSON.stringify(body), {
182
214
  status,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { type BundleLocation, type CfContainerRunnerDeps, type ContainerNamespaceLike, createCfContainerStepRunner, } from "./cf-container-runner.js";
2
2
  export { type CloudflareEdgeDriverOptions, createCloudflareEdgeDriver, } from "./cloudflare-edge-driver.js";
3
+ export { type ConcurrencyCoordinator, type ConcurrencyCoordinatorDeps, createConcurrencyCoordinator, handleConcurrencyCoordinatorRequest, } from "./concurrency-coordinator.js";
3
4
  export { createHttpDispatcher, createInlineDispatcher, createServiceBindingDispatcher, type HttpDispatcherOptions, type ServiceBindingDispatcherOptions, type ServiceBindingLike, type StepDispatcher, type StepDispatcherContext, } from "./dispatchers.js";
4
5
  export { createDurableObjectRunStore } from "./do-store.js";
5
6
  export { type DurableObjectDeps, handleDurableObjectAlarm, handleDurableObjectRequest, } from "./durable-object.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAkCA,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,2BAA2B,GAC5B,MAAM,0BAA0B,CAAA;AACjC,OAAO,EACL,KAAK,2BAA2B,EAChC,0BAA0B,GAC3B,MAAM,6BAA6B,CAAA;AACpC,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,8BAA8B,EAC9B,KAAK,qBAAqB,EAC1B,KAAK,+BAA+B,EACpC,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,qBAAqB,GAC3B,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,2BAA2B,EAAE,MAAM,eAAe,CAAA;AAC3D,OAAO,EACL,KAAK,iBAAiB,EACtB,wBAAwB,EACxB,0BAA0B,GAC3B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,KAAK,4BAA4B,EACjC,gBAAgB,EAChB,qBAAqB,EACrB,KAAK,eAAe,GACrB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACL,iBAAiB,EACjB,KAAK,WAAW,EAChB,KAAK,kBAAkB,GACxB,MAAM,cAAc,CAAA;AACrB,cAAc,YAAY,CAAA;AAC1B,OAAO,EACL,KAAK,0BAA0B,EAC/B,mBAAmB,EACnB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAkCA,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,2BAA2B,GAC5B,MAAM,0BAA0B,CAAA;AACjC,OAAO,EACL,KAAK,2BAA2B,EAChC,0BAA0B,GAC3B,MAAM,6BAA6B,CAAA;AACpC,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,0BAA0B,EAC/B,4BAA4B,EAC5B,mCAAmC,GACpC,MAAM,8BAA8B,CAAA;AACrC,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,8BAA8B,EAC9B,KAAK,qBAAqB,EAC1B,KAAK,+BAA+B,EACpC,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,qBAAqB,GAC3B,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,2BAA2B,EAAE,MAAM,eAAe,CAAA;AAC3D,OAAO,EACL,KAAK,iBAAiB,EACtB,wBAAwB,EACxB,0BAA0B,GAC3B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,KAAK,4BAA4B,EACjC,gBAAgB,EAChB,qBAAqB,EACrB,KAAK,eAAe,GACrB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACL,iBAAiB,EACjB,KAAK,WAAW,EAChB,KAAK,kBAAkB,GACxB,MAAM,cAAc,CAAA;AACrB,cAAc,YAAY,CAAA;AAC1B,OAAO,EACL,KAAK,0BAA0B,EAC/B,mBAAmB,EACnB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAA"}
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@
33
33
  // design this adapter implements.
34
34
  export { createCfContainerStepRunner, } from "./cf-container-runner.js";
35
35
  export { createCloudflareEdgeDriver, } from "./cloudflare-edge-driver.js";
36
+ export { createConcurrencyCoordinator, handleConcurrencyCoordinatorRequest, } from "./concurrency-coordinator.js";
36
37
  export { createHttpDispatcher, createInlineDispatcher, createServiceBindingDispatcher, } from "./dispatchers.js";
37
38
  export { createDurableObjectRunStore } from "./do-store.js";
38
39
  export { handleDurableObjectAlarm, handleDurableObjectRequest, } from "./durable-object.js";
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Duration } from "@voyantjs/workflows";
1
+ import type { Duration, EnvironmentName, RunTrigger } from "@voyantjs/workflows";
2
2
  import type { WaitpointInjection } from "@voyantjs/workflows-orchestrator";
3
3
  /**
4
4
  * Subset of Cloudflare's `DurableObjectStorage` we actually use.
@@ -58,6 +58,13 @@ export interface TriggerPayload {
58
58
  wakeAt: number;
59
59
  };
60
60
  priority?: number;
61
+ triggeredBy?: RunTrigger;
62
+ concurrencyLease?: ConcurrencyLease;
63
+ }
64
+ export interface ConcurrencyLease {
65
+ environment: EnvironmentName;
66
+ key: string;
67
+ runId: string;
61
68
  }
62
69
  /** Cancel payload. */
63
70
  export interface CancelPayload {
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AACnD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAA;AAE1E;;;;;;;;;GASG;AACH,MAAM,WAAW,wBAAwB;IACvC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAA;IAC3C,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IACrC,IAAI,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAA;IAC/E,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACnC,4DAA4D;IAC5D,QAAQ,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACxC,gCAAgC;IAChC,WAAW,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9B;AAED,uDAAuD;AACvD,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,KAAK,CAAA;IAC3C,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,uCAAuC;AACvC,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,kBAAkB,CAAA;CAC9B;AAED,uBAAuB;AACvB,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,eAAe,EAAE,MAAM,CAAA;IACvB,KAAK,EAAE,OAAO,CAAA;IACd,UAAU,EAAE;QACV,QAAQ,EAAE,MAAM,CAAA;QAChB,SAAS,EAAE,MAAM,CAAA;QACjB,cAAc,EAAE,MAAM,CAAA;QACtB;;;;WAIG;QACH,YAAY,CAAC,EAAE,MAAM,CAAA;KACtB,CAAA;IACD,WAAW,CAAC,EAAE,YAAY,GAAG,SAAS,GAAG,aAAa,CAAA;IACtD,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,KAAK,CAAC,EAAE,QAAQ,GAAG;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,sBAAsB;AACtB,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU,CAAC,WAAW,GAAG,OAAO;IAC/C,8FAA8F;IAC9F,eAAe,EAAE,WAAW,CAAA;CAC7B"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAChF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAA;AAE1E;;;;;;;;;GASG;AACH,MAAM,WAAW,wBAAwB;IACvC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAA;IAC3C,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IACrC,IAAI,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAA;IAC/E,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACnC,4DAA4D;IAC5D,QAAQ,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACxC,gCAAgC;IAChC,WAAW,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9B;AAED,uDAAuD;AACvD,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,KAAK,CAAA;IAC3C,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,uCAAuC;AACvC,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,kBAAkB,CAAA;CAC9B;AAED,uBAAuB;AACvB,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,eAAe,EAAE,MAAM,CAAA;IACvB,KAAK,EAAE,OAAO,CAAA;IACd,UAAU,EAAE;QACV,QAAQ,EAAE,MAAM,CAAA;QAChB,SAAS,EAAE,MAAM,CAAA;QACjB,cAAc,EAAE,MAAM,CAAA;QACtB;;;;WAIG;QACH,YAAY,CAAC,EAAE,MAAM,CAAA;KACtB,CAAA;IACD,WAAW,CAAC,EAAE,YAAY,GAAG,SAAS,GAAG,aAAa,CAAA;IACtD,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,KAAK,CAAC,EAAE,QAAQ,GAAG;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,UAAU,CAAA;IACxB,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;CACpC;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,eAAe,CAAA;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd;AAED,sBAAsB;AACtB,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU,CAAC,WAAW,GAAG,OAAO;IAC/C,8FAA8F;IAC9F,eAAe,EAAE,WAAW,CAAA;CAC7B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyantjs/workflows-orchestrator-cloudflare",
3
- "version": "0.30.3",
3
+ "version": "0.30.5",
4
4
  "description": "Cloudflare Worker + Durable Object adapter for @voyantjs/workflows-orchestrator. Dispatches workflow-step requests to tenant Workers via a Workers-for-Platforms dispatch namespace.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -26,8 +26,8 @@
26
26
  "NOTICE"
27
27
  ],
28
28
  "dependencies": {
29
- "@voyantjs/workflows-orchestrator": "0.30.3",
30
- "@voyantjs/workflows": "0.30.3"
29
+ "@voyantjs/workflows-orchestrator": "0.30.5",
30
+ "@voyantjs/workflows": "0.30.5"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@cloudflare/vitest-pool-workers": "^0.15.1",
@@ -24,6 +24,7 @@ import type {
24
24
  RunDetail,
25
25
  RunSummary,
26
26
  TriggerOptions,
27
+ WorkflowDefinition,
27
28
  } from "@voyantjs/workflows"
28
29
  import type {
29
30
  DriverFactory,
@@ -36,13 +37,19 @@ import type {
36
37
  } from "@voyantjs/workflows/driver"
37
38
  import { deriveStableEventId } from "@voyantjs/workflows/events"
38
39
  import type { WorkflowManifest } from "@voyantjs/workflows/protocol"
39
- import { routeEvent } from "@voyantjs/workflows-orchestrator"
40
+ import {
41
+ type RuntimeConcurrencyPolicy,
42
+ resolveConcurrencyKey,
43
+ routeEvent,
44
+ WorkflowConcurrencyRejectedError,
45
+ } from "@voyantjs/workflows-orchestrator"
40
46
 
41
47
  import {
42
48
  type CfManifestStore,
43
49
  createKvManifestStore,
44
50
  type KvNamespaceLike,
45
51
  } from "./manifest-kv-store.js"
52
+ import type { TriggerPayload } from "./types.js"
46
53
  import type { DurableObjectNamespaceLike } from "./worker.js"
47
54
 
48
55
  // ---- Public factory options ----
@@ -50,6 +57,8 @@ import type { DurableObjectNamespaceLike } from "./worker.js"
50
57
  export interface CloudflareEdgeDriverOptions {
51
58
  /** Durable Object namespace holding one DO per run. */
52
59
  orchestratorNamespace: DurableObjectNamespaceLike
60
+ /** Optional Durable Object namespace coordinating workflow concurrency across runs. */
61
+ concurrencyNamespace?: DurableObjectNamespaceLike
53
62
  /** KV namespace storing serialized manifests. */
54
63
  manifestKv: KvNamespaceLike
55
64
  /**
@@ -129,6 +138,99 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
129
138
  return stub.fetch(request)
130
139
  }
131
140
 
141
+ async function forwardToConcurrencyDO(
142
+ environment: EnvironmentName,
143
+ concurrencyKey: string,
144
+ request: Request,
145
+ ): Promise<Response> {
146
+ if (!opts.concurrencyNamespace) {
147
+ throw new Error("CloudflareEdgeDriver: concurrency namespace is not configured")
148
+ }
149
+ const id = opts.concurrencyNamespace.idFromName(
150
+ `workflow-concurrency:${environment}:${concurrencyKey}`,
151
+ )
152
+ const stub = opts.concurrencyNamespace.get(id)
153
+ return stub.fetch(request)
154
+ }
155
+
156
+ function findManifestPolicy(
157
+ manifest: WorkflowManifest | null,
158
+ workflowId: string,
159
+ ): RuntimeConcurrencyPolicy | undefined {
160
+ return manifest?.workflows.find((entry) => entry.id === workflowId)?.concurrency
161
+ }
162
+
163
+ async function resolveConcurrencyPolicy(
164
+ workflow: { config?: { concurrency?: unknown } } | string,
165
+ workflowId: string,
166
+ environment: EnvironmentName,
167
+ manifest?: WorkflowManifest | null,
168
+ ): Promise<RuntimeConcurrencyPolicy | undefined> {
169
+ if (typeof workflow !== "string" && workflow.config?.concurrency) {
170
+ return workflow.config.concurrency as RuntimeConcurrencyPolicy
171
+ }
172
+ return findManifestPolicy(manifest ?? (await getManifest({ environment })), workflowId)
173
+ }
174
+
175
+ async function forwardTrigger(
176
+ payload: TriggerPayload & { runId: string; environment: EnvironmentName },
177
+ policy?: RuntimeConcurrencyPolicy,
178
+ ): Promise<Response> {
179
+ if (!policy || !opts.concurrencyNamespace) {
180
+ return forwardToRunDO(
181
+ payload.runId,
182
+ new Request("https://do-internal/trigger", {
183
+ method: "POST",
184
+ headers: { "content-type": "application/json" },
185
+ body: JSON.stringify(payload),
186
+ }),
187
+ )
188
+ }
189
+
190
+ const concurrencyKey = resolveConcurrencyKey(payload.workflowId, payload.input, policy)
191
+ const resp = await forwardToConcurrencyDO(
192
+ payload.environment,
193
+ concurrencyKey,
194
+ new Request("https://do-internal/trigger", {
195
+ method: "POST",
196
+ headers: { "content-type": "application/json" },
197
+ body: JSON.stringify({
198
+ concurrency: {
199
+ key: concurrencyKey,
200
+ limit: policy.limit,
201
+ strategy: policy.strategy,
202
+ },
203
+ trigger: payload,
204
+ }),
205
+ }),
206
+ )
207
+ if (resp.status === 409) {
208
+ const body = await safeJson<{ concurrencyKey?: string }>(resp)
209
+ throw new WorkflowConcurrencyRejectedError(body?.concurrencyKey ?? concurrencyKey)
210
+ }
211
+ return resp
212
+ }
213
+
214
+ async function releaseConcurrencySlot(detail: RunDetail | null): Promise<void> {
215
+ if (!detail || !opts.concurrencyNamespace) return
216
+ const policy = await resolveConcurrencyPolicy(
217
+ detail.workflowId,
218
+ detail.workflowId,
219
+ detail.environment,
220
+ )
221
+ if (!policy) return
222
+ const concurrencyKey = resolveConcurrencyKey(detail.workflowId, detail.input, policy)
223
+ await forwardToConcurrencyDO(
224
+ detail.environment,
225
+ concurrencyKey,
226
+ new Request("https://do-internal/release", {
227
+ method: "POST",
228
+ headers: { "content-type": "application/json" },
229
+ body: JSON.stringify({ runId: detail.id }),
230
+ }),
231
+ ).catch(() => undefined)
232
+ }
233
+
132
234
  function genRunId(seed?: string): string {
133
235
  if (seed !== undefined) return seed
134
236
  if (opts.idGenerator) return opts.idGenerator()
@@ -162,7 +264,7 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
162
264
  }
163
265
 
164
266
  async function trigger<TIn, TOut>(
165
- workflow: { id: string } | string,
267
+ workflow: WorkflowDefinition<TIn, TOut> | string,
166
268
  input: TIn,
167
269
  triggerOpts?: TriggerOptions,
168
270
  ): Promise<Run<TOut>> {
@@ -190,13 +292,10 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
190
292
  priority: triggerOpts?.priority,
191
293
  triggeredBy: { kind: "api" as const },
192
294
  }
193
- const resp = await forwardToRunDO(
194
- runId,
195
- new Request("https://do-internal/trigger", {
196
- method: "POST",
197
- headers: { "content-type": "application/json" },
198
- body: JSON.stringify(payload),
199
- }),
295
+ const policy = await resolveConcurrencyPolicy(workflow, workflowId, env)
296
+ const resp = await forwardTrigger(
297
+ payload as TriggerPayload & { runId: string; environment: EnvironmentName },
298
+ policy,
200
299
  )
201
300
  if (!resp.ok) {
202
301
  const body = await safeText(resp)
@@ -273,13 +372,15 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
273
372
  },
274
373
  }
275
374
  try {
276
- const resp = await forwardToRunDO(
277
- runId,
278
- new Request("https://do-internal/trigger", {
279
- method: "POST",
280
- headers: { "content-type": "application/json" },
281
- body: JSON.stringify(payload),
282
- }),
375
+ const policy = await resolveConcurrencyPolicy(
376
+ entry.targetWorkflowId,
377
+ entry.targetWorkflowId,
378
+ args.environment,
379
+ manifest,
380
+ )
381
+ const resp = await forwardTrigger(
382
+ payload as TriggerPayload & { runId: string; environment: EnvironmentName },
383
+ policy,
283
384
  )
284
385
  if (resp.ok) {
285
386
  matches.push({
@@ -334,55 +435,60 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
334
435
  // streamRun are explicitly unsupported per architecture
335
436
  // doc §8.3) ----
336
437
 
438
+ async function getRunDetail(runId: string): Promise<RunDetail | null> {
439
+ try {
440
+ const resp = await forwardToRunDO(
441
+ runId,
442
+ new Request("https://do-internal/get", { method: "GET" }),
443
+ )
444
+ if (resp.status === 404) return null
445
+ if (!resp.ok) return null
446
+ const rec = (await resp.json()) as {
447
+ id: string
448
+ workflowId: string
449
+ workflowVersion: string
450
+ status: RunSummary["status"]
451
+ startedAt: number
452
+ completedAt?: number
453
+ tags: string[]
454
+ environment: EnvironmentName
455
+ input: unknown
456
+ output?: unknown
457
+ error?: unknown
458
+ }
459
+ return {
460
+ id: rec.id,
461
+ workflowId: rec.workflowId,
462
+ status: rec.status,
463
+ startedAt: rec.startedAt,
464
+ completedAt: rec.completedAt,
465
+ tags: [...rec.tags],
466
+ environment: rec.environment,
467
+ version: rec.workflowVersion,
468
+ input: rec.input,
469
+ output: rec.output,
470
+ error: rec.error,
471
+ durationMs:
472
+ rec.completedAt !== undefined
473
+ ? Math.max(0, rec.completedAt - rec.startedAt)
474
+ : undefined,
475
+ }
476
+ } catch {
477
+ return null
478
+ }
479
+ }
480
+
337
481
  const admin: Partial<WorkflowAdmin> = {
338
482
  async getRun(runId: string): Promise<RunDetail | null> {
339
- try {
340
- const resp = await forwardToRunDO(
341
- runId,
342
- new Request("https://do-internal/get", { method: "GET" }),
343
- )
344
- if (resp.status === 404) return null
345
- if (!resp.ok) return null
346
- const rec = (await resp.json()) as {
347
- id: string
348
- workflowId: string
349
- workflowVersion: string
350
- status: RunSummary["status"]
351
- startedAt: number
352
- completedAt?: number
353
- tags: string[]
354
- environment: EnvironmentName
355
- input: unknown
356
- output?: unknown
357
- error?: unknown
358
- }
359
- return {
360
- id: rec.id,
361
- workflowId: rec.workflowId,
362
- status: rec.status,
363
- startedAt: rec.startedAt,
364
- completedAt: rec.completedAt,
365
- tags: [...rec.tags],
366
- environment: rec.environment,
367
- version: rec.workflowVersion,
368
- input: rec.input,
369
- output: rec.output,
370
- error: rec.error,
371
- durationMs:
372
- rec.completedAt !== undefined
373
- ? Math.max(0, rec.completedAt - rec.startedAt)
374
- : undefined,
375
- }
376
- } catch {
377
- return null
378
- }
483
+ return getRunDetail(runId)
379
484
  },
380
485
 
381
486
  async cancelRun(runId: string, cancelOpts?: { reason?: string; compensate?: boolean }) {
382
487
  // Per architecture doc §21.21, cancel does NOT run compensations
383
488
  // by default; the `compensate` flag is accepted but no-op in v1.
384
489
  void cancelOpts?.compensate
385
- await forwardToRunDO(
490
+ const detail = opts.concurrencyNamespace ? await getRunDetail(runId) : null
491
+ const resp = await forwardToRunDO(
386
492
  runId,
387
493
  new Request("https://do-internal/cancel", {
388
494
  method: "POST",
@@ -390,6 +496,7 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
390
496
  body: JSON.stringify({ reason: cancelOpts?.reason }),
391
497
  }),
392
498
  )
499
+ if (resp.ok) await releaseConcurrencySlot(detail)
393
500
  },
394
501
 
395
502
  async listRuns(_listOpts?: ListRunsOptions) {
@@ -438,3 +545,11 @@ async function safeText(resp: Response): Promise<string> {
438
545
  return ""
439
546
  }
440
547
  }
548
+
549
+ async function safeJson<T>(resp: Response): Promise<T | undefined> {
550
+ try {
551
+ return (await resp.json()) as T
552
+ } catch {
553
+ return undefined
554
+ }
555
+ }
@@ -0,0 +1,237 @@
1
+ import {
2
+ type RunRecord,
3
+ type RuntimeConcurrencyPolicy,
4
+ WorkflowConcurrencyRejectedError,
5
+ } from "@voyantjs/workflows-orchestrator"
6
+
7
+ import type { DurableObjectStorageLike, TriggerPayload } from "./types.js"
8
+ import type { DurableObjectNamespaceLike } from "./worker.js"
9
+
10
+ export interface ConcurrencyCoordinatorDeps<Id = unknown> {
11
+ storage: DurableObjectStorageLike
12
+ runDO: DurableObjectNamespaceLike<Id>
13
+ }
14
+
15
+ export interface ConcurrencyCoordinator {
16
+ fetch(req: Request): Promise<Response>
17
+ }
18
+
19
+ interface CoordinatorState {
20
+ active: string[]
21
+ }
22
+
23
+ interface ConcurrencyTriggerPayload {
24
+ concurrency: {
25
+ key: string
26
+ limit?: number
27
+ strategy?: RuntimeConcurrencyPolicy["strategy"]
28
+ }
29
+ trigger: TriggerPayload & { runId: string }
30
+ }
31
+
32
+ interface ReleasePayload {
33
+ runId: string
34
+ }
35
+
36
+ interface Waiter {
37
+ runId: string
38
+ resolve(): void
39
+ }
40
+
41
+ const STATE_KEY = "state"
42
+
43
+ export function createConcurrencyCoordinator<Id>(
44
+ deps: ConcurrencyCoordinatorDeps<Id>,
45
+ ): ConcurrencyCoordinator {
46
+ const waiters: Waiter[] = []
47
+ let stateLock: Promise<void> = Promise.resolve()
48
+
49
+ async function withStateLock<T>(fn: () => Promise<T>): Promise<T> {
50
+ const previous = stateLock
51
+ let release!: () => void
52
+ stateLock = new Promise<void>((resolve) => {
53
+ release = resolve
54
+ })
55
+ await previous
56
+ try {
57
+ return await fn()
58
+ } finally {
59
+ release()
60
+ }
61
+ }
62
+
63
+ async function loadState(): Promise<CoordinatorState> {
64
+ return (await deps.storage.get<CoordinatorState>(STATE_KEY)) ?? { active: [] }
65
+ }
66
+
67
+ async function saveState(state: CoordinatorState): Promise<void> {
68
+ await deps.storage.put(STATE_KEY, { active: [...state.active] })
69
+ }
70
+
71
+ async function acquire(payload: ConcurrencyTriggerPayload): Promise<void> {
72
+ const runId = payload.trigger.runId
73
+ let holdersToCancel: string[] | undefined
74
+ let waitForSlot: Promise<void> | undefined
75
+
76
+ await withStateLock(async () => {
77
+ const state = await loadState()
78
+ if (state.active.includes(runId)) return
79
+
80
+ const limit = normalizeLimit(payload.concurrency.limit)
81
+ if (state.active.length < limit) {
82
+ state.active.push(runId)
83
+ await saveState(state)
84
+ return
85
+ }
86
+
87
+ const strategy = payload.concurrency.strategy ?? "queue"
88
+ if (strategy === "cancel-newest") {
89
+ throw new WorkflowConcurrencyRejectedError(payload.concurrency.key)
90
+ }
91
+
92
+ if (strategy === "cancel-in-progress") {
93
+ holdersToCancel = [...state.active]
94
+ state.active = [runId]
95
+ await saveState(state)
96
+ return
97
+ }
98
+
99
+ waitForSlot = new Promise<void>((resolve) => {
100
+ waiters.push({ runId, resolve })
101
+ })
102
+ })
103
+
104
+ if (holdersToCancel) {
105
+ await Promise.all(
106
+ holdersToCancel.map((holder) =>
107
+ forwardToRunDO(deps.runDO, holder, "/cancel", {
108
+ reason: "cancelled by workflow concurrency policy",
109
+ }).catch(() => undefined),
110
+ ),
111
+ )
112
+ }
113
+
114
+ await waitForSlot
115
+ }
116
+
117
+ async function release(runId: string): Promise<void> {
118
+ let next: Waiter | undefined
119
+ await withStateLock(async () => {
120
+ const state = await loadState()
121
+ const wasActive = state.active.includes(runId)
122
+ state.active = state.active.filter((holder) => holder !== runId)
123
+ next = wasActive ? waiters.shift() : undefined
124
+ if (wasActive && next) {
125
+ state.active.push(next.runId)
126
+ }
127
+ await saveState(state)
128
+ })
129
+ next?.resolve()
130
+ }
131
+
132
+ return {
133
+ async fetch(req) {
134
+ const url = new URL(req.url)
135
+
136
+ if (req.method === "POST" && url.pathname === "/trigger") {
137
+ const payload = (await req.json()) as ConcurrencyTriggerPayload
138
+ try {
139
+ await acquire(payload)
140
+ } catch (err) {
141
+ if (err instanceof WorkflowConcurrencyRejectedError) {
142
+ return json(409, {
143
+ error: err.code,
144
+ message: err.message,
145
+ concurrencyKey: err.concurrencyKey,
146
+ })
147
+ }
148
+ throw err
149
+ }
150
+
151
+ let resp: Response | undefined
152
+ try {
153
+ resp = await forwardToRunDO(deps.runDO, payload.trigger.runId, "/trigger", {
154
+ ...payload.trigger,
155
+ concurrencyLease: {
156
+ environment: payload.trigger.environment ?? "development",
157
+ key: payload.concurrency.key,
158
+ runId: payload.trigger.runId,
159
+ },
160
+ })
161
+ if (resp.ok) {
162
+ const record = (await resp.clone().json()) as RunRecord
163
+ if (isTerminal(record.status)) {
164
+ await release(record.id)
165
+ }
166
+ } else {
167
+ await release(payload.trigger.runId)
168
+ }
169
+ return resp
170
+ } catch (err) {
171
+ await release(payload.trigger.runId)
172
+ throw err
173
+ }
174
+ }
175
+
176
+ if (req.method === "POST" && url.pathname === "/release") {
177
+ const payload = (await req.json()) as ReleasePayload
178
+ await release(payload.runId)
179
+ return json(200, { ok: true })
180
+ }
181
+
182
+ if (req.method === "GET" && url.pathname === "/state") {
183
+ return json(200, await loadState())
184
+ }
185
+
186
+ return json(404, { error: "route_not_found", path: url.pathname })
187
+ },
188
+ }
189
+ }
190
+
191
+ export async function handleConcurrencyCoordinatorRequest<Id>(
192
+ req: Request,
193
+ deps: ConcurrencyCoordinatorDeps<Id> & { coordinator?: ConcurrencyCoordinator },
194
+ ): Promise<Response> {
195
+ const coordinator = deps.coordinator ?? createConcurrencyCoordinator(deps)
196
+ return coordinator.fetch(req)
197
+ }
198
+
199
+ async function forwardToRunDO<Id>(
200
+ runDO: DurableObjectNamespaceLike<Id>,
201
+ runId: string,
202
+ path: "/trigger" | "/cancel",
203
+ body: unknown,
204
+ ): Promise<Response> {
205
+ const id = runDO.idFromName(runId)
206
+ const stub = runDO.get(id)
207
+ return stub.fetch(
208
+ new Request(`https://do-internal${path}`, {
209
+ method: "POST",
210
+ headers: { "content-type": "application/json" },
211
+ body: JSON.stringify(body),
212
+ }),
213
+ )
214
+ }
215
+
216
+ function normalizeLimit(limit: number | undefined): number {
217
+ if (limit === undefined) return 1
218
+ if (!Number.isFinite(limit)) return 1
219
+ return Math.max(1, Math.floor(limit))
220
+ }
221
+
222
+ function isTerminal(status: RunRecord["status"]): boolean {
223
+ return (
224
+ status === "completed" ||
225
+ status === "failed" ||
226
+ status === "cancelled" ||
227
+ status === "compensated" ||
228
+ status === "compensation_failed"
229
+ )
230
+ }
231
+
232
+ function json(status: number, body: unknown): Response {
233
+ return new Response(JSON.stringify(body), {
234
+ status,
235
+ headers: { "content-type": "application/json; charset=utf-8" },
236
+ })
237
+ }
@@ -32,10 +32,12 @@ import type { StepDispatcher } from "./dispatchers.js"
32
32
  import { createDurableObjectRunStore } from "./do-store.js"
33
33
  import type {
34
34
  CancelPayload,
35
+ ConcurrencyLease,
35
36
  DurableObjectStorageLike,
36
37
  ResumePayload,
37
38
  TriggerPayload,
38
39
  } from "./types.js"
40
+ import type { DurableObjectNamespaceLike } from "./worker.js"
39
41
 
40
42
  export interface DurableObjectDeps {
41
43
  storage: DurableObjectStorageLike
@@ -52,9 +54,12 @@ export interface DurableObjectDeps {
52
54
  * - `createHttpDispatcher` — arbitrary HTTP endpoint
53
55
  */
54
56
  dispatcher: StepDispatcher
57
+ concurrencyDO?: DurableObjectNamespaceLike
55
58
  now?: () => number
56
59
  }
57
60
 
61
+ const CONCURRENCY_LEASE_KEY = "concurrencyLease"
62
+
58
63
  function resolve(
59
64
  deps: DurableObjectDeps,
60
65
  record: { tenantMeta: { tenantScript?: string }; workflowId: string },
@@ -74,6 +79,9 @@ export async function handleDurableObjectRequest(
74
79
 
75
80
  if (req.method === "POST" && url.pathname === "/trigger") {
76
81
  const payload = (await req.json()) as TriggerPayload
82
+ if (payload.concurrencyLease) {
83
+ await deps.storage.put(CONCURRENCY_LEASE_KEY, payload.concurrencyLease)
84
+ }
77
85
  const handler = resolve(deps, {
78
86
  tenantMeta: payload.tenantMeta,
79
87
  workflowId: payload.workflowId,
@@ -88,6 +96,7 @@ export async function handleDurableObjectRequest(
88
96
  tags: payload.tags,
89
97
  runId: payload.runId,
90
98
  idempotencyKey: payload.idempotencyKey,
99
+ triggeredBy: payload.triggeredBy,
91
100
  delay:
92
101
  typeof payload.delay === "object" && payload.delay !== null && "wakeAt" in payload.delay
93
102
  ? new Date(payload.delay.wakeAt)
@@ -114,6 +123,7 @@ export async function handleDurableObjectRequest(
114
123
  return json(status, { error: out.status, message: out.message })
115
124
  }
116
125
  await reconcileAlarm(out.record, store, deps)
126
+ await releaseConcurrencyLeaseIfTerminal(out.record, deps)
117
127
  return json(200, out.record)
118
128
  }
119
129
 
@@ -131,6 +141,7 @@ export async function handleDurableObjectRequest(
131
141
  return json(status, { error: out.status, message: out.message })
132
142
  }
133
143
  await reconcileAlarm(out.record, store, deps)
144
+ await releaseConcurrencyLeaseIfTerminal(out.record, deps)
134
145
  return json(200, out.record)
135
146
  }
136
147
 
@@ -189,6 +200,7 @@ export async function handleDurableObjectAlarm(deps: DurableObjectDeps): Promise
189
200
  await driveUntilPaused(record, { handler, now: deps.now })
190
201
  await store.save(record)
191
202
  await reconcileAlarm(record, store, deps)
203
+ await releaseConcurrencyLeaseIfTerminal(record, deps)
192
204
  }
193
205
 
194
206
  /**
@@ -239,6 +251,38 @@ async function getStoredRunId(
239
251
  return all[0]?.id
240
252
  }
241
253
 
254
+ async function releaseConcurrencyLeaseIfTerminal(
255
+ record: RunRecord,
256
+ deps: DurableObjectDeps,
257
+ ): Promise<void> {
258
+ if (!deps.concurrencyDO || !isTerminal(record.status)) return
259
+ const lease = await deps.storage.get<ConcurrencyLease>(CONCURRENCY_LEASE_KEY)
260
+ if (!lease) return
261
+
262
+ const id = deps.concurrencyDO.idFromName(`workflow-concurrency:${lease.environment}:${lease.key}`)
263
+ const stub = deps.concurrencyDO.get(id)
264
+ await stub
265
+ .fetch(
266
+ new Request("https://do-internal/release", {
267
+ method: "POST",
268
+ headers: { "content-type": "application/json" },
269
+ body: JSON.stringify({ runId: lease.runId }),
270
+ }),
271
+ )
272
+ .catch(() => undefined)
273
+ await deps.storage.delete(CONCURRENCY_LEASE_KEY)
274
+ }
275
+
276
+ function isTerminal(status: RunRecord["status"]): boolean {
277
+ return (
278
+ status === "completed" ||
279
+ status === "failed" ||
280
+ status === "cancelled" ||
281
+ status === "compensated" ||
282
+ status === "compensation_failed"
283
+ )
284
+ }
285
+
242
286
  function json(status: number, body: unknown): Response {
243
287
  return new Response(JSON.stringify(body), {
244
288
  status,
package/src/index.ts CHANGED
@@ -42,6 +42,12 @@ export {
42
42
  type CloudflareEdgeDriverOptions,
43
43
  createCloudflareEdgeDriver,
44
44
  } from "./cloudflare-edge-driver.js"
45
+ export {
46
+ type ConcurrencyCoordinator,
47
+ type ConcurrencyCoordinatorDeps,
48
+ createConcurrencyCoordinator,
49
+ handleConcurrencyCoordinatorRequest,
50
+ } from "./concurrency-coordinator.js"
45
51
  export {
46
52
  createHttpDispatcher,
47
53
  createInlineDispatcher,
package/src/types.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // a hard dependency on `@cloudflare/workers-types` — matching the
3
3
  // shape is enough, and tests can pass plain objects.
4
4
 
5
- import type { Duration } from "@voyantjs/workflows"
5
+ import type { Duration, EnvironmentName, RunTrigger } from "@voyantjs/workflows"
6
6
  import type { WaitpointInjection } from "@voyantjs/workflows-orchestrator"
7
7
 
8
8
  /**
@@ -61,6 +61,14 @@ export interface TriggerPayload {
61
61
  idempotencyKey?: string
62
62
  delay?: Duration | { wakeAt: number }
63
63
  priority?: number
64
+ triggeredBy?: RunTrigger
65
+ concurrencyLease?: ConcurrencyLease
66
+ }
67
+
68
+ export interface ConcurrencyLease {
69
+ environment: EnvironmentName
70
+ key: string
71
+ runId: string
64
72
  }
65
73
 
66
74
  /** Cancel payload. */