@openfn/ws-worker 1.21.5 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -33,7 +33,9 @@ var destroy = async (app, logger) => {
33
33
  await Promise.all([
34
34
  new Promise((resolve) => {
35
35
  app.destroyed = true;
36
- app.workloop?.stop("server closed");
36
+ for (const w of app.workloops) {
37
+ w.stop("server closed");
38
+ }
37
39
  app.server.close(async () => {
38
40
  resolve();
39
41
  });
@@ -51,11 +53,11 @@ var destroy = async (app, logger) => {
51
53
  var waitForRunsAndClaims = (app, logger) => new Promise((resolve) => {
52
54
  const log = () => {
53
55
  logger.debug(
54
- `Waiting for ${Object.keys(app.workflows).length} runs and ${Object.keys(app.openClaims).length} claims to complete...`
56
+ `Waiting for ${Object.keys(app.workflows).length} runs and ${app.pendingClaims()} claims to complete...`
55
57
  );
56
58
  };
57
59
  const checkAllClear = () => {
58
- if (Object.keys(app.workflows).length + Object.keys(app.openClaims).length === 0) {
60
+ if (Object.keys(app.workflows).length + app.pendingClaims() === 0) {
59
61
  logger.debug("All runs completed!");
60
62
  app.events.off(INTERNAL_RUN_COMPLETE, checkAllClear);
61
63
  app.events.off(INTERNAL_CLAIM_COMPLETE, checkAllClear);
@@ -64,7 +66,7 @@ var waitForRunsAndClaims = (app, logger) => new Promise((resolve) => {
64
66
  log();
65
67
  }
66
68
  };
67
- if (Object.keys(app.workflows).length || Object.keys(app.openClaims).length) {
69
+ if (Object.keys(app.workflows).length || app.pendingClaims()) {
68
70
  log();
69
71
  app.events.on(INTERNAL_RUN_COMPLETE, checkAllClear);
70
72
  app.events.on(INTERNAL_CLAIM_COMPLETE, checkAllClear);
@@ -75,51 +77,6 @@ var waitForRunsAndClaims = (app, logger) => new Promise((resolve) => {
75
77
  });
76
78
  var destroy_default = destroy;
77
79
 
78
- // src/util/try-with-backoff.ts
79
- var BACKOFF_MULTIPLIER = 1.15;
80
- var tryWithBackoff = (fn, opts = {}) => {
81
- const { min = 1e3, max = 1e4, maxRuns, runs = 1 } = opts;
82
- let cancelled = false;
83
- if (!opts.isCancelled) {
84
- opts.isCancelled = () => cancelled;
85
- }
86
- const promise = new Promise(async (resolve, reject) => {
87
- try {
88
- await fn();
89
- resolve();
90
- } catch (e) {
91
- if (e?.abort) {
92
- cancelled = true;
93
- return reject();
94
- }
95
- if (opts.isCancelled()) {
96
- return resolve();
97
- }
98
- if (!isNaN(maxRuns) && runs >= maxRuns) {
99
- return reject(new Error("max runs exceeded"));
100
- }
101
- setTimeout(() => {
102
- if (opts.isCancelled()) {
103
- return resolve();
104
- }
105
- const nextOpts = {
106
- maxRuns,
107
- runs: runs + 1,
108
- min: Math.min(max, min * BACKOFF_MULTIPLIER),
109
- max,
110
- isCancelled: opts.isCancelled
111
- };
112
- tryWithBackoff(fn, nextOpts).then(resolve).catch(reject);
113
- }, min);
114
- }
115
- });
116
- promise.cancel = () => {
117
- cancelled = true;
118
- };
119
- return promise;
120
- };
121
- var try_with_backoff_default = tryWithBackoff;
122
-
123
80
  // src/api/claim.ts
124
81
  import v8 from "node:v8";
125
82
  import * as Sentry from "@sentry/node";
@@ -148,24 +105,26 @@ var ClaimError = class extends Error {
148
105
  }
149
106
  };
150
107
  var claimIdGen = 0;
151
- var claim = (app, logger = mockLogger, options = {}) => {
108
+ var claim = (app, workloop, logger = mockLogger, options = {}) => {
152
109
  return new Promise((resolve, reject) => {
153
- app.openClaims ??= {};
154
- const { maxWorkers = 5, demand = 1 } = options;
110
+ const { demand = 1 } = options;
155
111
  const podName = NAME ? `[${NAME}] ` : "";
156
- const activeWorkers = Object.keys(app.workflows).length;
157
- const pendingClaims = Object.values(app.openClaims).reduce(
112
+ const activeInWorkloop = workloop.activeRuns.size;
113
+ const capacity = workloop.capacity;
114
+ const pendingWorkloopClaims = Object.values(workloop.openClaims).reduce(
158
115
  (a, b) => a + b,
159
116
  0
160
117
  );
161
- if (activeWorkers >= maxWorkers) {
162
- app.workloop?.stop(`server at capacity (${activeWorkers}/${maxWorkers})`);
163
- return reject(new ClaimError("Server at capacity"));
164
- } else if (activeWorkers + pendingClaims >= maxWorkers) {
165
- app.workloop?.stop(
166
- `server at capacity (${activeWorkers}/${maxWorkers}, ${pendingClaims} pending)`
118
+ if (activeInWorkloop >= capacity) {
119
+ workloop.stop(
120
+ `workloop ${workloop.id} at capacity (${activeInWorkloop}/${capacity})`
167
121
  );
168
- return reject(new ClaimError("Server at capacity"));
122
+ return reject(new ClaimError("Workloop at capacity"));
123
+ } else if (activeInWorkloop + pendingWorkloopClaims >= capacity) {
124
+ workloop.stop(
125
+ `workloop ${workloop.id} at capacity (${activeInWorkloop}/${capacity}, ${pendingWorkloopClaims} pending)`
126
+ );
127
+ return reject(new ClaimError("Workloop at capacity"));
169
128
  }
170
129
  if (!app.queueChannel) {
171
130
  logger.warn("skipping claim attempt: websocket unavailable");
@@ -178,21 +137,22 @@ var claim = (app, logger = mockLogger, options = {}) => {
178
137
  return reject(e);
179
138
  }
180
139
  const claimId = ++claimIdGen;
181
- app.openClaims[claimId] = demand;
140
+ workloop.openClaims[claimId] = demand;
182
141
  const { used_heap_size, heap_size_limit } = v8.getHeapStatistics();
183
142
  const usedHeapMb = Math.round(used_heap_size / 1024 / 1024);
184
143
  const totalHeapMb = Math.round(heap_size_limit / 1024 / 1024);
185
144
  const memPercent = Math.round(usedHeapMb / totalHeapMb * 100);
186
145
  logger.debug(
187
- `Claiming runs :: demand ${demand} | capacity ${activeWorkers}/${maxWorkers} | memory ${memPercent}% (${usedHeapMb}/${totalHeapMb}mb)`
146
+ `Claiming runs [${workloop.id}] :: demand ${demand} | capacity ${activeInWorkloop}/${capacity} | memory ${memPercent}% (${usedHeapMb}/${totalHeapMb}mb)`
188
147
  );
189
148
  app.events.emit(INTERNAL_CLAIM_START);
190
149
  const start = Date.now();
191
150
  app.queueChannel.push(CLAIM, {
192
151
  demand,
193
- worker_name: NAME || null
152
+ worker_name: NAME || null,
153
+ queues: workloop.queues
194
154
  }).receive("ok", async ({ runs }) => {
195
- delete app.openClaims[claimId];
155
+ delete workloop.openClaims[claimId];
196
156
  const duration = Date.now() - start;
197
157
  logger.debug(
198
158
  `${podName}claimed ${runs.length} runs in ${duration}ms (${runs.length ? runs.map((r) => r.id).join(",") : "-"})`
@@ -216,17 +176,19 @@ var claim = (app, logger = mockLogger, options = {}) => {
216
176
  } else {
217
177
  logger.debug("skipping run token validation for", run.id);
218
178
  }
179
+ workloop.activeRuns.add(run.id);
180
+ app.runWorkloopMap[run.id] = workloop;
219
181
  logger.debug(`${podName} starting run ${run.id}`);
220
182
  app.execute(run);
221
183
  }
222
184
  resolve();
223
185
  app.events.emit(INTERNAL_CLAIM_COMPLETE, { runs });
224
186
  }).receive("error", (e) => {
225
- delete app.openClaims[claimId];
187
+ delete workloop.openClaims[claimId];
226
188
  logger.error("Error on claim", e);
227
189
  reject(new Error("claim error"));
228
190
  }).receive("timeout", () => {
229
- delete app.openClaims[claimId];
191
+ delete workloop.openClaims[claimId];
230
192
  logger.error("TIMEOUT on claim. Runs may be lost.");
231
193
  reject(new Error("timeout"));
232
194
  });
@@ -234,43 +196,6 @@ var claim = (app, logger = mockLogger, options = {}) => {
234
196
  };
235
197
  var claim_default = claim;
236
198
 
237
- // src/api/workloop.ts
238
- var startWorkloop = (app, logger, minBackoff, maxBackoff, maxWorkers) => {
239
- let promise;
240
- let cancelled = false;
241
- const workLoop = () => {
242
- if (!cancelled) {
243
- promise = try_with_backoff_default(
244
- () => claim_default(app, logger, {
245
- maxWorkers
246
- }),
247
- {
248
- min: minBackoff,
249
- max: maxBackoff
250
- }
251
- );
252
- promise.then(() => {
253
- if (!cancelled) {
254
- setTimeout(workLoop, minBackoff);
255
- }
256
- }).catch(() => {
257
- });
258
- }
259
- };
260
- workLoop();
261
- return {
262
- stop: (reason = "reason unknown") => {
263
- if (!cancelled) {
264
- logger.info(`cancelling workloop: ${reason}`);
265
- cancelled = true;
266
- promise.cancel();
267
- }
268
- },
269
- isStopped: () => cancelled
270
- };
271
- };
272
- var workloop_default = startWorkloop;
273
-
274
199
  // src/api/execute.ts
275
200
  import * as Sentry4 from "@sentry/node";
276
201
  import {
@@ -575,6 +500,51 @@ var stringify_default = (obj) => stringify(obj, (_key, value) => {
575
500
  return value;
576
501
  });
577
502
 
503
+ // src/util/try-with-backoff.ts
504
+ var BACKOFF_MULTIPLIER = 1.15;
505
+ var tryWithBackoff = (fn, opts = {}) => {
506
+ const { min = 1e3, max = 1e4, maxRuns, runs = 1 } = opts;
507
+ let cancelled = false;
508
+ if (!opts.isCancelled) {
509
+ opts.isCancelled = () => cancelled;
510
+ }
511
+ const promise = new Promise(async (resolve, reject) => {
512
+ try {
513
+ await fn();
514
+ resolve();
515
+ } catch (e) {
516
+ if (e?.abort) {
517
+ cancelled = true;
518
+ return reject();
519
+ }
520
+ if (opts.isCancelled()) {
521
+ return resolve();
522
+ }
523
+ if (!isNaN(maxRuns) && runs >= maxRuns) {
524
+ return reject(new Error("max runs exceeded"));
525
+ }
526
+ setTimeout(() => {
527
+ if (opts.isCancelled()) {
528
+ return resolve();
529
+ }
530
+ const nextOpts = {
531
+ maxRuns,
532
+ runs: runs + 1,
533
+ min: Math.min(max, min * BACKOFF_MULTIPLIER),
534
+ max,
535
+ isCancelled: opts.isCancelled
536
+ };
537
+ tryWithBackoff(fn, nextOpts).then(resolve).catch(reject);
538
+ }, min);
539
+ }
540
+ });
541
+ promise.cancel = () => {
542
+ cancelled = true;
543
+ };
544
+ return promise;
545
+ };
546
+ var try_with_backoff_default = tryWithBackoff;
547
+
578
548
  // src/util/timestamp.ts
579
549
  var timeInMicroseconds = (time) => time && (BigInt(time) / BigInt(1e3)).toString();
580
550
 
@@ -1235,6 +1205,7 @@ var connectToWorkerQueue = (endpoint, serverId, secret, logger, options) => {
1235
1205
  messageTimeout = DEFAULT_MESSAGE_TIMEOUT_SECONDS,
1236
1206
  claimTimeout = DEFAULT_CLAIM_TIMEOUT_SECONDS,
1237
1207
  capacity,
1208
+ queues,
1238
1209
  SocketConstructor = PhxSocket
1239
1210
  } = options;
1240
1211
  const events = new EventEmitter();
@@ -1271,6 +1242,9 @@ var connectToWorkerQueue = (endpoint, serverId, secret, logger, options) => {
1271
1242
  didOpen = true;
1272
1243
  shouldReportConnectionError = true;
1273
1244
  const joinPayload = { capacity };
1245
+ if (queues) {
1246
+ joinPayload.queues = queues;
1247
+ }
1274
1248
  const channel = socket.channel("worker:queue", joinPayload);
1275
1249
  channel.onMessage = (ev, load) => {
1276
1250
  events.emit("message", ev, load);
@@ -1311,6 +1285,140 @@ var connectToWorkerQueue = (endpoint, serverId, secret, logger, options) => {
1311
1285
  };
1312
1286
  var worker_queue_default = connectToWorkerQueue;
1313
1287
 
1288
+ // src/api/workloop.ts
1289
+ var Workloop = class {
1290
+ constructor({
1291
+ id,
1292
+ queues,
1293
+ capacity
1294
+ }) {
1295
+ this.activeRuns = /* @__PURE__ */ new Set();
1296
+ this.openClaims = {};
1297
+ this.cancelled = true;
1298
+ this.id = id;
1299
+ this.queues = queues;
1300
+ this.capacity = capacity;
1301
+ }
1302
+ hasCapacity() {
1303
+ const pendingClaims = Object.values(this.openClaims).reduce(
1304
+ (a, b) => a + b,
1305
+ 0
1306
+ );
1307
+ return this.activeRuns.size + pendingClaims < this.capacity;
1308
+ }
1309
+ start(app, logger, minBackoff, maxBackoff) {
1310
+ this.logger = logger;
1311
+ this.cancelled = false;
1312
+ const loop = () => {
1313
+ if (!this.cancelled) {
1314
+ this.promise = try_with_backoff_default(() => claim_default(app, this, logger), {
1315
+ min: minBackoff,
1316
+ max: maxBackoff
1317
+ });
1318
+ this.promise.then(() => {
1319
+ if (!this.cancelled) {
1320
+ setTimeout(loop, minBackoff);
1321
+ }
1322
+ }).catch(() => {
1323
+ });
1324
+ }
1325
+ };
1326
+ loop();
1327
+ }
1328
+ stop(reason = "reason unknown") {
1329
+ if (!this.cancelled) {
1330
+ this.logger?.info(`cancelling workloop: ${reason}`);
1331
+ this.cancelled = true;
1332
+ this.promise?.cancel();
1333
+ }
1334
+ }
1335
+ isStopped() {
1336
+ return this.cancelled;
1337
+ }
1338
+ };
1339
+
1340
+ // src/util/parse-workloops.ts
1341
+ var WorkloopValidationError = class extends Error {
1342
+ constructor(message) {
1343
+ super(message);
1344
+ this.name = "WorkloopValidationError";
1345
+ }
1346
+ };
1347
+ var VALID_NAME = /^[a-zA-Z0-9_]+$/;
1348
+ function parseWorkloops(input) {
1349
+ const trimmed = input.trim();
1350
+ if (!trimmed) {
1351
+ throw new WorkloopValidationError("Workloop configuration cannot be empty");
1352
+ }
1353
+ const tokens = trimmed.split(/\s+/);
1354
+ const configs = tokens.map(parseToken);
1355
+ const seenConfigs = /* @__PURE__ */ new Map();
1356
+ for (let i = 0; i < configs.length; i++) {
1357
+ const key = JSON.stringify(configs[i].queues);
1358
+ if (seenConfigs.has(key)) {
1359
+ const prevIndex = seenConfigs.get(key);
1360
+ console.warn(
1361
+ `Warning: workloops at positions ${prevIndex} and ${i} have identical queue configurations: ${tokens[prevIndex]} and ${tokens[i]}`
1362
+ );
1363
+ } else {
1364
+ seenConfigs.set(key, i);
1365
+ }
1366
+ }
1367
+ return configs;
1368
+ }
1369
+ function parseToken(token) {
1370
+ const lastColon = token.lastIndexOf(":");
1371
+ if (lastColon === -1) {
1372
+ throw new WorkloopValidationError(
1373
+ `Invalid token "${token}": missing :<count> suffix`
1374
+ );
1375
+ }
1376
+ const prefStr = token.slice(0, lastColon);
1377
+ const countStr = token.slice(lastColon + 1);
1378
+ const count = Number(countStr);
1379
+ if (!Number.isInteger(count) || countStr !== String(Math.floor(count))) {
1380
+ throw new WorkloopValidationError(
1381
+ `Invalid count "${countStr}" in token "${token}": must be a positive integer`
1382
+ );
1383
+ }
1384
+ if (count < 1) {
1385
+ throw new WorkloopValidationError(
1386
+ `Invalid count "${countStr}" in token "${token}": must be >= 1`
1387
+ );
1388
+ }
1389
+ const names = prefStr.split(">");
1390
+ for (const name of names) {
1391
+ if (name === "") {
1392
+ throw new WorkloopValidationError(`Empty queue name in token "${token}"`);
1393
+ }
1394
+ if (name !== "*" && !VALID_NAME.test(name)) {
1395
+ throw new WorkloopValidationError(
1396
+ `Invalid queue name "${name}" in token "${token}": must match /^[a-zA-Z0-9_]+$/ or be "*"`
1397
+ );
1398
+ }
1399
+ }
1400
+ const nonWildcardNames = names.filter((n) => n !== "*");
1401
+ const seen = /* @__PURE__ */ new Set();
1402
+ for (const name of nonWildcardNames) {
1403
+ if (seen.has(name)) {
1404
+ console.warn(
1405
+ `Warning: duplicate queue name "${name}" in token "${token}"`
1406
+ );
1407
+ }
1408
+ seen.add(name);
1409
+ }
1410
+ const wildcardIndex = names.indexOf("*");
1411
+ if (wildcardIndex !== -1 && wildcardIndex !== names.length - 1) {
1412
+ throw new WorkloopValidationError(
1413
+ `Wildcard "*" must be the last element in token "${token}"`
1414
+ );
1415
+ }
1416
+ return new Workloop({ id: token, queues: names, capacity: count });
1417
+ }
1418
+
1419
+ // src/util/get-default-workloop-config.ts
1420
+ var get_default_workloop_config_default = (capacity = 5) => `manual>*:${capacity}`;
1421
+
1314
1422
  // src/server.ts
1315
1423
  var exec = promisify(_exec);
1316
1424
  var DEFAULT_PORT = 2222;
@@ -1338,8 +1446,10 @@ function connect(app, logger, options = {}) {
1338
1446
  app.resumeWorkloop();
1339
1447
  };
1340
1448
  const onDisconnect = () => {
1341
- if (!app.workloop?.isStopped()) {
1342
- app.workloop?.stop("Socket disconnected unexpectedly");
1449
+ for (const w of app.workloops) {
1450
+ if (!w.isStopped()) {
1451
+ w.stop("Socket disconnected unexpectedly");
1452
+ }
1343
1453
  }
1344
1454
  if (!app.destroyed) {
1345
1455
  logger.info("Connection to lightning lost");
@@ -1361,17 +1471,26 @@ function connect(app, logger, options = {}) {
1361
1471
  const onMessage = (event) => {
1362
1472
  if (event === WORK_AVAILABLE) {
1363
1473
  if (!app.destroyed) {
1364
- claim_default(app, logger, { maxWorkers: options.maxWorkflows }).catch(() => {
1365
- });
1474
+ for (const w of app.workloops) {
1475
+ if (w.hasCapacity()) {
1476
+ claim_default(app, w, logger).catch(() => {
1477
+ });
1478
+ }
1479
+ }
1366
1480
  }
1367
1481
  }
1368
1482
  };
1483
+ const queuesMap = {};
1484
+ for (const w of app.workloops) {
1485
+ queuesMap[w.queues.join(">")] = w.capacity;
1486
+ }
1369
1487
  worker_queue_default(options.lightning, app.id, options.secret, logger, {
1370
1488
  // TODO: options.socketTimeoutSeconds wins because this is what USED to be used
1371
1489
  // But it's deprecated and should be removed soon
1372
1490
  messageTimeout: options.socketTimeoutSeconds ?? options.messageTimeoutSeconds,
1373
1491
  claimTimeout: options.claimTimeoutSeconds,
1374
- capacity: options.maxWorkflows
1492
+ capacity: options.maxWorkflows,
1493
+ queues: queuesMap
1375
1494
  }).on("connect", onConnect).on("disconnect", onDisconnect).on("error", onError).on("message", onMessage);
1376
1495
  }
1377
1496
  async function setupCollections(options, logger) {
@@ -1420,27 +1539,36 @@ function createServer(engine, options = {}) {
1420
1539
  logger.debug(str);
1421
1540
  })
1422
1541
  );
1423
- app.openClaims = {};
1424
1542
  app.workflows = {};
1425
1543
  app.destroyed = false;
1544
+ app.workloops = parseWorkloops(
1545
+ options.workloopConfigs ?? get_default_workloop_config_default(options.maxWorkflows)
1546
+ );
1547
+ app.runWorkloopMap = {};
1426
1548
  app.server = app.listen(port);
1427
1549
  logger.success(`Worker ${app.id} listening on ${port}`);
1428
1550
  process.send?.("READY");
1429
1551
  router.get("/livez", healthcheck_default);
1430
1552
  router.get("/", healthcheck_default);
1431
1553
  app.options = options;
1432
- app.resumeWorkloop = () => {
1554
+ app.resumeWorkloop = (workloop) => {
1433
1555
  if (options.noLoop || app.destroyed) {
1434
1556
  return;
1435
1557
  }
1436
- if (!app.workloop || app.workloop?.isStopped()) {
1437
- logger.info("Starting workloop");
1438
- app.workloop = workloop_default(
1558
+ const targets = workloop ? [workloop] : app.workloops;
1559
+ for (const w of targets) {
1560
+ if (!w.hasCapacity()) {
1561
+ continue;
1562
+ }
1563
+ if (!w.isStopped()) {
1564
+ w.stop("restarting");
1565
+ }
1566
+ logger.info(`Starting workloop for ${w.id}`);
1567
+ w.start(
1439
1568
  app,
1440
1569
  logger,
1441
1570
  options.backoff?.min || MIN_BACKOFF,
1442
- options.backoff?.max || MAX_BACKOFF,
1443
- options.maxWorkflows
1571
+ options.backoff?.max || MAX_BACKOFF
1444
1572
  );
1445
1573
  }
1446
1574
  };
@@ -1485,8 +1613,16 @@ function createServer(engine, options = {}) {
1485
1613
  );
1486
1614
  delete app.workflows[id];
1487
1615
  runChannel.leave();
1488
- app.events.emit(INTERNAL_RUN_COMPLETE);
1489
- app.resumeWorkloop();
1616
+ const owningWorkloop = app.runWorkloopMap[id];
1617
+ if (owningWorkloop) {
1618
+ owningWorkloop.activeRuns.delete(id);
1619
+ delete app.runWorkloopMap[id];
1620
+ app.events.emit(INTERNAL_RUN_COMPLETE);
1621
+ app.resumeWorkloop(owningWorkloop);
1622
+ } else {
1623
+ app.events.emit(INTERNAL_RUN_COMPLETE);
1624
+ app.resumeWorkloop();
1625
+ }
1490
1626
  };
1491
1627
  const context = execute(
1492
1628
  runChannel,
@@ -1500,7 +1636,14 @@ function createServer(engine, options = {}) {
1500
1636
  app.workflows[id] = context;
1501
1637
  } catch (e) {
1502
1638
  delete app.workflows[id];
1503
- app.resumeWorkloop();
1639
+ const owningWorkloop = app.runWorkloopMap[id];
1640
+ if (owningWorkloop) {
1641
+ owningWorkloop.activeRuns.delete(id);
1642
+ delete app.runWorkloopMap[id];
1643
+ app.resumeWorkloop(owningWorkloop);
1644
+ } else {
1645
+ app.resumeWorkloop();
1646
+ }
1504
1647
  logger.error(`Unexpected error executing ${id}`);
1505
1648
  logger.error(e);
1506
1649
  }
@@ -1510,9 +1653,13 @@ function createServer(engine, options = {}) {
1510
1653
  };
1511
1654
  router.post("/claim", async (ctx) => {
1512
1655
  logger.info("triggering claim from POST request");
1513
- return claim_default(app, logger, {
1514
- maxWorkers: options.maxWorkflows
1515
- }).then(() => {
1656
+ const promises = app.workloops.map((w) => {
1657
+ if (w.hasCapacity()) {
1658
+ return claim_default(app, w, logger);
1659
+ }
1660
+ return Promise.reject(new Error("Workloop at capacity"));
1661
+ });
1662
+ return Promise.any(promises).then(() => {
1516
1663
  logger.info("claim complete: 1 run claimed");
1517
1664
  ctx.body = "complete";
1518
1665
  ctx.status = 200;
@@ -1523,10 +1670,15 @@ function createServer(engine, options = {}) {
1523
1670
  });
1524
1671
  });
1525
1672
  app.claim = () => {
1526
- return claim_default(app, logger, {
1527
- maxWorkers: options.maxWorkflows
1673
+ const promises = app.workloops.map((w) => {
1674
+ if (w.hasCapacity()) {
1675
+ return claim_default(app, w, logger);
1676
+ }
1677
+ return Promise.reject(new Error("Workloop at capacity"));
1528
1678
  });
1679
+ return Promise.any(promises);
1529
1680
  };
1681
+ app.pendingClaims = () => app.workloops.reduce((sum, w) => sum + Object.keys(w.openClaims).length, 0);
1530
1682
  app.destroy = () => destroy_default(app, logger);
1531
1683
  app.use(router.routes());
1532
1684
  if (options.lightning) {