@voyantjs/workflows-orchestrator-cloudflare 0.30.1 → 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,CAkU3F"}
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;
@@ -108,13 +165,11 @@ export function createCloudflareEdgeDriver(opts) {
108
165
  delay: triggerOpts?.delay instanceof Date
109
166
  ? { wakeAt: triggerOpts.delay.getTime() }
110
167
  : triggerOpts?.delay,
168
+ priority: triggerOpts?.priority,
111
169
  triggeredBy: { kind: "api" },
112
170
  };
113
- const resp = await forwardToRunDO(runId, new Request("https://do-internal/trigger", {
114
- method: "POST",
115
- headers: { "content-type": "application/json" },
116
- body: JSON.stringify(payload),
117
- }));
171
+ const policy = await resolveConcurrencyPolicy(workflow, workflowId, env);
172
+ const resp = await forwardTrigger(payload, policy);
118
173
  if (!resp.ok) {
119
174
  const body = await safeText(resp);
120
175
  throw new Error(`CloudflareEdgeDriver: trigger DO returned ${resp.status}: ${body.slice(0, 256)}`);
@@ -180,11 +235,8 @@ export function createCloudflareEdgeDriver(opts) {
180
235
  },
181
236
  };
182
237
  try {
183
- const resp = await forwardToRunDO(runId, new Request("https://do-internal/trigger", {
184
- method: "POST",
185
- headers: { "content-type": "application/json" },
186
- body: JSON.stringify(payload),
187
- }));
238
+ const policy = await resolveConcurrencyPolicy(entry.targetWorkflowId, entry.targetWorkflowId, args.environment, manifest);
239
+ const resp = await forwardTrigger(payload, policy);
188
240
  if (resp.ok) {
189
241
  matches.push({
190
242
  filterId: entry.filterId,
@@ -236,45 +288,51 @@ export function createCloudflareEdgeDriver(opts) {
236
288
  // layer; getRun + cancelRun are direct DO RPC; listRuns +
237
289
  // streamRun are explicitly unsupported per architecture
238
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
+ }
239
320
  const admin = {
240
321
  async getRun(runId) {
241
- try {
242
- const resp = await forwardToRunDO(runId, new Request("https://do-internal/get", { method: "GET" }));
243
- if (resp.status === 404)
244
- return null;
245
- if (!resp.ok)
246
- return null;
247
- const rec = (await resp.json());
248
- return {
249
- id: rec.id,
250
- workflowId: rec.workflowId,
251
- status: rec.status,
252
- startedAt: rec.startedAt,
253
- completedAt: rec.completedAt,
254
- tags: [...rec.tags],
255
- environment: rec.environment,
256
- version: rec.workflowVersion,
257
- input: rec.input,
258
- output: rec.output,
259
- error: rec.error,
260
- durationMs: rec.completedAt !== undefined
261
- ? Math.max(0, rec.completedAt - rec.startedAt)
262
- : undefined,
263
- };
264
- }
265
- catch {
266
- return null;
267
- }
322
+ return getRunDetail(runId);
268
323
  },
269
324
  async cancelRun(runId, cancelOpts) {
270
325
  // Per architecture doc §21.21, cancel does NOT run compensations
271
326
  // by default; the `compensate` flag is accepted but no-op in v1.
272
327
  void cancelOpts?.compensate;
273
- 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", {
274
330
  method: "POST",
275
331
  headers: { "content-type": "application/json" },
276
332
  body: JSON.stringify({ reason: cancelOpts?.reason }),
277
333
  }));
334
+ if (resp.ok)
335
+ await releaseConcurrencySlot(detail);
278
336
  },
279
337
  async listRuns(_listOpts) {
280
338
  // Self-host Mode 1 has no native cross-run query layer; voyant-cloud
@@ -318,3 +376,11 @@ async function safeText(resp) {
318
376
  return "";
319
377
  }
320
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,CAwEnB;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,9 +46,11 @@ 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,
53
+ priority: payload.priority,
48
54
  }, { store, handler, now: deps.now });
49
55
  await reconcileAlarm(record, store, deps);
50
56
  return json(200, record);
@@ -61,6 +67,7 @@ export async function handleDurableObjectRequest(req, deps) {
61
67
  return json(status, { error: out.status, message: out.message });
62
68
  }
63
69
  await reconcileAlarm(out.record, store, deps);
70
+ await releaseConcurrencyLeaseIfTerminal(out.record, deps);
64
71
  return json(200, out.record);
65
72
  }
66
73
  if (req.method === "POST" && url.pathname === "/cancel") {
@@ -75,6 +82,7 @@ export async function handleDurableObjectRequest(req, deps) {
75
82
  return json(status, { error: out.status, message: out.message });
76
83
  }
77
84
  await reconcileAlarm(out.record, store, deps);
85
+ await releaseConcurrencyLeaseIfTerminal(out.record, deps);
78
86
  return json(200, out.record);
79
87
  }
80
88
  if (req.method === "GET" && url.pathname === "/get") {
@@ -132,6 +140,7 @@ export async function handleDurableObjectAlarm(deps) {
132
140
  await driveUntilPaused(record, { handler, now: deps.now });
133
141
  await store.save(record);
134
142
  await reconcileAlarm(record, store, deps);
143
+ await releaseConcurrencyLeaseIfTerminal(record, deps);
135
144
  }
136
145
  /**
137
146
  * Look at the record's pending waitpoints; if any are DATETIME,
@@ -176,6 +185,30 @@ async function getStoredRunId(store) {
176
185
  const all = await store.list();
177
186
  return all[0]?.id;
178
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
+ }
179
212
  function json(status, body) {
180
213
  return new Response(JSON.stringify(body), {
181
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.
@@ -57,6 +57,14 @@ export interface TriggerPayload {
57
57
  delay?: Duration | {
58
58
  wakeAt: number;
59
59
  };
60
+ priority?: number;
61
+ triggeredBy?: RunTrigger;
62
+ concurrencyLease?: ConcurrencyLease;
63
+ }
64
+ export interface ConcurrencyLease {
65
+ environment: EnvironmentName;
66
+ key: string;
67
+ runId: string;
60
68
  }
61
69
  /** Cancel payload. */
62
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;CACtC;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.1",
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.1",
30
- "@voyantjs/workflows": "0.30.1"
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>> {
@@ -187,15 +289,13 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
187
289
  triggerOpts?.delay instanceof Date
188
290
  ? { wakeAt: triggerOpts.delay.getTime() }
189
291
  : triggerOpts?.delay,
292
+ priority: triggerOpts?.priority,
190
293
  triggeredBy: { kind: "api" as const },
191
294
  }
192
- const resp = await forwardToRunDO(
193
- runId,
194
- new Request("https://do-internal/trigger", {
195
- method: "POST",
196
- headers: { "content-type": "application/json" },
197
- body: JSON.stringify(payload),
198
- }),
295
+ const policy = await resolveConcurrencyPolicy(workflow, workflowId, env)
296
+ const resp = await forwardTrigger(
297
+ payload as TriggerPayload & { runId: string; environment: EnvironmentName },
298
+ policy,
199
299
  )
200
300
  if (!resp.ok) {
201
301
  const body = await safeText(resp)
@@ -272,13 +372,15 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
272
372
  },
273
373
  }
274
374
  try {
275
- const resp = await forwardToRunDO(
276
- runId,
277
- new Request("https://do-internal/trigger", {
278
- method: "POST",
279
- headers: { "content-type": "application/json" },
280
- body: JSON.stringify(payload),
281
- }),
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,
282
384
  )
283
385
  if (resp.ok) {
284
386
  matches.push({
@@ -333,55 +435,60 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
333
435
  // streamRun are explicitly unsupported per architecture
334
436
  // doc §8.3) ----
335
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
+
336
481
  const admin: Partial<WorkflowAdmin> = {
337
482
  async getRun(runId: string): Promise<RunDetail | null> {
338
- try {
339
- const resp = await forwardToRunDO(
340
- runId,
341
- new Request("https://do-internal/get", { method: "GET" }),
342
- )
343
- if (resp.status === 404) return null
344
- if (!resp.ok) return null
345
- const rec = (await resp.json()) as {
346
- id: string
347
- workflowId: string
348
- workflowVersion: string
349
- status: RunSummary["status"]
350
- startedAt: number
351
- completedAt?: number
352
- tags: string[]
353
- environment: EnvironmentName
354
- input: unknown
355
- output?: unknown
356
- error?: unknown
357
- }
358
- return {
359
- id: rec.id,
360
- workflowId: rec.workflowId,
361
- status: rec.status,
362
- startedAt: rec.startedAt,
363
- completedAt: rec.completedAt,
364
- tags: [...rec.tags],
365
- environment: rec.environment,
366
- version: rec.workflowVersion,
367
- input: rec.input,
368
- output: rec.output,
369
- error: rec.error,
370
- durationMs:
371
- rec.completedAt !== undefined
372
- ? Math.max(0, rec.completedAt - rec.startedAt)
373
- : undefined,
374
- }
375
- } catch {
376
- return null
377
- }
483
+ return getRunDetail(runId)
378
484
  },
379
485
 
380
486
  async cancelRun(runId: string, cancelOpts?: { reason?: string; compensate?: boolean }) {
381
487
  // Per architecture doc §21.21, cancel does NOT run compensations
382
488
  // by default; the `compensate` flag is accepted but no-op in v1.
383
489
  void cancelOpts?.compensate
384
- await forwardToRunDO(
490
+ const detail = opts.concurrencyNamespace ? await getRunDetail(runId) : null
491
+ const resp = await forwardToRunDO(
385
492
  runId,
386
493
  new Request("https://do-internal/cancel", {
387
494
  method: "POST",
@@ -389,6 +496,7 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
389
496
  body: JSON.stringify({ reason: cancelOpts?.reason }),
390
497
  }),
391
498
  )
499
+ if (resp.ok) await releaseConcurrencySlot(detail)
392
500
  },
393
501
 
394
502
  async listRuns(_listOpts?: ListRunsOptions) {
@@ -437,3 +545,11 @@ async function safeText(resp: Response): Promise<string> {
437
545
  return ""
438
546
  }
439
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,10 +96,12 @@ 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)
94
103
  : payload.delay,
104
+ priority: payload.priority,
95
105
  },
96
106
  { store, handler, now: deps.now },
97
107
  )
@@ -113,6 +123,7 @@ export async function handleDurableObjectRequest(
113
123
  return json(status, { error: out.status, message: out.message })
114
124
  }
115
125
  await reconcileAlarm(out.record, store, deps)
126
+ await releaseConcurrencyLeaseIfTerminal(out.record, deps)
116
127
  return json(200, out.record)
117
128
  }
118
129
 
@@ -130,6 +141,7 @@ export async function handleDurableObjectRequest(
130
141
  return json(status, { error: out.status, message: out.message })
131
142
  }
132
143
  await reconcileAlarm(out.record, store, deps)
144
+ await releaseConcurrencyLeaseIfTerminal(out.record, deps)
133
145
  return json(200, out.record)
134
146
  }
135
147
 
@@ -188,6 +200,7 @@ export async function handleDurableObjectAlarm(deps: DurableObjectDeps): Promise
188
200
  await driveUntilPaused(record, { handler, now: deps.now })
189
201
  await store.save(record)
190
202
  await reconcileAlarm(record, store, deps)
203
+ await releaseConcurrencyLeaseIfTerminal(record, deps)
191
204
  }
192
205
 
193
206
  /**
@@ -238,6 +251,38 @@ async function getStoredRunId(
238
251
  return all[0]?.id
239
252
  }
240
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
+
241
286
  function json(status: number, body: unknown): Response {
242
287
  return new Response(JSON.stringify(body), {
243
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
  /**
@@ -60,6 +60,15 @@ export interface TriggerPayload {
60
60
  runId?: string
61
61
  idempotencyKey?: string
62
62
  delay?: Duration | { wakeAt: number }
63
+ priority?: number
64
+ triggeredBy?: RunTrigger
65
+ concurrencyLease?: ConcurrencyLease
66
+ }
67
+
68
+ export interface ConcurrencyLease {
69
+ environment: EnvironmentName
70
+ key: string
71
+ runId: string
63
72
  }
64
73
 
65
74
  /** Cancel payload. */