@webda/workflow 0.0.0-stage → 4.0.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +166 -0
  2. package/README.md +54 -2
  3. package/lib/authoring.d.ts +115 -0
  4. package/lib/authoring.d.ts.map +1 -0
  5. package/lib/authoring.js +107 -0
  6. package/lib/authoring.js.map +1 -0
  7. package/lib/expression.d.ts +21 -0
  8. package/lib/expression.d.ts.map +1 -0
  9. package/lib/expression.js +196 -0
  10. package/lib/expression.js.map +1 -0
  11. package/lib/graph.d.ts +429 -0
  12. package/lib/graph.d.ts.map +1 -0
  13. package/lib/graph.js +72 -0
  14. package/lib/graph.js.map +1 -0
  15. package/lib/hooks.d.ts +38 -0
  16. package/lib/hooks.d.ts.map +1 -0
  17. package/lib/hooks.js +23 -0
  18. package/lib/hooks.js.map +1 -0
  19. package/lib/index.d.ts +11 -0
  20. package/lib/index.d.ts.map +1 -0
  21. package/lib/index.js +15 -0
  22. package/lib/index.js.map +1 -0
  23. package/lib/interpreter.d.ts +69 -0
  24. package/lib/interpreter.d.ts.map +1 -0
  25. package/lib/interpreter.js +332 -0
  26. package/lib/interpreter.js.map +1 -0
  27. package/lib/models/workflow.model.d.ts +71 -0
  28. package/lib/models/workflow.model.d.ts.map +1 -0
  29. package/lib/models/workflow.model.js +70 -0
  30. package/lib/models/workflow.model.js.map +1 -0
  31. package/lib/models/workflowinstance.model.d.ts +132 -0
  32. package/lib/models/workflowinstance.model.d.ts.map +1 -0
  33. package/lib/models/workflowinstance.model.js +168 -0
  34. package/lib/models/workflowinstance.model.js.map +1 -0
  35. package/lib/models/workflowsignal.model.d.ts +51 -0
  36. package/lib/models/workflowsignal.model.d.ts.map +1 -0
  37. package/lib/models/workflowsignal.model.js +53 -0
  38. package/lib/models/workflowsignal.model.js.map +1 -0
  39. package/lib/models/workflowtask.model.d.ts +103 -0
  40. package/lib/models/workflowtask.model.d.ts.map +1 -0
  41. package/lib/models/workflowtask.model.js +104 -0
  42. package/lib/models/workflowtask.model.js.map +1 -0
  43. package/lib/workflow.service.d.ts +685 -0
  44. package/lib/workflow.service.d.ts.map +1 -0
  45. package/lib/workflow.service.js +1951 -0
  46. package/lib/workflow.service.js.map +1 -0
  47. package/package.json +68 -3
  48. package/webda.module.json +2688 -0
@@ -0,0 +1,1951 @@
1
+ var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
2
+ var useValue = arguments.length > 2;
3
+ for (var i = 0; i < initializers.length; i++) {
4
+ value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
5
+ }
6
+ return useValue ? value : void 0;
7
+ };
8
+ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
9
+ function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
10
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
11
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
12
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
13
+ var _, done = false;
14
+ for (var i = decorators.length - 1; i >= 0; i--) {
15
+ var context = {};
16
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
17
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
18
+ context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
19
+ var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
20
+ if (kind === "accessor") {
21
+ if (result === void 0) continue;
22
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
23
+ if (_ = accept(result.get)) descriptor.get = _;
24
+ if (_ = accept(result.set)) descriptor.set = _;
25
+ if (_ = accept(result.init)) initializers.unshift(_);
26
+ }
27
+ else if (_ = accept(result)) {
28
+ if (kind === "field") initializers.unshift(_);
29
+ else descriptor[key] = _;
30
+ }
31
+ }
32
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
33
+ done = true;
34
+ };
35
+ import { escape } from "@webda/ql";
36
+ import { callOperation, canCallOperation, CronService, hasSchema, Operation, listFullOperations, registerOperation, registerSchema, runAsSystem, Service, ServiceParameters, Session, SimpleOperationContext, useApplication, useContext, useCore, useDynamicService, useInstanceStorage, useSystem, WebdaError } from "@webda/core";
37
+ import { Duration } from "@webda/utils";
38
+ import { createHash, randomUUID } from "node:crypto";
39
+ import { BUILTINS, flowVersion } from "./graph.js";
40
+ import { keysOf, run, statementIdOf } from "./interpreter.js";
41
+ import { setWorkflowControls } from "./hooks.js";
42
+ import { WorkflowInstance } from "./models/workflowinstance.model.js";
43
+ import { WorkflowTask } from "./models/workflowtask.model.js";
44
+ import { WorkflowSignal } from "./models/workflowsignal.model.js";
45
+ import { Workflow as StoredWorkflow } from "./models/workflow.model.js";
46
+ import { validateExpr } from "./expression.js";
47
+ import { useRepository } from "@webda/models";
48
+ /**
49
+ * Output schema of every flow launch operation
50
+ */
51
+ export const LAUNCH_OUTPUT_SCHEMA = "Webda/WorkflowLaunch.output";
52
+ /**
53
+ * Status of a task error that is never retried: the same call would fail the same way
54
+ */
55
+ const NOT_RETRYABLE_STATUS = {
56
+ 400: "BadRequest",
57
+ 401: "Unauthorized",
58
+ 403: "Forbidden",
59
+ 404: "NotFound"
60
+ };
61
+ /**
62
+ * An error of a task, as the engine reports it in the flow
63
+ */
64
+ class TaskError extends Error {
65
+ code;
66
+ retryable;
67
+ /**
68
+ * Create a new TaskError instance
69
+ * @param code - error code
70
+ * @param message - error message
71
+ * @param retryable - whether another attempt may succeed
72
+ */
73
+ constructor(code, message, retryable = true) {
74
+ super(message);
75
+ this.code = code;
76
+ this.retryable = retryable;
77
+ }
78
+ }
79
+ /**
80
+ * Statuses of a task row that will still produce an outcome
81
+ */
82
+ const LIVE_TASK = ["PENDING", "RUNNING", "WAITING_EVENT", "WAITING_CHILD"];
83
+ /**
84
+ * `runAt` of a wait without timeout (JSON has no Infinity)
85
+ */
86
+ export const NO_TIMEOUT = Number.MAX_SAFE_INTEGER;
87
+ /**
88
+ * Built-ins an instance waits on: when all its pending tasks are of these, it is WAITING
89
+ */
90
+ const WAITING_OPERATIONS = [BUILTINS.Sleep, BUILTINS.WaitForEvent];
91
+ /**
92
+ * Statement kinds of a flow graph, with their expression fields
93
+ */
94
+ const STATEMENT_EXPRESSIONS = {
95
+ task: ["input"],
96
+ assign: ["value"],
97
+ if: ["cond"],
98
+ forOf: ["items"],
99
+ while: ["cond"],
100
+ try: [],
101
+ parallel: [],
102
+ map: ["items"],
103
+ return: ["value"],
104
+ throw: ["value"]
105
+ };
106
+ /**
107
+ * Operation id syntax
108
+ */
109
+ const OPERATION_ID = /^([A-Z][A-Za-z0-9]*\.)*([A-Z][a-zA-Z0-9]*)$/;
110
+ /**
111
+ * Whether an object is an instance of a class with this name, its own or an ancestor
112
+ * @param object - the object
113
+ * @param className - the class name
114
+ * @returns true when a constructor of its prototype chain has this name
115
+ */
116
+ function isInstanceOfClass(object, className) {
117
+ for (let proto = object ? Object.getPrototypeOf(object) : null; proto; proto = Object.getPrototypeOf(proto)) {
118
+ if (proto.constructor?.name === className)
119
+ return true;
120
+ }
121
+ return false;
122
+ }
123
+ /**
124
+ * Abort reason of an attempt whose outcome must not be written: its lease was lost, or the service stopped
125
+ */
126
+ const DROPPED = "WorkflowAttemptDropped";
127
+ /**
128
+ * Abort signal of the current workflow task attempt
129
+ *
130
+ * It is aborted on timeout, when the worker loses the task lease, and when the service stops. An operation run by a
131
+ * workflow should observe it to stop early: an attempt that keeps running is ignored, and the task may run again
132
+ * (operations are assumed idempotent).
133
+ * @returns the signal, undefined outside a workflow task
134
+ */
135
+ export function useAbortSignal() {
136
+ try {
137
+ return useContext()?.getExtension?.("abortSignal");
138
+ }
139
+ catch {
140
+ // A context without extensions (the global context)
141
+ return undefined;
142
+ }
143
+ }
144
+ /**
145
+ * Parameters of the {@link WorkflowService}
146
+ */
147
+ export class WorkflowServiceParameters extends ServiceParameters {
148
+ /**
149
+ * Interval between two polls of the worker (ms)
150
+ * @default 1000
151
+ */
152
+ pollInterval;
153
+ /**
154
+ * Maximum number of tasks running at once on this replica
155
+ * @default 10
156
+ */
157
+ concurrency;
158
+ /**
159
+ * Lease of a claimed task (ms), renewed while it runs
160
+ * @default 60000
161
+ */
162
+ leaseDuration;
163
+ /**
164
+ * Maximum size of the journal and input of an instance (bytes of JSON)
165
+ * @default 262144
166
+ */
167
+ maxContextSize;
168
+ /**
169
+ * Run the worker loop on this replica
170
+ * @default true
171
+ */
172
+ worker;
173
+ /**
174
+ * Allow `useSystem` tasks in stored workflows
175
+ * @default false
176
+ */
177
+ allowStoredSystem;
178
+ /**
179
+ * How long a signal sent before any wait is kept (Duration syntax)
180
+ * @default "1d"
181
+ */
182
+ signalTtl;
183
+ /**
184
+ * Interval of the recovery sweep (ms): an active instance not updated for this long and without a live task is
185
+ * replayed
186
+ * @default 60000
187
+ */
188
+ sweepInterval;
189
+ /**
190
+ * Grace period of stop() for the running attempts (ms), then they are aborted and their outcome dropped
191
+ * @default 30000
192
+ */
193
+ stopTimeout;
194
+ /**
195
+ * Flows to register
196
+ */
197
+ flows;
198
+ /**
199
+ * Maximum size of a signal payload (bytes of JSON)
200
+ * @default 65536
201
+ */
202
+ maxSignalPayload;
203
+ /**
204
+ * Maximum number of unexpired buffered signals per `(event, key)`: a new one past it is refused
205
+ * @default 100
206
+ */
207
+ maxBufferedSignals;
208
+ /**
209
+ * Role needed to create, update or delete a stored workflow
210
+ * @default "workflow-admin"
211
+ */
212
+ storedWorkflowRole;
213
+ /**
214
+ * PubSub service announcing the stored workflow changes to the other replicas
215
+ */
216
+ syncPubSub;
217
+ /**
218
+ * Without `syncPubSub`, interval between two reloads of the stored workflows (ms)
219
+ * @default 60000
220
+ */
221
+ syncInterval;
222
+ /**
223
+ * @override
224
+ * @param params - the input parameters
225
+ * @returns this
226
+ */
227
+ load(params = {}) {
228
+ super.load(params);
229
+ this.pollInterval ??= 1000;
230
+ this.concurrency ??= 10;
231
+ this.leaseDuration ??= 60000;
232
+ this.maxContextSize ??= 262144;
233
+ this.worker ??= true;
234
+ this.allowStoredSystem ??= false;
235
+ this.sweepInterval ??= 60000;
236
+ this.stopTimeout ??= 30000;
237
+ this.signalTtl ??= "1d";
238
+ this.storedWorkflowRole ??= "workflow-admin";
239
+ this.maxSignalPayload ??= 65536;
240
+ this.maxBufferedSignals ??= 100;
241
+ this.syncInterval ??= 60000;
242
+ return this;
243
+ }
244
+ }
245
+ /**
246
+ * Whether an instance still runs
247
+ * @param instance - the instance
248
+ * @returns true when RUNNING or WAITING
249
+ */
250
+ function isActive(instance) {
251
+ return instance?.status === "RUNNING" || instance?.status === "WAITING";
252
+ }
253
+ /**
254
+ * Whether two flow errors are the same
255
+ * @param a - an error
256
+ * @param b - another error
257
+ * @returns true when code, message and operation match
258
+ */
259
+ function sameError(a, b) {
260
+ return !!a && !!b && a.code === b.code && a.message === b.message && a.operation === b.operation;
261
+ }
262
+ /**
263
+ * Uuid of the n-th row of a task key: the same for every worker, so two advances cannot create it twice
264
+ * @param instance - instance uuid
265
+ * @param key - task key
266
+ * @param index - row index of the key, from 1
267
+ * @returns a uuid
268
+ */
269
+ function taskUuid(instance, key, index) {
270
+ const h = createHash("sha256").update(`${instance}\n${key}\n${index}`).digest("hex");
271
+ return `${h.substring(0, 8)}-${h.substring(8, 12)}-5${h.substring(13, 16)}-a${h.substring(17, 20)}-${h.substring(20, 32)}`;
272
+ }
273
+ /**
274
+ * Runs durable workflows: registers each flow as an operation, launches instances and runs their tasks
275
+ *
276
+ * Every replica runs the worker loop: a task is claimed with a lease (a conditional patch on `leaseUntil`), so it
277
+ * runs on one replica at a time. Its outcome goes to the instance journal (a conditional patch on `revision`), then
278
+ * the flow is replayed to find the next tasks.
279
+ *
280
+ * @WebdaModda WorkflowService
281
+ */
282
+ let WorkflowService = (() => {
283
+ let _classSuper = Service;
284
+ let _instanceExtraInitializers = [];
285
+ let _signal_decorators;
286
+ return class WorkflowService extends _classSuper {
287
+ static {
288
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
289
+ _signal_decorators = [Operation({
290
+ id: "Workflow.Signal",
291
+ rest: { method: "post", path: "workflows/signal" },
292
+ graphql: false,
293
+ grpc: false
294
+ })];
295
+ __esDecorate(this, null, _signal_decorators, { kind: "method", name: "signal", static: false, private: false, access: { has: obj => "signal" in obj, get: obj => obj.signal }, metadata: _metadata }, null, _instanceExtraInitializers);
296
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
297
+ }
298
+ /**
299
+ * Flows registered on this service, by flow id
300
+ */
301
+ flows = (__runInitializers(this, _instanceExtraInitializers), new Map());
302
+ /**
303
+ * Every version registered of each flow, for migrate
304
+ */
305
+ versions = new Map();
306
+ /**
307
+ * Flows of the modules (compiled `.flow.ts`) registered by this service
308
+ */
309
+ moduleFlows = new Set();
310
+ /**
311
+ * Worker id, the lease owner of the tasks it claims
312
+ */
313
+ workerId = randomUUID();
314
+ /**
315
+ * Attempts running on this replica, by task uuid
316
+ */
317
+ running = new Map();
318
+ /**
319
+ * Tasks reclaimed after too many crashes: they fail without running
320
+ */
321
+ crashed = new WeakSet();
322
+ /**
323
+ * The service is stopping: nothing more is claimed
324
+ */
325
+ stopping = false;
326
+ /**
327
+ * Poll in progress
328
+ */
329
+ pollPromise;
330
+ /**
331
+ * Last recovery sweep (ms)
332
+ */
333
+ lastSweep = 0;
334
+ /**
335
+ * Executions started by the worker loop
336
+ */
337
+ executions = new Set();
338
+ /**
339
+ * Worker loop timer
340
+ */
341
+ timer;
342
+ /**
343
+ * Stored workflows registered here, by workflow uuid
344
+ */
345
+ storedFlows = new Map();
346
+ /**
347
+ * Stored workflows reload timer
348
+ */
349
+ syncTimer;
350
+ /**
351
+ * Subscription to the stored workflow changes
352
+ */
353
+ syncConsumer;
354
+ /**
355
+ * @override
356
+ */
357
+ resolve() {
358
+ super.resolve();
359
+ // The configured service serves the WorkflowInstance actions
360
+ setWorkflowControls(this);
361
+ // Before the transports init: they snapshot the operations
362
+ for (const def of this.parameters.flows ?? []) {
363
+ this.registerFlow({ ...def, version: def.version ?? "", source: def.source ?? "code" });
364
+ }
365
+ this.registerModuleFlows();
366
+ return this;
367
+ }
368
+ /**
369
+ * Register the flows compiled from the `.flow.ts` files (`flows` of the modules), with their crons
370
+ *
371
+ * The first WorkflowService to resolve registers them; the typed calls are resolved in {@link init}, once every
372
+ * service registered its operations.
373
+ */
374
+ registerModuleFlows() {
375
+ const flows = useApplication().getModules()?.flows ?? {};
376
+ for (const [id, entry] of Object.entries(flows)) {
377
+ const operation = listFullOperations()[id];
378
+ if (operation?.context?.flowId === id && operation.service !== this.getName())
379
+ continue;
380
+ if (operation && !operation.context?.flowId) {
381
+ throw new WebdaError.FatalInitError(`Flow ${id}: its operation id is used by the operation of service ${operation.service}`);
382
+ }
383
+ const { crons, ...def } = structuredClone(entry);
384
+ // The compiled version is kept: the build hashed the graph before its targets are resolved
385
+ this.registerFlow({ ...def, source: "code" });
386
+ this.moduleFlows.add(id);
387
+ for (const cron of crons ?? []) {
388
+ CronService.register(id, cron);
389
+ }
390
+ }
391
+ }
392
+ /**
393
+ * The operation a typed call (`target: { class, method }`) runs: the operation of the configured service whose
394
+ * instance is of that class (matched by constructor name, the class or an ancestor)
395
+ * @param target - the target of the task
396
+ * @param target.class - the service class name
397
+ * @param target.method - the method name
398
+ * @returns the operation id, or why there is none: no service (missing) or several (ambiguous)
399
+ */
400
+ resolveTarget(target) {
401
+ const { class: className, method } = target ?? {};
402
+ const operations = Object.entries(listFullOperations());
403
+ const matches = Object.entries(useCore().getServices())
404
+ .filter(([, service]) => isInstanceOfClass(service, className))
405
+ .flatMap(([name]) => operations.filter(([, op]) => op.service === name && op.method === method).map(([opId]) => ({ name, opId })));
406
+ if (matches.length === 1)
407
+ return { operation: matches[0].opId };
408
+ return matches.length
409
+ ? {
410
+ ambiguous: `several configured services expose ${className}.${method}: ${matches.map(m => m.name).join(", ")}`
411
+ }
412
+ : { missing: `no configured service of class ${className} exposes ${method} as an operation` };
413
+ }
414
+ /**
415
+ * Resolve the typed calls of a flow body in place
416
+ * @param body - the statements
417
+ * @returns the problems, by statement id
418
+ */
419
+ resolveTargets(body) {
420
+ const problems = { missing: [], ambiguous: [] };
421
+ const walk = (stmts) => {
422
+ for (const stmt of stmts ?? []) {
423
+ for (const key of ["then", "else", "body", "catch", "finally"])
424
+ walk(stmt[key]);
425
+ (stmt.branches ?? []).forEach(walk);
426
+ if (stmt.kind !== "task" || !stmt.target)
427
+ continue;
428
+ const res = this.resolveTarget(stmt.target);
429
+ if (res.operation)
430
+ stmt.operation = res.operation;
431
+ else if (res.ambiguous)
432
+ problems.ambiguous.push(`${stmt.id}: ${res.ambiguous}`);
433
+ else
434
+ problems.missing.push(`${stmt.id}: ${res.missing}`);
435
+ }
436
+ };
437
+ walk(body);
438
+ return problems;
439
+ }
440
+ /**
441
+ * Point every typed call of the compiled flows at the operation of the configured service of its class
442
+ *
443
+ * A flow whose service is not configured (a packaged unit without it) is removed with its crons; a call that several
444
+ * services serve, or a flow whose operation was taken over, stops the startup.
445
+ * @throws FatalInitError on an ambiguous call, or a flow id taken by another operation
446
+ */
447
+ resolveModuleTargets() {
448
+ for (const id of [...this.moduleFlows]) {
449
+ const operation = listFullOperations()[id];
450
+ if (operation?.context?.flowId !== id || operation.service !== this.getName()) {
451
+ throw new WebdaError.FatalInitError(`Flow ${id}: its operation id is used by the operation of service ${operation?.service}`);
452
+ }
453
+ const flow = this.flows.get(id);
454
+ const { missing, ambiguous } = this.resolveTargets(flow.body);
455
+ if (ambiguous.length) {
456
+ throw new WebdaError.FatalInitError(`Flow ${id} calls an ambiguous operation: ${ambiguous.join("; ")}`);
457
+ }
458
+ if (missing.length) {
459
+ this.log("ERROR", `Flow ${id} is not registered: ${missing.join("; ")}`);
460
+ this.unregisterModuleFlow(id);
461
+ }
462
+ }
463
+ }
464
+ /**
465
+ * Remove a module flow: its operation and its crons
466
+ * @param id - the flow id
467
+ */
468
+ unregisterModuleFlow(id) {
469
+ this.moduleFlows.delete(id);
470
+ this.flows.delete(id);
471
+ this.versions.delete(id);
472
+ CronService.unregister(id);
473
+ const operation = useInstanceStorage().operations?.[id];
474
+ if (operation?.service === this.getName() && operation.context?.flowId === id) {
475
+ delete useInstanceStorage().operations[id];
476
+ }
477
+ }
478
+ /**
479
+ * @override
480
+ */
481
+ async init() {
482
+ await super.init();
483
+ this.resolveModuleTargets();
484
+ this.listenStored();
485
+ await this.reloadStored();
486
+ this.startSync();
487
+ if (this.parameters.worker) {
488
+ this.startWorker();
489
+ }
490
+ return this;
491
+ }
492
+ /**
493
+ * Stop the worker loop
494
+ *
495
+ * Running attempts get `stopTimeout` to finish, then they are aborted and their outcome dropped: their lease
496
+ * expires and another worker runs them again.
497
+ * @override
498
+ */
499
+ async stop() {
500
+ this.stopping = true;
501
+ if (this.timer) {
502
+ clearInterval(this.timer);
503
+ this.timer = undefined;
504
+ }
505
+ if (this.syncTimer) {
506
+ clearInterval(this.syncTimer);
507
+ this.syncTimer = undefined;
508
+ }
509
+ await this.syncConsumer?.cancel();
510
+ this.syncConsumer = undefined;
511
+ // A poll in flight may still start executions
512
+ await this.pollPromise;
513
+ let timer;
514
+ const grace = new Promise(resolve => {
515
+ timer = setTimeout(resolve, this.parameters.stopTimeout);
516
+ });
517
+ await Promise.race([Promise.allSettled([...this.executions]), grace]);
518
+ clearTimeout(timer);
519
+ for (const attempt of this.running.values()) {
520
+ attempt.controller.abort(DROPPED);
521
+ }
522
+ await super.stop();
523
+ }
524
+ /**
525
+ * Register a schema in the validator and the application
526
+ * @param name - schema name
527
+ * @param schema - the JSON schema
528
+ */
529
+ registerSchema(name, schema) {
530
+ if (!hasSchema(name)) {
531
+ registerSchema(name, schema);
532
+ }
533
+ useApplication().getSchemas()[name] = schema;
534
+ }
535
+ /**
536
+ * Register a flow as an operation `<flow id>` served by {@link launch}
537
+ * @param def - the flow definition
538
+ */
539
+ registerFlow(def) {
540
+ const flow = { ...def, version: def.version || flowVersion(def) };
541
+ this.flows.set(flow.id, flow);
542
+ if (!this.versions.has(flow.id))
543
+ this.versions.set(flow.id, new Map());
544
+ this.versions.get(flow.id).set(flow.version, flow);
545
+ // Core cannot remove a schema: a stored flow, which changes at runtime, has one per version
546
+ const input = flow.source === "stored" ? `${flow.id}.input.${flow.version}` : `${flow.id}.input`;
547
+ // The parameters are given to launch in this order
548
+ this.registerSchema(input, {
549
+ type: "object",
550
+ ...flow.input,
551
+ properties: flow.input?.properties ?? {},
552
+ "x-webda-arguments": flow.params
553
+ });
554
+ this.registerSchema(LAUNCH_OUTPUT_SCHEMA, {
555
+ type: "object",
556
+ properties: { uuid: { type: "string" } },
557
+ required: ["uuid"]
558
+ });
559
+ registerOperation(flow.id, {
560
+ service: this.getName(),
561
+ method: "launch",
562
+ input,
563
+ output: LAUNCH_OUTPUT_SCHEMA,
564
+ context: { flowId: flow.id },
565
+ graphql: false,
566
+ grpc: false
567
+ });
568
+ }
569
+ /**
570
+ * Get a registered flow
571
+ * @param flowId - the flow id
572
+ * @returns the definition
573
+ */
574
+ getFlow(flowId) {
575
+ return this.flows.get(flowId);
576
+ }
577
+ /**
578
+ * Find a flow registered on any WorkflowService, by its operation
579
+ * @param flowId - the flow id
580
+ * @returns the current definition, undefined when the operation is not a flow
581
+ */
582
+ resolveFlow(flowId) {
583
+ if (!flowId)
584
+ return undefined;
585
+ const local = this.flows.get(flowId);
586
+ if (local)
587
+ return local;
588
+ const operation = listFullOperations()[flowId];
589
+ if (operation?.context?.flowId !== flowId || !operation.service)
590
+ return undefined;
591
+ const service = useDynamicService(operation.service);
592
+ return typeof service?.getFlow === "function" ? service.getFlow(flowId) : undefined;
593
+ }
594
+ /**
595
+ * Find a version of a flow
596
+ * @param flowId - the flow id
597
+ * @param version - the version
598
+ * @returns the definition
599
+ */
600
+ getFlowVersion(flowId, version) {
601
+ const local = this.versions.get(flowId)?.get(version);
602
+ if (local)
603
+ return local;
604
+ const operation = listFullOperations()[flowId];
605
+ const service = operation?.service ? useDynamicService(operation.service) : undefined;
606
+ return service !== this && typeof service?.getFlowVersion === "function"
607
+ ? service.getFlowVersion(flowId, version)
608
+ : undefined;
609
+ }
610
+ /**
611
+ * Role needed to write a stored workflow
612
+ * @returns the role
613
+ */
614
+ getStoredWorkflowRole() {
615
+ return this.parameters.storedWorkflowRole;
616
+ }
617
+ /**
618
+ * Validate and stamp stored workflows on save, and register them once saved
619
+ */
620
+ listenStored() {
621
+ const repo = useRepository(StoredWorkflow);
622
+ repo.on("Create", ({ object }) => this.prepareStored(object, object));
623
+ repo.on("Update", ({ object }) => this.prepareStored(object, object));
624
+ repo.on("Patch", async ({ object_id, object }) => {
625
+ const current = await runAsSystem(() => StoredWorkflow.ref(object_id).get());
626
+ await this.prepareStored({ ...current, ...object, uuid: object_id }, object);
627
+ });
628
+ // Partial updates (increments, collections, attribute removal) would skip the validation
629
+ repo.on("PartialUpdate", () => {
630
+ throw new WebdaError.BadRequest("A stored workflow is changed with update or patch, not partial updates");
631
+ });
632
+ for (const event of ["Created", "Updated", "Patched", "Deleted", "PartialUpdated"]) {
633
+ repo.on(event, ({ object_id }) => this.storedChanged(object_id));
634
+ }
635
+ }
636
+ /**
637
+ * Validate a stored workflow about to be saved and stamp its version and last saver
638
+ * @param workflow - the workflow as it will be saved
639
+ * @param target - the data written: receives `version` and `updatedBy`
640
+ */
641
+ prepareStored(workflow, target) {
642
+ this.validateStored(workflow);
643
+ target.version = flowVersion(workflow);
644
+ const context = useContext();
645
+ // A save by the system keeps the last saver: its crons never run as the system
646
+ if (context && !context.isGlobalContext() && context.getCurrentUserId()) {
647
+ target.updatedBy = context.getCurrentUserId();
648
+ }
649
+ }
650
+ /**
651
+ * Validate a stored workflow
652
+ * @param workflow - the workflow
653
+ * @throws BadRequest when its name, graph or options are refused
654
+ */
655
+ validateStored(workflow) {
656
+ const fail = (message) => {
657
+ throw new WebdaError.BadRequest(`Invalid workflow: ${message}`);
658
+ };
659
+ const name = workflow?.name;
660
+ if (typeof name !== "string" || !OPERATION_ID.test(name)) {
661
+ fail(`name ${name} must be an operation id (Group.Name)`);
662
+ }
663
+ // The registered operation decides: a module enabled later wins over a stored flow
664
+ if (listFullOperations()[name]) {
665
+ const flow = this.operationFlow(name);
666
+ if (!flow)
667
+ fail(`${name} is already an operation`);
668
+ if (flow.source !== "stored")
669
+ fail(`${name} is already a code flow`);
670
+ }
671
+ if ([...this.storedFlows].some(([uuid, f]) => f.flowId === name && uuid !== workflow.uuid)) {
672
+ fail(`${name} is already a stored workflow`);
673
+ }
674
+ if (!Array.isArray(workflow.params) || workflow.params.some((p) => typeof p !== "string")) {
675
+ fail("params must be an array of names");
676
+ }
677
+ if (workflow.input === null || typeof workflow.input !== "object") {
678
+ fail("input must be a JSON schema");
679
+ }
680
+ if (!Array.isArray(workflow.body)) {
681
+ fail("body must be an array of statements");
682
+ }
683
+ if (workflow.options?.useSystem && !this.parameters.allowStoredSystem) {
684
+ fail("useSystem is not allowed in a stored workflow");
685
+ }
686
+ for (const cron of workflow.crons ?? []) {
687
+ if (typeof cron?.cron !== "string" || (cron.input !== undefined && typeof cron.input !== "object")) {
688
+ fail("a cron needs a cron expression and an object input");
689
+ }
690
+ }
691
+ const walk = (stmts, path) => {
692
+ if (!Array.isArray(stmts))
693
+ fail(`${path} must be an array of statements`);
694
+ stmts.forEach((stmt, i) => {
695
+ const where = typeof stmt?.id === "string" ? stmt.id : `${path}[${i}]`;
696
+ if (typeof stmt?.id !== "string")
697
+ fail(`${where} has no id`);
698
+ const fields = STATEMENT_EXPRESSIONS[stmt.kind];
699
+ if (!fields)
700
+ fail(`${where}: unknown statement kind ${stmt.kind}`);
701
+ for (const field of fields) {
702
+ if (stmt[field] === undefined && field === "value" && stmt.kind === "return")
703
+ continue;
704
+ const invalid = validateExpr(stmt[field], `$.${field}`);
705
+ if (invalid.length)
706
+ fail(`${where}: invalid expression at ${invalid.join(", ")}`);
707
+ }
708
+ if (stmt.kind === "task") {
709
+ if (stmt.target !== undefined) {
710
+ // A typed call compiled from a .flow.ts: it runs the operation of the configured service of its class
711
+ const res = this.resolveTarget(stmt.target);
712
+ if (!res.operation)
713
+ fail(`${where}: ${res.missing ?? res.ambiguous}`);
714
+ }
715
+ else {
716
+ const known = Object.values(BUILTINS).includes(stmt.operation) ||
717
+ stmt.operation === name ||
718
+ !!listFullOperations()[stmt.operation];
719
+ if (!known)
720
+ fail(`${where}: unknown operation ${stmt.operation}`);
721
+ }
722
+ if (stmt.options?.useSystem && !this.parameters.allowStoredSystem) {
723
+ fail(`${where}: useSystem is not allowed in a stored workflow`);
724
+ }
725
+ }
726
+ for (const key of ["then", "else", "body", "catch", "finally"]) {
727
+ if (stmt[key] !== undefined)
728
+ walk(stmt[key], `${where}.${key}`);
729
+ }
730
+ (stmt.branches ?? []).forEach((branch, b) => walk(branch, `${where}.branches[${b}]`));
731
+ });
732
+ };
733
+ walk(workflow.body, "body");
734
+ }
735
+ /**
736
+ * The flow served by an operation, if it is a flow
737
+ * @param flowId - the operation id
738
+ * @returns the flow of the WorkflowService serving the operation
739
+ */
740
+ operationFlow(flowId) {
741
+ const operation = listFullOperations()[flowId];
742
+ if (operation?.context?.flowId !== flowId || !operation.service)
743
+ return undefined;
744
+ const service = operation.service === this.getName() ? this : useDynamicService(operation.service);
745
+ return typeof service?.getFlow === "function" ? service.getFlow(flowId) : undefined;
746
+ }
747
+ /**
748
+ * User a stored flow runs as when the system launches it: its last saver
749
+ * @param flowId - the flow id
750
+ * @returns the user id, undefined when unknown
751
+ */
752
+ storedRunAs(flowId) {
753
+ const local = [...this.storedFlows.values()].find(f => f.flowId === flowId);
754
+ if (local)
755
+ return local.updatedBy;
756
+ const operation = listFullOperations()[flowId];
757
+ const service = operation?.service ? useDynamicService(operation.service) : undefined;
758
+ return service && service !== this && typeof service.storedRunAs === "function"
759
+ ? service.storedRunAs(flowId)
760
+ : undefined;
761
+ }
762
+ /**
763
+ * Identity of a new instance: a stored flow never runs as the system, it runs as its last saver
764
+ * @param def - the flow
765
+ * @param launcher - the identity of the caller (launch) or of the parent instance (child flows)
766
+ * @returns the launcher of the instance
767
+ * @throws Forbidden when a stored flow launched by the system has no user to run as
768
+ */
769
+ instanceLauncher(def, launcher) {
770
+ if (!launcher?.system || def.source !== "stored")
771
+ return launcher;
772
+ const updatedBy = this.storedRunAs(def.id);
773
+ if (!updatedBy) {
774
+ throw new WebdaError.Forbidden(`${def.id} has no user to run as`);
775
+ }
776
+ return { userId: updatedBy };
777
+ }
778
+ /**
779
+ * Identity of a child flow, which must be allowed to call the flow operation like a launch
780
+ * @param def - the child flow
781
+ * @param launcher - the launcher of the parent
782
+ * @param input - the child input
783
+ * @returns the launcher of the child
784
+ * @throws TaskError Forbidden when the child may not be started
785
+ */
786
+ async childLauncher(def, launcher, input) {
787
+ let child;
788
+ try {
789
+ child = this.instanceLauncher(def, launcher);
790
+ }
791
+ catch (err) {
792
+ throw new TaskError("Forbidden", err.message, false);
793
+ }
794
+ const ctx = child.system ? useSystem(input) : this.launcherContext(child, input);
795
+ if (!(await canCallOperation(ctx, def.id, { input }))) {
796
+ throw new TaskError("Forbidden", `${def.id} PermissionDenied`, false);
797
+ }
798
+ return child;
799
+ }
800
+ /**
801
+ * A stored workflow changed on this replica: register it here and tell the others
802
+ * @param uuid - the workflow uuid
803
+ */
804
+ async storedChanged(uuid) {
805
+ await this.syncStored(uuid);
806
+ if (this.parameters.syncPubSub) {
807
+ await useDynamicService(this.parameters.syncPubSub).sendMessage({ workflow: uuid });
808
+ }
809
+ }
810
+ /**
811
+ * Register, re-register or remove a stored workflow from the store
812
+ * @param uuid - the workflow uuid
813
+ */
814
+ async syncStored(uuid) {
815
+ const workflow = await runAsSystem(async () => (await StoredWorkflow.ref(uuid).exists()) ? (await StoredWorkflow.ref(uuid).get()) : undefined);
816
+ if (workflow) {
817
+ this.registerStored(workflow);
818
+ }
819
+ else {
820
+ this.unregisterStored(uuid);
821
+ }
822
+ }
823
+ /**
824
+ * Validate then register a stored workflow; an invalid one is removed and logged
825
+ * @param workflow - the stored workflow
826
+ * @returns true when registered
827
+ */
828
+ registerStored(workflow) {
829
+ try {
830
+ // Every registration path: the store may hold a workflow written behind the validation
831
+ this.validateStored(workflow);
832
+ this.applyStored(workflow);
833
+ return true;
834
+ }
835
+ catch (err) {
836
+ this.log("ERROR", `Stored workflow ${workflow?.name} (${workflow?.uuid}) is not registered`, err);
837
+ this.unregisterStored(workflow.uuid);
838
+ return false;
839
+ }
840
+ }
841
+ /**
842
+ * Register a stored workflow as a flow, with its crons
843
+ * @param workflow - the stored workflow
844
+ */
845
+ applyStored(workflow) {
846
+ const known = this.storedFlows.get(workflow.uuid);
847
+ const crons = JSON.stringify(workflow.crons ?? []);
848
+ const version = workflow.version || flowVersion(workflow);
849
+ if (known && known.flowId === workflow.name && known.version === version && known.crons === crons) {
850
+ known.updatedBy = workflow.updatedBy;
851
+ return;
852
+ }
853
+ if (known && known.flowId !== workflow.name) {
854
+ this.unregisterStored(workflow.uuid);
855
+ }
856
+ // The typed calls of a graph compiled from a .flow.ts run the configured operation, as for the code flow
857
+ const body = structuredClone(workflow.body);
858
+ this.resolveTargets(body);
859
+ this.registerFlow({
860
+ id: workflow.name,
861
+ version,
862
+ params: workflow.params,
863
+ input: workflow.input,
864
+ body,
865
+ options: workflow.options,
866
+ source: "stored"
867
+ });
868
+ for (const id of known?.cronIds ?? [])
869
+ CronService.unregister(id);
870
+ const cronIds = [];
871
+ for (const cron of workflow.crons ?? []) {
872
+ try {
873
+ cronIds.push(CronService.getExportId(CronService.register(workflow.name, cron)));
874
+ }
875
+ catch (err) {
876
+ this.log("ERROR", `Cannot register the cron ${cron.cron} of ${workflow.name}`, err);
877
+ }
878
+ }
879
+ this.storedFlows.set(workflow.uuid, {
880
+ flowId: workflow.name,
881
+ version,
882
+ updatedBy: workflow.updatedBy,
883
+ crons,
884
+ cronIds
885
+ });
886
+ }
887
+ /**
888
+ * Remove a stored workflow: its operation and its crons (running instances keep their pinned definition)
889
+ * @param uuid - the workflow uuid
890
+ */
891
+ unregisterStored(uuid) {
892
+ const known = this.storedFlows.get(uuid);
893
+ if (!known)
894
+ return;
895
+ this.storedFlows.delete(uuid);
896
+ for (const id of known.cronIds)
897
+ CronService.unregister(id);
898
+ // Only what this stored flow registered: a code flow or another operation of the same name stays
899
+ if (this.flows.get(known.flowId)?.source !== "stored")
900
+ return;
901
+ this.flows.delete(known.flowId);
902
+ const operation = useInstanceStorage().operations?.[known.flowId];
903
+ if (operation?.service === this.getName() && operation.context?.flowId === known.flowId) {
904
+ delete useInstanceStorage().operations[known.flowId];
905
+ }
906
+ }
907
+ /**
908
+ * Load every stored workflow: register the new and changed ones, remove the deleted ones
909
+ */
910
+ async reloadStored() {
911
+ const workflows = await runAsSystem(async () => {
912
+ const all = [];
913
+ for await (const workflow of StoredWorkflow.iterate(""))
914
+ all.push(workflow);
915
+ return all;
916
+ });
917
+ const seen = new Set();
918
+ for (const workflow of workflows) {
919
+ seen.add(workflow.uuid);
920
+ this.registerStored(workflow);
921
+ }
922
+ for (const uuid of [...this.storedFlows.keys()]) {
923
+ if (!seen.has(uuid))
924
+ this.unregisterStored(uuid);
925
+ }
926
+ }
927
+ /**
928
+ * Follow the stored workflow changes of the other replicas: through `syncPubSub`, or a reload every
929
+ * `syncInterval`
930
+ */
931
+ startSync() {
932
+ if (this.parameters.syncPubSub) {
933
+ this.syncConsumer ??= useDynamicService(this.parameters.syncPubSub).consume(async (event) => {
934
+ if (event?.workflow)
935
+ await this.syncStored(String(event.workflow));
936
+ });
937
+ return;
938
+ }
939
+ if (this.syncTimer)
940
+ return;
941
+ this.syncTimer = setInterval(() => {
942
+ this.reloadStored().catch(err => this.log("ERROR", "Cannot reload the stored workflows", err));
943
+ }, this.parameters.syncInterval);
944
+ this.syncTimer.unref?.();
945
+ }
946
+ /**
947
+ * Launch the flow of the current operation, as the caller
948
+ * @param args - the flow parameters, in order
949
+ * @returns the instance uuid
950
+ */
951
+ async launch(...args) {
952
+ const context = useContext();
953
+ const flowId = context.getExtension("operationContext")?.flowId;
954
+ const def = flowId ? this.flows.get(flowId) : undefined;
955
+ if (!def) {
956
+ throw new WebdaError.NotFound(`Unknown flow ${flowId}`);
957
+ }
958
+ const input = {};
959
+ def.params.forEach((name, i) => {
960
+ if (args[i] !== undefined)
961
+ input[name] = args[i];
962
+ });
963
+ // A stored workflow never runs as the system: a cron tick runs it as the user who saved it last
964
+ const session = context.getSession();
965
+ const launcher = this.instanceLauncher(def, context.isGlobalContext()
966
+ ? { system: true }
967
+ : {
968
+ userId: context.getCurrentUserId() || undefined,
969
+ identId: session.identUsed,
970
+ scopes: session.scopes,
971
+ clientId: session.clientId
972
+ });
973
+ const instance = await runAsSystem(() => this.createInstance(def, input, launcher));
974
+ return { uuid: instance.uuid };
975
+ }
976
+ /**
977
+ * Create an instance and run it to its first tasks
978
+ * @param def - the flow definition, pinned in the instance
979
+ * @param input - the flow input
980
+ * @param launcher - the identity of the steps
981
+ * @param options - the parent of an awaited child, a fixed uuid (created once)
982
+ * @param options.parent - parent instance and task key
983
+ * @param options.parent.instance - parent instance uuid
984
+ * @param options.parent.key - task key in the parent
985
+ * @param options.uuid - uuid of the instance: an existing one is not created again
986
+ * @returns the instance
987
+ */
988
+ async createInstance(def, input, launcher, options = {}) {
989
+ const now = Date.now();
990
+ if (options.uuid && (await WorkflowInstance.ref(options.uuid).exists())) {
991
+ return (await WorkflowInstance.ref(options.uuid).get());
992
+ }
993
+ const instance = await WorkflowInstance.create({
994
+ ...(options.uuid ? { uuid: options.uuid } : {}),
995
+ ...(options.parent ? { parent: options.parent } : {}),
996
+ flowId: def.id,
997
+ version: def.version,
998
+ definition: def,
999
+ input,
1000
+ journal: {},
1001
+ status: "RUNNING",
1002
+ launcher: JSON.parse(JSON.stringify(launcher)),
1003
+ revision: 0,
1004
+ createdAt: now,
1005
+ updatedAt: now
1006
+ });
1007
+ await this.advance(instance.uuid);
1008
+ return instance;
1009
+ }
1010
+ /**
1011
+ * Start the worker loop
1012
+ */
1013
+ startWorker() {
1014
+ if (this.timer)
1015
+ return;
1016
+ this.timer = setInterval(() => this.poll(), this.parameters.pollInterval);
1017
+ this.timer.unref?.();
1018
+ }
1019
+ /**
1020
+ * One iteration of the worker loop: claim due tasks and run them in the background
1021
+ * @returns the poll in progress, it ends when the tasks are claimed and started
1022
+ */
1023
+ poll() {
1024
+ if (this.pollPromise)
1025
+ return this.pollPromise;
1026
+ if (this.stopping)
1027
+ return Promise.resolve();
1028
+ this.pollPromise = this.claimDue()
1029
+ .then(tasks => {
1030
+ for (const task of tasks) {
1031
+ const execution = this.execute(task)
1032
+ .catch(err => this.log("ERROR", "Workflow task failed", task.uuid, err))
1033
+ .finally(() => this.executions.delete(execution));
1034
+ this.executions.add(execution);
1035
+ }
1036
+ })
1037
+ .then(() => this.maybeSweep())
1038
+ .catch(err => this.log("ERROR", "Workflow poll failed", err))
1039
+ .finally(() => {
1040
+ this.pollPromise = undefined;
1041
+ });
1042
+ return this.pollPromise;
1043
+ }
1044
+ /**
1045
+ * Run the recovery sweep when `sweepInterval` passed since the last one
1046
+ */
1047
+ async maybeSweep() {
1048
+ if (this.stopping || Date.now() - this.lastSweep < this.parameters.sweepInterval)
1049
+ return;
1050
+ this.lastSweep = Date.now();
1051
+ await this.sweep();
1052
+ }
1053
+ /**
1054
+ * Replay the stranded instances: active, not updated for `sweepInterval`, without a PENDING or RUNNING task
1055
+ *
1056
+ * A crash between two steps leaves such an instance: replay is idempotent, and the revision CAS of
1057
+ * {@link advance} guards against a concurrent writer.
1058
+ * @returns the number of instances replayed
1059
+ */
1060
+ async sweep() {
1061
+ return runAsSystem(async () => {
1062
+ const before = Date.now() - this.parameters.sweepInterval;
1063
+ let count = 0;
1064
+ for (const status of ["RUNNING", "WAITING"]) {
1065
+ const { results } = await WorkflowInstance.query(`status = ? AND updatedAt < ? LIMIT 100`, [status, before]);
1066
+ for (const instance of results) {
1067
+ const live = (await WorkflowTask.query(`instance = ? AND (status = 'PENDING' OR status = 'RUNNING' OR status = 'WAITING_EVENT' OR status = 'WAITING_CHILD') LIMIT 1`, [instance.uuid])).results;
1068
+ if (live.length)
1069
+ continue;
1070
+ await this.advance(instance.uuid);
1071
+ count++;
1072
+ }
1073
+ }
1074
+ // Buffered signals nobody consumed in time
1075
+ const expired = (await WorkflowSignal.query(`expiresAt <= ? LIMIT 100`, [Date.now()])).results;
1076
+ for (const signal of expired) {
1077
+ await WorkflowSignal.ref(signal.uuid)
1078
+ .delete()
1079
+ .catch(() => { });
1080
+ }
1081
+ return count;
1082
+ });
1083
+ }
1084
+ /**
1085
+ * One poll: claim the due tasks and run them
1086
+ * @returns the number of tasks claimed
1087
+ */
1088
+ async tick() {
1089
+ const tasks = await this.claimDue();
1090
+ await Promise.all(tasks.map(task => this.execute(task)));
1091
+ return tasks.length;
1092
+ }
1093
+ /**
1094
+ * Claim the due tasks, up to the free slots of this replica
1095
+ *
1096
+ * The PENDING tasks due now, and the RUNNING ones whose lease expired (their worker crashed)
1097
+ * @returns the tasks claimed
1098
+ */
1099
+ async claimDue() {
1100
+ const slots = this.parameters.concurrency - this.running.size;
1101
+ if (slots <= 0)
1102
+ return [];
1103
+ return runAsSystem(async () => {
1104
+ const now = Date.now();
1105
+ const candidates = [
1106
+ ...(await WorkflowTask.query(escape(["status = 'PENDING' AND runAt <= ", " LIMIT ", ""], [now, slots]))).results,
1107
+ ...(await WorkflowTask.query(escape(["status = 'RUNNING' AND leaseUntil < ", " LIMIT ", ""], [now, slots]))).results,
1108
+ // Waits whose timeout passed
1109
+ ...(await WorkflowTask.query(escape(["status = 'WAITING_EVENT' AND runAt <= ", " LIMIT ", ""], [now, slots]))).results
1110
+ ];
1111
+ const claimed = [];
1112
+ for (const task of candidates) {
1113
+ if (claimed.length >= slots || this.stopping)
1114
+ break;
1115
+ if (await this.claim(task)) {
1116
+ claimed.push(task);
1117
+ }
1118
+ }
1119
+ return claimed;
1120
+ });
1121
+ }
1122
+ /**
1123
+ * Claim a task with a lease: a conditional patch on its `leaseUntil`
1124
+ *
1125
+ * Reclaiming an expired lease counts as a new attempt.
1126
+ * @param task - the task, as queried
1127
+ * @param extra - other fields to set with the claim
1128
+ * @returns true when this worker holds the task
1129
+ */
1130
+ async claim(task, extra = {}) {
1131
+ const now = Date.now();
1132
+ const patch = {
1133
+ ...extra,
1134
+ status: "RUNNING",
1135
+ owner: this.workerId,
1136
+ leaseUntil: now + this.parameters.leaseDuration,
1137
+ startedAt: now
1138
+ };
1139
+ const reclaim = task.status === "RUNNING";
1140
+ if (reclaim) {
1141
+ patch.attempt = task.attempt + 1;
1142
+ }
1143
+ const previous = task.leaseUntil;
1144
+ try {
1145
+ await WorkflowTask.ref(task.uuid).patch(patch, "leaseUntil", previous);
1146
+ }
1147
+ catch (err) {
1148
+ const current = (await WorkflowTask.ref(task.uuid).get());
1149
+ if (current && current.leaseUntil === previous)
1150
+ throw err;
1151
+ // Another worker has it
1152
+ return false;
1153
+ }
1154
+ Object.assign(task, patch);
1155
+ // A crash recovery gets one more attempt than the retries: past it the task keeps killing its workers
1156
+ if (reclaim && task.attempt - 1 > (task.options?.retries ?? 0) + 1) {
1157
+ this.crashed.add(task);
1158
+ }
1159
+ return true;
1160
+ }
1161
+ /**
1162
+ * Run a claimed task, write its outcome and advance its instance
1163
+ *
1164
+ * Every write of the attempt is conditional on the lease this worker holds: an attempt whose lease was lost (its
1165
+ * worker was too slow and another one reclaimed it) drops its outcome.
1166
+ * @param task - the task
1167
+ * @param advance - advance the instance after the outcome is journaled
1168
+ */
1169
+ async execute(task, advance = true) {
1170
+ const instance = await runAsSystem(() => WorkflowInstance.ref(task.instance).get());
1171
+ if (!isActive(instance) || instance.journal?.[task.key]) {
1172
+ // Cancelled, or its outcome is already journaled (a crash before the row was finished): never run it again
1173
+ await this.finishTask(task, { status: "CANCELLED" });
1174
+ return;
1175
+ }
1176
+ const controller = new AbortController();
1177
+ this.running.set(task.uuid, { task, controller });
1178
+ const renew = setInterval(() => this.renewLease(task), Math.max(this.parameters.leaseDuration / 2, 1));
1179
+ renew.unref?.();
1180
+ let entry;
1181
+ let retryable = false;
1182
+ try {
1183
+ if (this.crashed.has(task)) {
1184
+ throw new TaskError("Crashed", `${task.operation} was abandoned by its worker ${task.attempt - 1} times`, false);
1185
+ }
1186
+ if (task.signaled) {
1187
+ // Resolved by a signal or a child: the outcome is on the row
1188
+ entry = task.error ? { error: task.error } : { result: task.output };
1189
+ }
1190
+ else {
1191
+ entry = { result: await this.runTask(task, instance, controller) };
1192
+ }
1193
+ }
1194
+ catch (err) {
1195
+ const error = this.toFlowError(err, task.operation);
1196
+ retryable =
1197
+ !(err instanceof TaskError && !err.retryable) && !Object.values(NOT_RETRYABLE_STATUS).includes(error.code);
1198
+ entry = { error };
1199
+ }
1200
+ finally {
1201
+ clearInterval(renew);
1202
+ this.running.delete(task.uuid);
1203
+ }
1204
+ if (controller.signal.reason === DROPPED || !(await this.holdsLease(task))) {
1205
+ this.log("DEBUG", "Workflow task attempt dropped", task.uuid);
1206
+ return;
1207
+ }
1208
+ if ("error" in entry) {
1209
+ const retries = task.options?.retries ?? 0;
1210
+ if (retryable && task.attempt <= retries) {
1211
+ await this.scheduleRetry(task);
1212
+ await this.finishTask(task, { status: "ERROR", error: entry.error });
1213
+ return;
1214
+ }
1215
+ }
1216
+ await this.writeJournal(task.instance, task.key, entry);
1217
+ await this.finishTask(task, "error" in entry ? { status: "ERROR", error: entry.error } : { status: "SUCCESS", output: entry.result });
1218
+ if (advance) {
1219
+ await this.advance(task.instance);
1220
+ }
1221
+ }
1222
+ /**
1223
+ * Conditional patch of a task row on the lease this worker holds
1224
+ * @param task - the task, with the lease this worker holds
1225
+ * @param data - the changes
1226
+ * @returns false when another worker holds the lease now
1227
+ */
1228
+ async patchLeased(task, data) {
1229
+ return runAsSystem(async () => {
1230
+ try {
1231
+ await WorkflowTask.ref(task.uuid).patch(data, "leaseUntil", task.leaseUntil);
1232
+ }
1233
+ catch (err) {
1234
+ const current = (await WorkflowTask.ref(task.uuid).get());
1235
+ if (current && current.leaseUntil === task.leaseUntil)
1236
+ throw err;
1237
+ return false;
1238
+ }
1239
+ Object.assign(task, data);
1240
+ return true;
1241
+ });
1242
+ }
1243
+ /**
1244
+ * Check this worker still holds the lease of a task, and extend it
1245
+ * @param task - the task
1246
+ * @returns false when the lease was lost
1247
+ */
1248
+ holdsLease(task) {
1249
+ return this.patchLeased(task, { leaseUntil: Date.now() + this.parameters.leaseDuration });
1250
+ }
1251
+ /**
1252
+ * Extend the lease of a running task, abort the attempt when the lease was lost
1253
+ * @param task - the task
1254
+ */
1255
+ async renewLease(task) {
1256
+ let held = false;
1257
+ try {
1258
+ held = await this.holdsLease(task);
1259
+ }
1260
+ catch (err) {
1261
+ this.log("WARN", "Cannot renew the lease of workflow task", task.uuid, err);
1262
+ }
1263
+ if (!held) {
1264
+ this.running.get(task.uuid)?.controller.abort(DROPPED);
1265
+ }
1266
+ }
1267
+ /**
1268
+ * Record the end of an attempt, if this worker still holds its lease
1269
+ *
1270
+ * `leaseUntil` becomes the end time, never 0: a worker that queried the task before cannot claim it again.
1271
+ * @param task - the task
1272
+ * @param data - status and outcome
1273
+ * @returns false when the lease was lost
1274
+ */
1275
+ finishTask(task, data) {
1276
+ const endedAt = Date.now();
1277
+ return this.patchLeased(task, { ...data, endedAt, leaseUntil: endedAt });
1278
+ }
1279
+ /**
1280
+ * Create the next attempt of a failed task, after the backoff delay
1281
+ * @param task - the failed attempt
1282
+ */
1283
+ async scheduleRetry(task) {
1284
+ const delay = new Duration(task.options?.delay ?? "1s").toMs();
1285
+ const factor = (task.options?.backoff ?? "exponential") === "exponential" ? 2 ** (task.attempt - 1) : 1;
1286
+ await this.createTask(task.instance, { key: task.key, operation: task.operation, input: task.input, options: task.options }, task.launcherId, Date.now() + delay * factor, task.attempt + 1);
1287
+ }
1288
+ /**
1289
+ * Run the operation (or built-in) of a task
1290
+ * @param task - the task
1291
+ * @param instance - its instance
1292
+ * @param controller - aborted on timeout
1293
+ * @returns the result
1294
+ */
1295
+ async runTask(task, instance, controller) {
1296
+ switch (task.operation) {
1297
+ case BUILTINS.DateNow:
1298
+ return Date.now();
1299
+ case BUILTINS.RandomNumber:
1300
+ return Math.random();
1301
+ case BUILTINS.RandomUuid:
1302
+ return randomUUID();
1303
+ case BUILTINS.Sleep:
1304
+ // The timer is the runAt of the task
1305
+ return null;
1306
+ case BUILTINS.WaitForEvent:
1307
+ throw new TaskError("Timeout", `No ${task.event} event for ${task.eventKey} before the timeout`, false);
1308
+ case BUILTINS.Start: {
1309
+ const def = this.resolveFlow(task.input?.flow);
1310
+ if (!def) {
1311
+ throw new TaskError("NotFound", `Unknown flow ${task.input?.flow}`, false);
1312
+ }
1313
+ const input = task.input?.input ?? {};
1314
+ const launcher = await this.childLauncher(def, instance.launcher, input);
1315
+ // The same uuid for every attempt: a child started twice is created once
1316
+ const uuid = taskUuid(task.uuid, "start", 1);
1317
+ await runAsSystem(() => this.createInstance(def, input, launcher, { uuid }));
1318
+ return { uuid };
1319
+ }
1320
+ }
1321
+ if (this.resolveFlow(task.operation)) {
1322
+ throw new TaskError("InvalidTask", `Child flow ${task.operation} runs as a WAITING_CHILD task`, false);
1323
+ }
1324
+ const options = task.options;
1325
+ if (options.useSystem && instance.definition?.source === "stored" && !this.parameters.allowStoredSystem) {
1326
+ throw new TaskError("SystemNotAllowed", "A stored workflow cannot run a task as the system", false);
1327
+ }
1328
+ const ctx = options.useSystem || instance.launcher?.system
1329
+ ? useSystem(task.input ?? null)
1330
+ : this.launcherContext(instance.launcher, task.input);
1331
+ ctx.setExtension("abortSignal", controller.signal);
1332
+ await ctx.init();
1333
+ const call = callOperation(ctx, task.operation);
1334
+ if (options.timeout) {
1335
+ const timeoutMs = new Duration(options.timeout).toMs();
1336
+ let timer;
1337
+ const timeout = new Promise((_resolve, reject) => {
1338
+ timer = setTimeout(() => {
1339
+ controller.abort();
1340
+ reject(new TaskError("Timeout", `${task.operation} timed out after ${options.timeout}`));
1341
+ }, timeoutMs);
1342
+ });
1343
+ // An attempt still running after its timeout is ignored
1344
+ call.catch(() => { });
1345
+ try {
1346
+ await Promise.race([call, timeout]);
1347
+ }
1348
+ finally {
1349
+ clearTimeout(timer);
1350
+ }
1351
+ }
1352
+ else {
1353
+ await call;
1354
+ }
1355
+ const output = ctx.getOutput();
1356
+ if (!output)
1357
+ return null;
1358
+ try {
1359
+ return JSON.parse(output);
1360
+ }
1361
+ catch {
1362
+ // A string result is written as is
1363
+ return output;
1364
+ }
1365
+ }
1366
+ /**
1367
+ * Build the context of the launcher: the session is rebuilt from the user id, so the permissions are fresh
1368
+ * @param launcher - the launcher
1369
+ * @param input - the operation input
1370
+ * @returns the context
1371
+ */
1372
+ launcherContext(launcher, input) {
1373
+ const ctx = new SimpleOperationContext();
1374
+ const session = new Session();
1375
+ if (launcher?.userId) {
1376
+ session.login(launcher.userId, launcher.identId ?? "");
1377
+ }
1378
+ session.scopes = launcher.scopes;
1379
+ session.clientId = launcher.clientId;
1380
+ ctx.setSession(session);
1381
+ ctx.setInput(Buffer.from(JSON.stringify(input ?? null)));
1382
+ return ctx;
1383
+ }
1384
+ /**
1385
+ * Convert an error to the data the flow sees
1386
+ * @param err - the error
1387
+ * @param operation - the operation of the task
1388
+ * @returns the flow error
1389
+ */
1390
+ toFlowError(err, operation) {
1391
+ let code;
1392
+ const status = typeof err?.getResponseCode === "function" ? err.getResponseCode() : undefined;
1393
+ if (err instanceof TaskError) {
1394
+ code = err.code;
1395
+ }
1396
+ else if (NOT_RETRYABLE_STATUS[status]) {
1397
+ code = NOT_RETRYABLE_STATUS[status];
1398
+ }
1399
+ else if (err instanceof WebdaError.HttpError) {
1400
+ code = err.constructor.name;
1401
+ }
1402
+ else {
1403
+ code = typeof err?.code === "string" && err.code ? err.code : err?.name || "Error";
1404
+ }
1405
+ return { code, message: String(err?.message ?? err), operation };
1406
+ }
1407
+ /**
1408
+ * Write a task outcome to the instance journal: a conditional patch on `revision`, retried on conflict
1409
+ *
1410
+ * The first outcome of a key wins. A journal over `maxContextSize` fails the instance.
1411
+ * @param instanceId - the instance uuid
1412
+ * @param key - the task key
1413
+ * @param entry - the outcome
1414
+ * @returns true when the outcome is in the journal
1415
+ */
1416
+ async writeJournal(instanceId, key, entry) {
1417
+ return runAsSystem(async () => {
1418
+ for (;;) {
1419
+ const instance = (await WorkflowInstance.ref(instanceId).get());
1420
+ if (!isActive(instance))
1421
+ return false;
1422
+ if (instance.journal?.[key])
1423
+ return true;
1424
+ const journal = { ...instance.journal, [key]: { ...entry, at: Date.now(), seq: instance.revision + 1 } };
1425
+ const size = Buffer.byteLength(JSON.stringify(journal)) + Buffer.byteLength(JSON.stringify(instance.input));
1426
+ if (size > this.parameters.maxContextSize) {
1427
+ const error = {
1428
+ code: "ContextTooLarge",
1429
+ message: `The context of the workflow is ${size} bytes, over ${this.parameters.maxContextSize}`,
1430
+ operation: instance.flowId
1431
+ };
1432
+ if (await this.patchInstance(instance, { status: "ERROR", error })) {
1433
+ await this.deliverToParent(instance.uuid);
1434
+ return false;
1435
+ }
1436
+ continue;
1437
+ }
1438
+ if (await this.patchInstance(instance, { journal }))
1439
+ return true;
1440
+ }
1441
+ });
1442
+ }
1443
+ /**
1444
+ * Patch an instance if its revision did not change
1445
+ * @param instance - the instance as read
1446
+ * @param data - the changes
1447
+ * @returns false when another writer changed it first
1448
+ */
1449
+ async patchInstance(instance, data) {
1450
+ try {
1451
+ await WorkflowInstance.ref(instance.uuid).patch({ ...data, revision: instance.revision + 1, updatedAt: Date.now() }, "revision", instance.revision);
1452
+ return true;
1453
+ }
1454
+ catch (err) {
1455
+ const current = (await WorkflowInstance.ref(instance.uuid).get());
1456
+ if (current?.revision === instance.revision)
1457
+ throw err;
1458
+ return false;
1459
+ }
1460
+ }
1461
+ /**
1462
+ * Replay an instance and act on the result: finish it, or create its missing tasks
1463
+ * @param instanceId - the instance uuid
1464
+ */
1465
+ async advance(instanceId) {
1466
+ await runAsSystem(async () => {
1467
+ for (;;) {
1468
+ const instance = (await WorkflowInstance.ref(instanceId).get());
1469
+ if (!isActive(instance))
1470
+ return;
1471
+ const res = run(instance.definition, instance.input, instance.journal);
1472
+ if (res.status === "done" || res.status === "error") {
1473
+ const end = res.status === "done" ? { status: "SUCCESS", output: res.output } : { status: "ERROR", error: res.error };
1474
+ if (await this.patchInstance(instance, end)) {
1475
+ await this.deliverToParent(instanceId);
1476
+ return;
1477
+ }
1478
+ continue;
1479
+ }
1480
+ let resolved = false;
1481
+ for (const scheduled of res.schedule) {
1482
+ resolved = (await this.scheduleTask(instance, scheduled)) || resolved;
1483
+ }
1484
+ // A buffered signal resolved a new wait: replay again
1485
+ if (resolved)
1486
+ continue;
1487
+ const status = res.schedule.every(t => WAITING_OPERATIONS.includes(t.operation) || this.resolveFlow(t.operation))
1488
+ ? "WAITING"
1489
+ : "RUNNING";
1490
+ if (status === instance.status || (await this.patchInstance(instance, { status })))
1491
+ return;
1492
+ }
1493
+ });
1494
+ }
1495
+ /**
1496
+ * Create the task of a scheduled key, unless a PENDING or RUNNING one exists
1497
+ * @param instance - the instance
1498
+ * @param scheduled - the task to run
1499
+ * @returns true when the task was resolved at once (a buffered signal): the journal changed
1500
+ */
1501
+ async scheduleTask(instance, scheduled) {
1502
+ const existing = (await WorkflowTask.query(`instance = ? AND key = ?`, [instance.uuid, scheduled.key]))
1503
+ .results;
1504
+ // A SUCCESS row is journaled before it is finished: the replay read the instance before it
1505
+ if (existing.some(t => LIVE_TASK.includes(t.status) || t.status === "SUCCESS"))
1506
+ return false;
1507
+ const now = Date.now();
1508
+ let runAt = now;
1509
+ let extra = {};
1510
+ if (scheduled.operation === BUILTINS.Sleep) {
1511
+ runAt = this.sleepUntil(scheduled.input, runAt);
1512
+ }
1513
+ else if (this.resolveFlow(scheduled.operation)) {
1514
+ return this.scheduleChild(instance, scheduled, existing.length + 1);
1515
+ }
1516
+ else if (scheduled.operation === BUILTINS.WaitForEvent) {
1517
+ const input = scheduled.input ?? {};
1518
+ extra = {
1519
+ status: "WAITING_EVENT",
1520
+ event: String(input.name),
1521
+ eventKey: String(input.key),
1522
+ allow: Array.isArray(input.allow) ? input.allow.map(String) : undefined
1523
+ };
1524
+ runAt = input.timeout ? this.sleepUntil({ duration: input.timeout }, now) : NO_TIMEOUT;
1525
+ }
1526
+ const task = await this.createTask(instance.uuid, scheduled, instance.launcher?.userId, runAt, 1, existing.length + 1, extra);
1527
+ return task?.status === "WAITING_EVENT" ? this.consumeBuffered(instance, task) : false;
1528
+ }
1529
+ /**
1530
+ * Start an awaited child flow: the task waits for the child instance, which runs as the same launcher
1531
+ * @param instance - the parent instance
1532
+ * @param scheduled - the task, its operation is a flow id
1533
+ * @param index - row index of the key
1534
+ * @returns true when the child finished at once: the parent journal changed
1535
+ */
1536
+ async scheduleChild(instance, scheduled, index) {
1537
+ const def = this.resolveFlow(scheduled.operation);
1538
+ // Created with the row: a worker resuming after a crash finds the same child
1539
+ const child = taskUuid(instance.uuid, `${scheduled.key}#child`, index);
1540
+ const task = await this.createTask(instance.uuid, scheduled, instance.launcher?.userId, NO_TIMEOUT, 1, index, {
1541
+ status: "WAITING_CHILD",
1542
+ child
1543
+ });
1544
+ if (!task)
1545
+ return false;
1546
+ let launcher;
1547
+ try {
1548
+ launcher = await this.childLauncher(def, instance.launcher, scheduled.input ?? {});
1549
+ }
1550
+ catch (err) {
1551
+ // Refused: the task fails like any task, the flow can catch it
1552
+ if (await this.claim(task, { signaled: true, error: this.toFlowError(err, scheduled.operation) })) {
1553
+ await this.execute(task, false);
1554
+ return true;
1555
+ }
1556
+ return false;
1557
+ }
1558
+ await this.createInstance(def, scheduled.input ?? {}, launcher, {
1559
+ parent: { instance: instance.uuid, key: scheduled.key },
1560
+ uuid: child
1561
+ });
1562
+ return (await WorkflowTask.ref(task.uuid).get()).status !== "WAITING_CHILD";
1563
+ }
1564
+ /**
1565
+ * Journal the end of a child into its parent: its output, its error, or `Cancelled`
1566
+ * @param childId - the child instance uuid
1567
+ */
1568
+ async deliverToParent(childId) {
1569
+ await runAsSystem(async () => {
1570
+ const child = (await WorkflowInstance.ref(childId).get());
1571
+ if (!child?.parent || isActive(child))
1572
+ return;
1573
+ const rows = (await WorkflowTask.query(`instance = ? AND key = ? AND status = 'WAITING_CHILD'`, [
1574
+ child.parent.instance,
1575
+ child.parent.key
1576
+ ])).results;
1577
+ const row = rows.find(r => r.child === child.uuid);
1578
+ if (!row)
1579
+ return;
1580
+ const outcome = child.status === "SUCCESS"
1581
+ ? { signaled: true, output: child.output }
1582
+ : {
1583
+ signaled: true,
1584
+ error: child.error ?? {
1585
+ code: "Cancelled",
1586
+ message: "The child flow was cancelled",
1587
+ operation: child.flowId
1588
+ }
1589
+ };
1590
+ if (await this.claim(row, outcome)) {
1591
+ await this.execute(row);
1592
+ }
1593
+ });
1594
+ }
1595
+ /**
1596
+ * Cancel an instance, its live tasks and the children it waits on
1597
+ * @param uuid - the instance uuid
1598
+ */
1599
+ async cancelInstance(uuid) {
1600
+ await runAsSystem(async () => {
1601
+ if (!(await this.cancelTree(uuid))) {
1602
+ throw new WebdaError.BadRequest("cancel: the instance is not running");
1603
+ }
1604
+ });
1605
+ }
1606
+ /**
1607
+ * Cancel an instance and, recursively, the children it waits on
1608
+ * @param uuid - the instance uuid
1609
+ * @returns false when the instance was not running
1610
+ */
1611
+ async cancelTree(uuid) {
1612
+ for (;;) {
1613
+ const instance = (await WorkflowInstance.ref(uuid).get());
1614
+ if (!isActive(instance))
1615
+ return false;
1616
+ if (await this.patchInstance(instance, { status: "CANCELLED" }))
1617
+ break;
1618
+ }
1619
+ for (const row of (await WorkflowTask.query(`instance = ?`, [uuid])).results) {
1620
+ if (!LIVE_TASK.includes(row.status))
1621
+ continue;
1622
+ // A local attempt stops now, a remote one loses its lease and drops its outcome
1623
+ this.running.get(row.uuid)?.controller.abort(DROPPED);
1624
+ await this.supersede(row);
1625
+ if (row.status === "WAITING_CHILD" && row.child) {
1626
+ await this.cancelTree(row.child);
1627
+ }
1628
+ }
1629
+ // A child cancelled on its own throws Cancelled in its parent
1630
+ await this.deliverToParent(uuid);
1631
+ return true;
1632
+ }
1633
+ /**
1634
+ * Mark a task row CANCELLED, conditionally on its lease: whoever holds it can no longer write its outcome
1635
+ * @param row - the task row, as read
1636
+ */
1637
+ async supersede(row) {
1638
+ const status = row.status;
1639
+ for (let current = row; current.status !== "CANCELLED";) {
1640
+ const now = Date.now();
1641
+ if (await this.patchLeased(current, { status: "CANCELLED", endedAt: now, leaseUntil: now }))
1642
+ break;
1643
+ current = (await WorkflowTask.ref(row.uuid).get());
1644
+ }
1645
+ // Keep the status seen by the caller
1646
+ row.status = status;
1647
+ }
1648
+ /**
1649
+ * Restart a failed instance: drop the outcome that failed it and every outcome journaled after it, then replay
1650
+ * @param uuid - the instance uuid
1651
+ */
1652
+ async retryInstance(uuid) {
1653
+ await runAsSystem(async () => {
1654
+ let kept;
1655
+ for (;;) {
1656
+ const instance = (await WorkflowInstance.ref(uuid).get());
1657
+ if (instance?.status !== "ERROR") {
1658
+ throw new WebdaError.BadRequest("retry: only a failed instance can be retried");
1659
+ }
1660
+ const order = (entry) => entry.seq ?? entry.at ?? 0;
1661
+ const entries = Object.entries(instance.journal);
1662
+ const failing = entries
1663
+ .filter(([, entry]) => "error" in entry && sameError(entry.error, instance.error))
1664
+ .sort(([, a], [, b]) => order(b) - order(a))[0];
1665
+ if (!failing) {
1666
+ throw new WebdaError.BadRequest("retry: the error does not come from a task");
1667
+ }
1668
+ kept = Object.fromEntries(entries.filter(([key, entry]) => key !== failing[0] && order(entry) < order(failing[1])));
1669
+ if (await this.patchInstance(instance, { status: "RUNNING", journal: kept, error: null }))
1670
+ break;
1671
+ }
1672
+ // Finished rows of the dropped keys would stop them from being scheduled again
1673
+ for (const row of (await WorkflowTask.query(`instance = ?`, [uuid])).results) {
1674
+ if ((row.status === "SUCCESS" || row.status === "ERROR") && !kept[row.key]) {
1675
+ await this.supersede(row);
1676
+ }
1677
+ }
1678
+ await this.advance(uuid);
1679
+ });
1680
+ }
1681
+ /**
1682
+ * Move a running instance to another version of its flow
1683
+ *
1684
+ * Refused when a journaled or live task key has no statement in the target graph.
1685
+ * @param uuid - the instance uuid
1686
+ * @param version - the target version, the latest registered when undefined
1687
+ */
1688
+ async migrateInstance(uuid, version) {
1689
+ await runAsSystem(async () => {
1690
+ for (;;) {
1691
+ const instance = (await WorkflowInstance.ref(uuid).get());
1692
+ if (!isActive(instance)) {
1693
+ throw new WebdaError.BadRequest("migrate: the instance is not running");
1694
+ }
1695
+ const target = version ? this.getFlowVersion(instance.flowId, version) : this.resolveFlow(instance.flowId);
1696
+ if (!target) {
1697
+ throw new WebdaError.BadRequest(`migrate: unknown version ${version ?? "(latest)"} of ${instance.flowId}`);
1698
+ }
1699
+ const ids = keysOf(target);
1700
+ const live = (await WorkflowTask.query(`instance = ?`, [uuid])).results
1701
+ .filter(row => LIVE_TASK.includes(row.status))
1702
+ .map(row => row.key);
1703
+ const missing = [...new Set([...Object.keys(instance.journal), ...live])].filter(key => !ids.has(statementIdOf(key)));
1704
+ if (missing.length) {
1705
+ throw new WebdaError.BadRequest(`migrate: ${missing.join(", ")} not in version ${target.version}`);
1706
+ }
1707
+ if (await this.patchInstance(instance, { definition: target, version: target.version }))
1708
+ break;
1709
+ }
1710
+ await this.advance(uuid);
1711
+ });
1712
+ }
1713
+ /**
1714
+ * Deliver a signal: resolve the waits on `(event, key)` whose instance accepts the caller, or buffer it when
1715
+ * nothing waits
1716
+ * @param event - event name
1717
+ * @param key - business key
1718
+ * @param payload - the result of the waits
1719
+ * @returns the number of waits resolved
1720
+ */
1721
+ async signal(event, key, payload) {
1722
+ const context = useContext();
1723
+ if (!context.isGlobalContext() && !context.getCurrentUserId()) {
1724
+ throw new WebdaError.Unauthorized("Workflow.Signal needs an authenticated caller");
1725
+ }
1726
+ if (Buffer.byteLength(JSON.stringify(payload ?? null)) > this.parameters.maxSignalPayload) {
1727
+ throw new WebdaError.BadRequest(`Workflow.Signal payload is over ${this.parameters.maxSignalPayload} bytes`);
1728
+ }
1729
+ const waits = await runAsSystem(() => this.waitsFor(event, key));
1730
+ if (waits.length === 0) {
1731
+ const buffered = await this.bufferSignal(context, event, key, payload);
1732
+ // A wait created meanwhile looked for buffered signals before this one was saved
1733
+ return { delivered: (await this.deliverBuffered(buffered)) ? 1 : 0 };
1734
+ }
1735
+ let delivered = 0;
1736
+ let refused = 0;
1737
+ for (const { instance, task } of waits) {
1738
+ if (!(await this.canSignal(context, instance, task))) {
1739
+ refused++;
1740
+ continue;
1741
+ }
1742
+ if (await runAsSystem(() => this.resolveWait(task, payload))) {
1743
+ delivered++;
1744
+ }
1745
+ }
1746
+ // Refused by every wait, or waits resolved by a concurrent signal: not delivered, not buffered. A refused sender
1747
+ // gets the answer of "nothing waits", so the pending waits cannot be probed
1748
+ return { delivered };
1749
+ }
1750
+ /**
1751
+ * The waits on `(event, key)` of active instances
1752
+ * @param event - event name
1753
+ * @param key - business key
1754
+ * @returns the waits and their instance
1755
+ */
1756
+ async waitsFor(event, key) {
1757
+ const tasks = (await WorkflowTask.query(`status = 'WAITING_EVENT' AND event = ? AND eventKey = ?`, [event, key]))
1758
+ .results;
1759
+ const waits = [];
1760
+ for (const task of tasks) {
1761
+ const instance = (await WorkflowInstance.ref(task.instance).get());
1762
+ if (isActive(instance))
1763
+ waits.push({ instance, task });
1764
+ }
1765
+ return waits;
1766
+ }
1767
+ /**
1768
+ * Whether a sender may signal a wait: the launcher, the system, and the users in the `allow` of the wait
1769
+ * @param context - the sender
1770
+ * @param instance - the instance waiting
1771
+ * @param task - the wait
1772
+ * @returns true when allowed
1773
+ */
1774
+ async canSignal(context, instance, task) {
1775
+ if ((await instance.canAct(context, "signal")) === true)
1776
+ return true;
1777
+ const userId = context.getCurrentUserId();
1778
+ return !!userId && (task.allow ?? []).includes(userId);
1779
+ }
1780
+ /**
1781
+ * Save a signal nothing waits for yet
1782
+ * @param context - the sender
1783
+ * @param event - event name
1784
+ * @param key - business key
1785
+ * @param payload - the payload
1786
+ * @returns the buffered signal
1787
+ */
1788
+ async bufferSignal(context, event, key, payload) {
1789
+ const now = Date.now();
1790
+ const system = context.isGlobalContext();
1791
+ const pending = await runAsSystem(async () => (await WorkflowSignal.query(escape(["event = ? AND key = ? AND expiresAt > ? LIMIT ", ""], [this.parameters.maxBufferedSignals]), [event, key, now])).results.length);
1792
+ if (pending >= this.parameters.maxBufferedSignals) {
1793
+ // Refused rather than dropping an older one: an accepted signal is never lost
1794
+ throw new WebdaError.TooManyRequests(`Too many buffered signals for ${event}/${key}`);
1795
+ }
1796
+ return runAsSystem(() => WorkflowSignal.create({
1797
+ event,
1798
+ key,
1799
+ payload,
1800
+ senderId: system ? undefined : context.getCurrentUserId(),
1801
+ senderIdent: system ? undefined : context.getSession()?.identUsed,
1802
+ system: system || undefined,
1803
+ createdAt: now,
1804
+ expiresAt: now + new Duration(this.parameters.signalTtl).toMs()
1805
+ }));
1806
+ }
1807
+ /**
1808
+ * Deliver a just buffered signal to a wait created while it was being saved
1809
+ * @param signal - the buffered signal
1810
+ * @returns true when a wait consumed it
1811
+ */
1812
+ async deliverBuffered(signal) {
1813
+ return runAsSystem(async () => {
1814
+ for (const { instance, task } of await this.waitsFor(signal.event, signal.key)) {
1815
+ if (await this.consumeSignal(signal, instance, task, true))
1816
+ return true;
1817
+ }
1818
+ return false;
1819
+ });
1820
+ }
1821
+ /**
1822
+ * Resolve a new wait with the oldest unexpired buffered signal its instance accepts
1823
+ * @param instance - the instance
1824
+ * @param task - the wait
1825
+ * @returns true when a signal was consumed
1826
+ */
1827
+ async consumeBuffered(instance, task) {
1828
+ const signals = (await WorkflowSignal.query(`event = ? AND key = ? AND expiresAt > ?`, [task.event, task.eventKey, Date.now()]))
1829
+ .results.sort((a, b) => a.createdAt - b.createdAt);
1830
+ for (const signal of signals) {
1831
+ // Advanced by the caller (advance)
1832
+ if (await this.consumeSignal(signal, instance, task, false))
1833
+ return true;
1834
+ }
1835
+ return false;
1836
+ }
1837
+ /**
1838
+ * Consume a buffered signal for a wait: its sender is checked now, against this instance
1839
+ * @param signal - the buffered signal
1840
+ * @param instance - the instance waiting
1841
+ * @param task - the wait
1842
+ * @param advance - advance the instance once the payload is journaled
1843
+ * @returns true when consumed and delivered
1844
+ */
1845
+ async consumeSignal(signal, instance, task, advance) {
1846
+ if (signal.expiresAt <= Date.now())
1847
+ return false;
1848
+ const sender = signal.system
1849
+ ? useSystem()
1850
+ : this.launcherContext({ userId: signal.senderId, identId: signal.senderIdent }, null);
1851
+ if (!(await this.canSignal(sender, instance, task)))
1852
+ return false;
1853
+ try {
1854
+ // Consumed once: a concurrent consumer fails the condition
1855
+ await WorkflowSignal.ref(signal.uuid).delete("expiresAt", signal.expiresAt);
1856
+ }
1857
+ catch {
1858
+ return false;
1859
+ }
1860
+ if (await this.resolveWait(task, signal.payload, advance))
1861
+ return true;
1862
+ // The wait timed out or another signal resolved it first: the signal stays buffered
1863
+ const { uuid, event, key, payload, senderId, senderIdent, system, createdAt, expiresAt } = signal;
1864
+ await WorkflowSignal.create({
1865
+ uuid,
1866
+ event,
1867
+ key,
1868
+ payload,
1869
+ senderId,
1870
+ senderIdent,
1871
+ system,
1872
+ createdAt,
1873
+ expiresAt
1874
+ });
1875
+ return false;
1876
+ }
1877
+ /**
1878
+ * Resolve a wait with a payload: claimed like a task, so it resolves once
1879
+ * @param task - the wait
1880
+ * @param payload - its result
1881
+ * @param advance - advance the instance once the payload is journaled
1882
+ * @returns false when the wait was already resolved or timed out
1883
+ */
1884
+ async resolveWait(task, payload, advance = true) {
1885
+ if (!(await this.claim(task, { signaled: true, output: payload })))
1886
+ return false;
1887
+ await this.execute(task, advance);
1888
+ return true;
1889
+ }
1890
+ /**
1891
+ * End of a `Workflow.Sleep`
1892
+ * @param input - `{ duration?: string, until?: number }`
1893
+ * @param now - the current time
1894
+ * @returns when the timer fires
1895
+ */
1896
+ sleepUntil(input, now) {
1897
+ if (typeof input?.until === "number") {
1898
+ return input.until;
1899
+ }
1900
+ try {
1901
+ return now + new Duration(input?.duration ?? 0).toMs();
1902
+ }
1903
+ catch {
1904
+ // An invalid duration fires at once
1905
+ return now;
1906
+ }
1907
+ }
1908
+ /**
1909
+ * Create a task row, unless another worker created the same row first
1910
+ * @param instance - the instance uuid
1911
+ * @param scheduled - the task
1912
+ * @param launcherId - user id of the launcher
1913
+ * @param runAt - when it becomes runnable
1914
+ * @param attempt - attempt number
1915
+ * @param index - row index of the key, when known
1916
+ * @param extra - other fields of the row
1917
+ * @returns the task created, undefined when another worker created it
1918
+ */
1919
+ async createTask(instance, scheduled, launcherId, runAt, attempt, index, extra = {}) {
1920
+ return runAsSystem(async () => {
1921
+ index ??= (await WorkflowTask.query(`instance = ? AND key = ?`, [instance, scheduled.key])).results.length + 1;
1922
+ const uuid = taskUuid(instance, scheduled.key, index);
1923
+ try {
1924
+ return (await WorkflowTask.create({
1925
+ uuid,
1926
+ instance,
1927
+ key: scheduled.key,
1928
+ operation: scheduled.operation,
1929
+ input: scheduled.input,
1930
+ options: scheduled.options,
1931
+ attempt,
1932
+ status: "PENDING",
1933
+ runAt,
1934
+ leaseUntil: 0,
1935
+ launcherId,
1936
+ ...extra
1937
+ }));
1938
+ }
1939
+ catch (err) {
1940
+ if (!(await WorkflowTask.ref(uuid).exists()))
1941
+ throw err;
1942
+ // Created by another worker
1943
+ return undefined;
1944
+ }
1945
+ });
1946
+ }
1947
+ };
1948
+ })();
1949
+ export { WorkflowService };
1950
+ export default WorkflowService;
1951
+ //# sourceMappingURL=workflow.service.js.map