@xyne/workflow-sdk 3.2.38 → 3.2.40

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 (212) hide show
  1. package/dist/builder/index.d.ts +3 -1
  2. package/dist/builder/index.d.ts.map +1 -1
  3. package/dist/builder/index.js +7 -1
  4. package/dist/builder/index.js.map +1 -1
  5. package/dist/client/types.d.ts +13 -0
  6. package/dist/client/types.d.ts.map +1 -1
  7. package/dist/client/workflow-client.d.ts.map +1 -1
  8. package/dist/client/workflow-client.js +8 -0
  9. package/dist/client/workflow-client.js.map +1 -1
  10. package/dist/common/citation-ref.d.ts +36 -36
  11. package/dist/connectors/builtin/builtin-connector.d.ts +1 -0
  12. package/dist/connectors/builtin/builtin-connector.d.ts.map +1 -1
  13. package/dist/connectors/builtin/builtin-connector.js +1 -0
  14. package/dist/connectors/builtin/builtin-connector.js.map +1 -1
  15. package/dist/connectors/builtin/steps/http-request.step.d.ts +2 -2
  16. package/dist/connectors/builtin/steps/switch.step.d.ts +12 -12
  17. package/dist/connectors/builtin/steps/wait.step.d.ts +90 -90
  18. package/dist/connectors/builtin/triggers/default-cron-trigger.d.ts +9 -1
  19. package/dist/connectors/builtin/triggers/default-cron-trigger.d.ts.map +1 -1
  20. package/dist/connectors/builtin/triggers/default-cron-trigger.js +3 -3
  21. package/dist/connectors/builtin/triggers/default-cron-trigger.js.map +1 -1
  22. package/dist/connectors/builtin/triggers/default-event-trigger.d.ts +12 -1
  23. package/dist/connectors/builtin/triggers/default-event-trigger.d.ts.map +1 -1
  24. package/dist/connectors/builtin/triggers/default-event-trigger.js +13 -1
  25. package/dist/connectors/builtin/triggers/default-event-trigger.js.map +1 -1
  26. package/dist/connectors/builtin/triggers/default-manual-trigger.d.ts +4 -1
  27. package/dist/connectors/builtin/triggers/default-manual-trigger.d.ts.map +1 -1
  28. package/dist/connectors/builtin/triggers/default-manual-trigger.js +3 -1
  29. package/dist/connectors/builtin/triggers/default-manual-trigger.js.map +1 -1
  30. package/dist/connectors/core/base-connector.d.ts +5 -0
  31. package/dist/connectors/core/base-connector.d.ts.map +1 -1
  32. package/dist/connectors/core/base-connector.js +5 -0
  33. package/dist/connectors/core/base-connector.js.map +1 -1
  34. package/dist/connectors/core/connector-registry.d.ts.map +1 -1
  35. package/dist/connectors/core/connector-registry.js +26 -12
  36. package/dist/connectors/core/connector-registry.js.map +1 -1
  37. package/dist/engine/condition-evaluator.d.ts.map +1 -1
  38. package/dist/engine/condition-evaluator.js +41 -5
  39. package/dist/engine/condition-evaluator.js.map +1 -1
  40. package/dist/engine/config-validator.d.ts +8 -0
  41. package/dist/engine/config-validator.d.ts.map +1 -1
  42. package/dist/engine/config-validator.js +61 -4
  43. package/dist/engine/config-validator.js.map +1 -1
  44. package/dist/engine/workflow-executor.d.ts.map +1 -1
  45. package/dist/engine/workflow-executor.js +5 -28
  46. package/dist/engine/workflow-executor.js.map +1 -1
  47. package/dist/index.d.ts +8 -4
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +3 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/ingest/start-policy.d.ts +202 -0
  52. package/dist/ingest/start-policy.d.ts.map +1 -0
  53. package/dist/ingest/start-policy.js +199 -0
  54. package/dist/ingest/start-policy.js.map +1 -0
  55. package/dist/ingest/types.d.ts +25 -6
  56. package/dist/ingest/types.d.ts.map +1 -1
  57. package/dist/persistence/in-memory-adapter.d.ts +4 -2
  58. package/dist/persistence/in-memory-adapter.d.ts.map +1 -1
  59. package/dist/persistence/in-memory-adapter.js +9 -1
  60. package/dist/persistence/in-memory-adapter.js.map +1 -1
  61. package/dist/persistence/types.d.ts +53 -1
  62. package/dist/persistence/types.d.ts.map +1 -1
  63. package/dist/router/types.d.ts +1 -0
  64. package/dist/router/types.d.ts.map +1 -1
  65. package/dist/router/workflow-router.d.ts.map +1 -1
  66. package/dist/router/workflow-router.js +57 -5
  67. package/dist/router/workflow-router.js.map +1 -1
  68. package/dist/runtime/types.d.ts +5 -0
  69. package/dist/runtime/types.d.ts.map +1 -1
  70. package/dist/runtime/workflow-runtime.d.ts +121 -7
  71. package/dist/runtime/workflow-runtime.d.ts.map +1 -1
  72. package/dist/runtime/workflow-runtime.js +430 -57
  73. package/dist/runtime/workflow-runtime.js.map +1 -1
  74. package/dist/schema/options.d.ts +101 -0
  75. package/dist/schema/options.d.ts.map +1 -0
  76. package/dist/schema/options.js +14 -0
  77. package/dist/schema/options.js.map +1 -0
  78. package/dist/steps/base-step.d.ts +12 -1
  79. package/dist/steps/base-step.d.ts.map +1 -1
  80. package/dist/steps/base-step.js +1 -0
  81. package/dist/steps/base-step.js.map +1 -1
  82. package/dist/steps/step-descriptor.d.ts +2 -0
  83. package/dist/steps/step-descriptor.d.ts.map +1 -1
  84. package/dist/steps/step-descriptor.js +1 -0
  85. package/dist/steps/step-descriptor.js.map +1 -1
  86. package/dist/triggers/api-trigger.d.ts +1 -1
  87. package/dist/triggers/api-trigger.d.ts.map +1 -1
  88. package/dist/triggers/api-trigger.js.map +1 -1
  89. package/dist/triggers/base-trigger.d.ts +67 -8
  90. package/dist/triggers/base-trigger.d.ts.map +1 -1
  91. package/dist/triggers/base-trigger.js +15 -3
  92. package/dist/triggers/base-trigger.js.map +1 -1
  93. package/dist/triggers/cron-trigger.d.ts +1 -1
  94. package/dist/triggers/cron-trigger.d.ts.map +1 -1
  95. package/dist/triggers/cron-trigger.js.map +1 -1
  96. package/dist/triggers/event-trigger.d.ts +3 -3
  97. package/dist/triggers/event-trigger.d.ts.map +1 -1
  98. package/dist/triggers/event-trigger.js +2 -2
  99. package/dist/triggers/event-trigger.js.map +1 -1
  100. package/dist/triggers/manual-trigger.d.ts +1 -1
  101. package/dist/triggers/manual-trigger.d.ts.map +1 -1
  102. package/dist/triggers/manual-trigger.js.map +1 -1
  103. package/dist/triggers/trigger-descriptor.d.ts +2 -0
  104. package/dist/triggers/trigger-descriptor.d.ts.map +1 -1
  105. package/dist/triggers/trigger-descriptor.js +1 -0
  106. package/dist/triggers/trigger-descriptor.js.map +1 -1
  107. package/dist/triggers/webhook-trigger.d.ts +25 -6
  108. package/dist/triggers/webhook-trigger.d.ts.map +1 -1
  109. package/dist/triggers/webhook-trigger.js +23 -2
  110. package/dist/triggers/webhook-trigger.js.map +1 -1
  111. package/dist/types/config-path.d.ts +24 -0
  112. package/dist/types/config-path.d.ts.map +1 -0
  113. package/dist/types/config-path.js +18 -0
  114. package/dist/types/config-path.js.map +1 -0
  115. package/dist/types/context.d.ts.map +1 -1
  116. package/dist/types/operators.d.ts +15 -1
  117. package/dist/types/operators.d.ts.map +1 -1
  118. package/dist/types/operators.js +21 -0
  119. package/dist/types/operators.js.map +1 -1
  120. package/dist/types/workflow-config.d.ts +13 -0
  121. package/dist/types/workflow-config.d.ts.map +1 -1
  122. package/dist/types/workflow-config.js +9 -7
  123. package/dist/types/workflow-config.js.map +1 -1
  124. package/dist/util/option-mark.d.ts +44 -0
  125. package/dist/util/option-mark.d.ts.map +1 -0
  126. package/dist/util/option-mark.js +132 -0
  127. package/dist/util/option-mark.js.map +1 -0
  128. package/dist/util/schema-convert.d.ts +5 -0
  129. package/dist/util/schema-convert.d.ts.map +1 -1
  130. package/dist/util/schema-convert.js +7 -1
  131. package/dist/util/schema-convert.js.map +1 -1
  132. package/dist/util/schema-marks.d.ts +58 -0
  133. package/dist/util/schema-marks.d.ts.map +1 -0
  134. package/dist/util/schema-marks.js +161 -0
  135. package/dist/util/schema-marks.js.map +1 -0
  136. package/dist/util/variable-ref.d.ts +0 -5
  137. package/dist/util/variable-ref.d.ts.map +1 -1
  138. package/dist/util/variable-ref.js +0 -5
  139. package/dist/util/variable-ref.js.map +1 -1
  140. package/package.json +1 -1
  141. package/dist/runtime/execution-event-bus.d.ts +0 -38
  142. package/dist/runtime/execution-event-bus.d.ts.map +0 -1
  143. package/dist/runtime/execution-event-bus.js +0 -87
  144. package/dist/runtime/execution-event-bus.js.map +0 -1
  145. package/dist/steps/builtin/code.step.d.ts +0 -99
  146. package/dist/steps/builtin/code.step.d.ts.map +0 -1
  147. package/dist/steps/builtin/code.step.js +0 -152
  148. package/dist/steps/builtin/code.step.js.map +0 -1
  149. package/dist/steps/builtin/conditional.step.d.ts +0 -182
  150. package/dist/steps/builtin/conditional.step.d.ts.map +0 -1
  151. package/dist/steps/builtin/conditional.step.js +0 -82
  152. package/dist/steps/builtin/conditional.step.js.map +0 -1
  153. package/dist/steps/builtin/dedup.step.d.ts +0 -65
  154. package/dist/steps/builtin/dedup.step.d.ts.map +0 -1
  155. package/dist/steps/builtin/dedup.step.js +0 -61
  156. package/dist/steps/builtin/dedup.step.js.map +0 -1
  157. package/dist/steps/builtin/http-request.step.d.ts +0 -1020
  158. package/dist/steps/builtin/http-request.step.d.ts.map +0 -1
  159. package/dist/steps/builtin/http-request.step.js +0 -491
  160. package/dist/steps/builtin/http-request.step.js.map +0 -1
  161. package/dist/steps/builtin/loop.step.d.ts +0 -100
  162. package/dist/steps/builtin/loop.step.d.ts.map +0 -1
  163. package/dist/steps/builtin/loop.step.js +0 -73
  164. package/dist/steps/builtin/loop.step.js.map +0 -1
  165. package/dist/steps/builtin/map.step.d.ts +0 -148
  166. package/dist/steps/builtin/map.step.d.ts.map +0 -1
  167. package/dist/steps/builtin/map.step.js +0 -111
  168. package/dist/steps/builtin/map.step.js.map +0 -1
  169. package/dist/steps/builtin/parallel.step.d.ts +0 -246
  170. package/dist/steps/builtin/parallel.step.d.ts.map +0 -1
  171. package/dist/steps/builtin/parallel.step.js +0 -175
  172. package/dist/steps/builtin/parallel.step.js.map +0 -1
  173. package/dist/steps/builtin/ssrf-guard.d.ts +0 -9
  174. package/dist/steps/builtin/ssrf-guard.d.ts.map +0 -1
  175. package/dist/steps/builtin/ssrf-guard.js +0 -115
  176. package/dist/steps/builtin/ssrf-guard.js.map +0 -1
  177. package/dist/steps/builtin/switch.step.d.ts +0 -235
  178. package/dist/steps/builtin/switch.step.d.ts.map +0 -1
  179. package/dist/steps/builtin/switch.step.js +0 -101
  180. package/dist/steps/builtin/switch.step.js.map +0 -1
  181. package/dist/steps/builtin/wait.step.d.ts +0 -1072
  182. package/dist/steps/builtin/wait.step.d.ts.map +0 -1
  183. package/dist/steps/builtin/wait.step.js +0 -254
  184. package/dist/steps/builtin/wait.step.js.map +0 -1
  185. package/dist/steps/step-registry.d.ts +0 -62
  186. package/dist/steps/step-registry.d.ts.map +0 -1
  187. package/dist/steps/step-registry.js +0 -103
  188. package/dist/steps/step-registry.js.map +0 -1
  189. package/dist/triggers/builtin/default-cron-trigger.d.ts +0 -44
  190. package/dist/triggers/builtin/default-cron-trigger.d.ts.map +0 -1
  191. package/dist/triggers/builtin/default-cron-trigger.js +0 -31
  192. package/dist/triggers/builtin/default-cron-trigger.js.map +0 -1
  193. package/dist/triggers/builtin/default-event-trigger.d.ts +0 -32
  194. package/dist/triggers/builtin/default-event-trigger.d.ts.map +0 -1
  195. package/dist/triggers/builtin/default-event-trigger.js +0 -21
  196. package/dist/triggers/builtin/default-event-trigger.js.map +0 -1
  197. package/dist/triggers/builtin/default-manual-trigger.d.ts +0 -45
  198. package/dist/triggers/builtin/default-manual-trigger.d.ts.map +0 -1
  199. package/dist/triggers/builtin/default-manual-trigger.js +0 -51
  200. package/dist/triggers/builtin/default-manual-trigger.js.map +0 -1
  201. package/dist/triggers/builtin/default-webhook-trigger.d.ts +0 -72
  202. package/dist/triggers/builtin/default-webhook-trigger.d.ts.map +0 -1
  203. package/dist/triggers/builtin/default-webhook-trigger.js +0 -91
  204. package/dist/triggers/builtin/default-webhook-trigger.js.map +0 -1
  205. package/dist/triggers/builtin/default-webhook-v2-trigger.d.ts +0 -29
  206. package/dist/triggers/builtin/default-webhook-v2-trigger.d.ts.map +0 -1
  207. package/dist/triggers/builtin/default-webhook-v2-trigger.js +0 -38
  208. package/dist/triggers/builtin/default-webhook-v2-trigger.js.map +0 -1
  209. package/dist/triggers/trigger-registry.d.ts +0 -48
  210. package/dist/triggers/trigger-registry.d.ts.map +0 -1
  211. package/dist/triggers/trigger-registry.js +0 -83
  212. package/dist/triggers/trigger-registry.js.map +0 -1
@@ -23,10 +23,72 @@ import { CredentialResolver } from '../engine/credential-resolver.js';
23
23
  import { headerValue } from '../ingest/headers.js';
24
24
  import { parseInboundBody } from '../ingest/body.js';
25
25
  import { matchesTriggerFilter } from '../ingest/filter.js';
26
+ import { isDelayedStart, readAnchor, resolveStartAt, startNeedsPayload, startSkipReason, } from '../ingest/start-policy.js';
26
27
  import { CronTrigger } from '../triggers/cron-trigger.js';
27
28
  import { PauseStep } from '../engine/pause-step.js';
28
29
  import { leafStepId } from '../engine/node-path.js';
29
- // ─── Helpers ───
30
+ /** Why an event was not admitted, for logs and for the error a manual run gets. */
31
+ function reasonOf(decision) {
32
+ if (decision.kind === 'gone')
33
+ return `the subject is gone (${decision.reason})`;
34
+ switch (decision.by) {
35
+ case 'scope':
36
+ return 'this workflow does not watch that event';
37
+ case 'conditions':
38
+ return "the workflow's conditions did not match";
39
+ }
40
+ }
41
+ /**
42
+ * How early a wake-up is tolerated before the run re-arms itself.
43
+ *
44
+ * A delayed job can fire a little early (clock skew between the queue and this
45
+ * process), and a host sweeper re-enqueueing a run whose job was lost can be
46
+ * eager. Neither should admit an event before its moment, because admission is
47
+ * what reads the world.
48
+ */
49
+ const EARLY_WAKE_TOLERANCE_MS = 1_000;
50
+ /**
51
+ * The fields whose values are actually known while editing.
52
+ *
53
+ * A sibling field may hold `{{trigger.projectId}}` — a promise about run time,
54
+ * not an id. A loader filtering by it would search for the literal text and
55
+ * quietly find nothing, so references are dropped here and offered separately as
56
+ * `config` for the rare loader that wants the raw text.
57
+ */
58
+ function literalValues(config) {
59
+ const out = {};
60
+ for (const [key, value] of Object.entries(config)) {
61
+ if (isPlainObject(value)) {
62
+ // Follow the form as deep as a field path can go: `repository.id` is a
63
+ // usable filter even when its neighbour `repository.name` is a reference.
64
+ out[key] = literalValues(value);
65
+ continue;
66
+ }
67
+ if (holdsRef(value))
68
+ continue;
69
+ out[key] = value;
70
+ }
71
+ return out;
72
+ }
73
+ function isPlainObject(value) {
74
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
75
+ }
76
+ /**
77
+ * Whether a value is waiting on run time anywhere inside it.
78
+ *
79
+ * An array is all-or-nothing: dropping the one element that holds a reference
80
+ * would hand the loader a shorter list and no sign that it is short, which reads
81
+ * as a narrower filter rather than an unanswerable one.
82
+ */
83
+ function holdsRef(value) {
84
+ if (typeof value === 'string')
85
+ return value.includes('{{');
86
+ if (Array.isArray(value))
87
+ return value.some(holdsRef);
88
+ if (isPlainObject(value))
89
+ return Object.values(value).some(holdsRef);
90
+ return false;
91
+ }
30
92
  function messageOf(err) {
31
93
  return err instanceof Error ? err.message : String(err);
32
94
  }
@@ -375,14 +437,24 @@ export class WorkflowRuntime {
375
437
  }
376
438
  // ─── Trigger Firing ───
377
439
  /**
378
- * Dispatch a domain event. Finds all active workflows that match,
379
- * hydrates payloads, applies filters, and enqueues executions.
440
+ * Dispatch a domain event to every active workflow watching for it.
441
+ *
442
+ * Each candidate goes through the same admission as any other entry path —
443
+ * `accepts`, then {@link admit} — so an event workflow, a webhook workflow and
444
+ * a manual run all decide the same way. Hydration is shared across candidates:
445
+ * fifty workflows on one ticket update cost one read, not fifty.
380
446
  *
381
447
  * Returns the execution IDs that were created.
382
448
  */
383
449
  async dispatchEvent(event) {
384
450
  const workflows = await this.persistence.findActiveWorkflows(event.type, event.metadata);
385
451
  const executionIds = [];
452
+ const hydrations = new Map();
453
+ const triggerEvent = {
454
+ type: event.type,
455
+ payload: event.payload,
456
+ ...(event.occurredAt !== undefined ? { occurredAt: event.occurredAt } : {}),
457
+ };
386
458
  for (const workflow of workflows) {
387
459
  const config = parseWorkflowConfig(workflow.config);
388
460
  if (!this.connectors.hasTrigger(config.trigger.type))
@@ -390,30 +462,12 @@ export class WorkflowRuntime {
390
462
  const trigger = this.connectors.getTrigger(config.trigger.type);
391
463
  if (!(trigger instanceof EventTrigger))
392
464
  continue;
393
- // Hydrate lightweight payload full context
394
- let payload = { ...event.payload };
395
- if (trigger.hydratePayload) {
396
- try {
397
- payload = await trigger.hydratePayload(payload);
398
- }
399
- catch (err) {
400
- this.log.warn(`dispatchEvent: hydratePayload failed for workflow ${workflow.id}: ${err instanceof Error ? err.message : String(err)}`);
401
- continue;
402
- }
403
- }
404
- // Filter — does this event match this workflow's trigger config?
405
- if (!trigger.matchFilters(config.trigger.config, payload)) {
406
- continue;
407
- }
408
- // And the author's filter, before a run exists.
409
- if (!matchesTriggerFilter(config.trigger.filter, payload, config.variables)) {
465
+ const armed = await this.arm(workflow, config, triggerEvent, { hydrations });
466
+ if (armed.kind === 'dropped') {
467
+ this.log.info(`dispatchEvent: ${event.type} not admitted by workflow ${workflow.id} — ${armed.reason}`);
410
468
  continue;
411
469
  }
412
- const execId = await this.createAndEnqueue(workflow, {
413
- type: event.type,
414
- ...payload,
415
- });
416
- executionIds.push(execId);
470
+ executionIds.push(armed.executionId);
417
471
  }
418
472
  return executionIds;
419
473
  }
@@ -426,11 +480,15 @@ export class WorkflowRuntime {
426
480
  * 1. The workflow exists, is ACTIVE and uses a webhook trigger — else 404, the
427
481
  * same answer for each so workflow ids cannot be probed.
428
482
  * 2. The trigger's credentials resolve — else 401, before any connector code runs.
429
- * 3. The trigger's own `verify` accepts the request else 401.
430
- * 4. The body is parsed from the raw bytes else 400.
431
- * 5. The trigger turns the request into events and a reply.
432
- * 6. Each event must pass the workflow's trigger filter, or it is dropped.
433
- * 7. Survivors are enqueued before the reply is returned.
483
+ * 3. The trigger's `handshake` may answer the request outright. It is the one
484
+ * connector hook that sees an unauthenticated request, so it can only reply,
485
+ * never start a run. A GET it does not answer gets 404: GET reaches nothing else.
486
+ * 4. The trigger's own `verify` accepts the request else 401.
487
+ * 5. The body is parsed from the raw bytes — else 400.
488
+ * 6. The trigger turns the request into events and a reply.
489
+ * 7. Each event is admitted the same way every other entry path is — `accepts`,
490
+ * then hydration, then the trigger's own filter and the author's — or dropped.
491
+ * 8. Survivors are enqueued before the reply is returned.
434
492
  *
435
493
  * Reasons for a rejection go to the log; responses stay generic.
436
494
  */
@@ -462,6 +520,18 @@ export class WorkflowRuntime {
462
520
  this.log.warn(`webhook ${workflowId}: rejected, ${messageOf(err)}`);
463
521
  return unauthorized;
464
522
  }
523
+ let handshakeReply;
524
+ try {
525
+ handshakeReply = await trigger.handshake(request, triggerConfig, ctx);
526
+ }
527
+ catch (err) {
528
+ // A broken handshake must not block signed deliveries: carry on to verification.
529
+ this.log.warn(`webhook ${workflowId}: handshake threw: ${messageOf(err)}`);
530
+ }
531
+ if (handshakeReply)
532
+ return { reply: handshakeReply, executionIds: [] };
533
+ if (request.method !== 'POST')
534
+ return notFound;
465
535
  let verified = false;
466
536
  try {
467
537
  verified = await trigger.verify(request, triggerConfig, ctx);
@@ -484,9 +554,12 @@ export class WorkflowRuntime {
484
554
  const translation = await trigger.handleRequest({ ...request, body }, triggerConfig, ctx);
485
555
  const executionIds = [];
486
556
  for (const event of translation.events) {
487
- if (!matchesTriggerFilter(config.trigger.filter, event.payload, config.variables))
557
+ const armed = await this.arm(workflow, config, event, { triggerCtx: ctx });
558
+ if (armed.kind === 'dropped') {
559
+ this.log.info(`webhook ${workflowId}: ${event.type} not admitted — ${armed.reason}`);
488
560
  continue;
489
- executionIds.push(await this.createAndEnqueue(workflow, { type: event.type, ...event.payload }, { publicTrigger: true }));
561
+ }
562
+ executionIds.push(armed.executionId);
490
563
  }
491
564
  return {
492
565
  reply: translation.reply ?? { status: 200, body: { ok: true } },
@@ -564,11 +637,11 @@ export class WorkflowRuntime {
564
637
  if (errors.length > 0) {
565
638
  throw new Error(`Invalid trigger payload: ${errors.map((e) => e.message).join('; ')}`);
566
639
  }
567
- const triggerPayload = {
568
- type: config.trigger.type,
569
- ...payload,
570
- };
571
- return this.createAndEnqueue(workflow, triggerPayload);
640
+ const armed = await this.arm(workflow, config, { type: config.trigger.type, payload });
641
+ if (armed.kind === 'dropped') {
642
+ throw new Error(`Workflow did not accept this run: ${armed.reason}`);
643
+ }
644
+ return armed.executionId;
572
645
  }
573
646
  /**
574
647
  * Start a workflow from the API trigger route.
@@ -595,7 +668,11 @@ export class WorkflowRuntime {
595
668
  if (errors.length > 0) {
596
669
  throw new Error(`Invalid trigger payload: ${errors.map((e) => e.message).join('; ')}`);
597
670
  }
598
- return this.createAndEnqueue(workflow, { type: config.trigger.type, ...payload }, { publicTrigger: true });
671
+ const armed = await this.arm(workflow, config, { type: config.trigger.type, payload });
672
+ if (armed.kind === 'dropped') {
673
+ throw new Error(`Workflow did not accept this run: ${armed.reason}`);
674
+ }
675
+ return armed.executionId;
599
676
  }
600
677
  /**
601
678
  * What the API trigger route needs before it builds a payload: the trigger's
@@ -1162,7 +1239,13 @@ export class WorkflowRuntime {
1162
1239
  const exec = await this.persistence.getExecution(executionId);
1163
1240
  if (!exec)
1164
1241
  return { status: 'not_found' };
1165
- if (!['PENDING', 'EXTERNAL_WAIT'].includes(exec.status)) {
1242
+ if (exec.status === 'SCHEDULED') {
1243
+ const activation = await this.activate(exec);
1244
+ if (activation.kind !== 'started') {
1245
+ return { status: 'skipped', reason: activation.reason };
1246
+ }
1247
+ }
1248
+ else if (!['PENDING', 'EXTERNAL_WAIT'].includes(exec.status)) {
1166
1249
  return {
1167
1250
  status: 'skipped',
1168
1251
  reason: `execution status is ${exec.status}`,
@@ -1251,30 +1334,298 @@ export class WorkflowRuntime {
1251
1334
  });
1252
1335
  if (data === null)
1253
1336
  return null; // nothing new, skip
1254
- return this.createAndEnqueue(workflow, {
1255
- type: config.trigger.type,
1256
- ...data,
1257
- });
1337
+ return this.admitAndEnqueue(workflow, config, data);
1258
1338
  }
1259
1339
  // Pure cron — always fire
1260
1340
  const payload = trigger.buildPayload
1261
1341
  ? trigger.buildPayload(config.trigger.config)
1262
1342
  : { firedAt: new Date().toISOString() };
1263
- return this.createAndEnqueue(workflow, {
1343
+ return this.admitAndEnqueue(workflow, config, payload);
1344
+ }
1345
+ /** A cron tick's payload, through the same admission as every other event. */
1346
+ async admitAndEnqueue(workflow, config, payload) {
1347
+ const event = {
1264
1348
  type: config.trigger.type,
1265
- ...payload,
1266
- });
1349
+ payload,
1350
+ occurredAt: new Date().toISOString(),
1351
+ };
1352
+ const armed = await this.arm(workflow, config, event);
1353
+ if (armed.kind === 'dropped') {
1354
+ this.log.info(`cron tick for workflow ${workflow.id} not admitted — ${armed.reason}`);
1355
+ return null;
1356
+ }
1357
+ return armed.executionId;
1358
+ }
1359
+ /**
1360
+ * Choices for one config field, from the step or trigger that owns it.
1361
+ *
1362
+ * Authoring, not running: this answers a person editing a form, so it runs as
1363
+ * THEM. The tenant comes from the workflow being edited, or from the folder a
1364
+ * new one is being created in — either way from a record the caller has just
1365
+ * been authorized against, never from the request body.
1366
+ */
1367
+ async getFieldOptions(ctx, input) {
1368
+ const attributes = await this.optionsScope(ctx, input);
1369
+ const component = input.kind === 'step'
1370
+ ? this.connectors.hasStep(input.type)
1371
+ ? this.connectors.getStep(input.type)
1372
+ : null
1373
+ : this.connectors.hasTrigger(input.type)
1374
+ ? this.connectors.getTrigger(input.type)
1375
+ : null;
1376
+ if (!component)
1377
+ throw new ResourceNotFoundError(input.kind, input.type);
1378
+ const getOptions = component.getOptions?.bind(component);
1379
+ // Not every field is a picker, and a component need not offer any. Nothing
1380
+ // to say is an empty page, not an error — the editor falls back to a text box.
1381
+ if (!getOptions)
1382
+ return { items: [] };
1383
+ const config = input.config ?? {};
1384
+ // A half-filled form is the NORMAL state here — the author may not have
1385
+ // chosen a connection yet, and that is a question the loader answers with a
1386
+ // notice ("Choose a connection first"), not a failure the editor should
1387
+ // raise. Hand over what resolved; let the component say what it needs.
1388
+ let credentials = {};
1389
+ try {
1390
+ credentials = await this.credentialResolver.resolve(attributes, component, config);
1391
+ }
1392
+ catch (err) {
1393
+ this.log.info(`field options for ${input.kind} "${input.type}": no usable credential yet — ${messageOf(err)}`);
1394
+ }
1395
+ const optionsCtx = {
1396
+ field: input.field,
1397
+ config,
1398
+ literal: literalValues(config),
1399
+ ...(input.search !== undefined ? { search: input.search } : {}),
1400
+ ...(input.cursor !== undefined ? { cursor: input.cursor } : {}),
1401
+ ...(input.values !== undefined ? { values: input.values } : {}),
1402
+ credentials,
1403
+ attributes,
1404
+ services: this.services,
1405
+ caller: ctx,
1406
+ ...(input.signal !== undefined ? { signal: input.signal } : {}),
1407
+ };
1408
+ return getOptions(optionsCtx);
1409
+ }
1410
+ /**
1411
+ * Whose data the picker may read.
1412
+ *
1413
+ * This lookup is not really a capability check — it is how the TENANT is
1414
+ * derived. `attributes` decides which workspace's credential the loader runs
1415
+ * with, and the only trustworthy source for that is a record the caller has
1416
+ * already been authorized against. Take it from the request body instead and
1417
+ * anyone can ask one workspace's connector to list another's channels. That is
1418
+ * why there is no ungated version of this.
1419
+ *
1420
+ * So the action asked for is the weakest that proves the caller belongs to the
1421
+ * tenant: reading the workflow in front of them, or being allowed to create one
1422
+ * in the folder they are working in. Browsing and naming stored ids share it
1423
+ * deliberately — `workflow:update` locked a read-only viewer out of resolving
1424
+ * labels, so they saw raw ids where the author saw names, and splitting the two
1425
+ * only moved the problem: the weaker half would then leak the stronger half back
1426
+ * by letting a reader name ids it guessed.
1427
+ *
1428
+ * What a picker then SHOWS is settled by the credential, not by a second
1429
+ * per-person rule: whoever may read the workflow sees what that workflow's
1430
+ * connection can reach. That is deliberate — the credential belongs to the
1431
+ * workspace, and narrowing it per viewer would be a policy the connector
1432
+ * cannot express and the tenant did not ask for.
1433
+ */
1434
+ async optionsScope(ctx, input) {
1435
+ if (input.workflowId !== undefined) {
1436
+ const { record } = await this.authorizedWorkflow(ctx, input.workflowId, 'workflow:read');
1437
+ return record.attributes;
1438
+ }
1439
+ if (input.folderId !== undefined) {
1440
+ const { record } = await this.authorizedFolder(ctx, input.folderId, 'workflow:create');
1441
+ return record.attributes;
1442
+ }
1443
+ throw new Error('Field options need the workflow being edited, or the folder for a new one');
1267
1444
  }
1268
1445
  // ─── Internal ───
1269
- async createAndEnqueue(workflow, triggerPayload, opts) {
1446
+ /**
1447
+ * Arm one event for one workflow: start a run now, or park one until its moment.
1448
+ *
1449
+ * The binding's start policy decides which. An immediate binding is admitted
1450
+ * here and now. A delayed one is armed on SCOPE alone — `accepts` — and stores
1451
+ * the raw event; the conditions that decide whether it acts are judged when it
1452
+ * fires, by {@link activate}. That is deliberate: a rule like "escalate if still
1453
+ * unassigned after two hours" must arm even when the ticket is assigned right
1454
+ * now, or it can never fire.
1455
+ */
1456
+ async arm(workflow, config, event, opts) {
1457
+ const start = config.trigger.start;
1458
+ if (!isDelayedStart(start))
1459
+ return this.startNow(workflow, config, event, opts);
1460
+ const trigger = this.connectors.getTrigger(config.trigger.type);
1461
+ if (!trigger.accepts(event, config.trigger.config)) {
1462
+ return { kind: 'dropped', reason: reasonOf({ kind: 'rejected', by: 'scope' }) };
1463
+ }
1464
+ // Only an anchor that lives in the payload costs a read here — "two hours
1465
+ // after the event" needs none. What it reads is used for the TIME alone and
1466
+ // then dropped: the run is armed from the raw event and enriched again when
1467
+ // it fires, which is what makes the wait worth anything.
1468
+ let anchorPayload = { ...event.payload };
1469
+ const hydrate = startNeedsPayload(start) ? trigger.hydrate?.bind(trigger) : undefined;
1470
+ if (hydrate) {
1471
+ const hydrated = await this.hydrateFor(workflow, trigger, hydrate, event, config.trigger.config, opts);
1472
+ if (hydrated.kind === 'gone') {
1473
+ return { kind: 'dropped', reason: reasonOf({ kind: 'gone', reason: hydrated.reason }) };
1474
+ }
1475
+ anchorPayload = hydrated.payload;
1476
+ }
1477
+ const anchorValue = start?.kind === 'at' ? readAnchor(start.anchor, anchorPayload, config.variables) : undefined;
1478
+ const when = resolveStartAt(start, event, anchorValue, new Date());
1479
+ if (when.kind === 'skip')
1480
+ return { kind: 'dropped', reason: startSkipReason(when.reason) };
1481
+ if (when.kind === 'now')
1482
+ return this.startNow(workflow, config, event, opts);
1483
+ const executionId = await this.createAndEnqueue(workflow, {}, { scheduled: { when, event } });
1484
+ return { kind: 'scheduled', executionId, fireAt: when.at };
1485
+ }
1486
+ /** Admit now, and enqueue the run if the event survives. */
1487
+ async startNow(workflow, config, event, opts) {
1488
+ const decision = await this.admit(workflow, config, event, opts);
1489
+ if (decision.kind !== 'ok')
1490
+ return { kind: 'dropped', reason: reasonOf(decision) };
1491
+ const executionId = await this.createAndEnqueue(workflow, {
1492
+ type: event.type,
1493
+ ...decision.payload,
1494
+ });
1495
+ return { kind: 'started', executionId };
1496
+ }
1497
+ /**
1498
+ * A scheduled run's moment has come.
1499
+ *
1500
+ * Everything that could have changed while it waited is re-read here: the
1501
+ * workflow definition (it may have been switched off or edited), and the event's
1502
+ * subject (it may have moved on, or stopped existing). The run is then admitted
1503
+ * exactly as an arriving event would be — same `admit`, same filters — which is
1504
+ * what makes "in two hours" behave like "now".
1505
+ */
1506
+ async activate(exec) {
1507
+ const origin = exec.origin;
1508
+ if (!origin) {
1509
+ // A scheduled run is admitted from the event on its record. Without one
1510
+ // there is nothing to admit, and nothing worth walking.
1511
+ return this.endScheduled(exec.id, 'CANCELLED', 'the scheduled run has no stored event');
1512
+ }
1513
+ const workflow = await this.persistence.getWorkflow(exec.workflowId);
1514
+ if (!workflow || workflow.status !== 'ACTIVE') {
1515
+ return this.endScheduled(exec.id, 'CANCELLED', 'the workflow is no longer active');
1516
+ }
1517
+ const fireAt = exec.fireAt?.getTime() ?? Number.NaN;
1518
+ if (!Number.isNaN(fireAt) && fireAt - Date.now() > EARLY_WAKE_TOLERANCE_MS) {
1519
+ // Woken early — re-arm rather than read the world before the moment.
1520
+ await this.queue.enqueue({
1521
+ executionId: exec.id,
1522
+ delay: fireAt - Date.now(),
1523
+ singletonKey: exec.id,
1524
+ });
1525
+ return { kind: 'deferred', reason: `not due until ${exec.fireAt?.toISOString() ?? 'its fire time'}` };
1526
+ }
1527
+ const config = parseWorkflowConfig(workflow.config);
1528
+ if (!this.connectors.hasTrigger(config.trigger.type)) {
1529
+ return this.endScheduled(exec.id, 'CANCELLED', `trigger "${config.trigger.type}" is no longer registered`);
1530
+ }
1531
+ const decision = await this.admit(workflow, config, origin.event);
1532
+ if (decision.kind !== 'ok') {
1533
+ return this.endScheduled(exec.id, decision.kind === 'gone' ? 'CANCELLED' : 'SKIPPED', reasonOf(decision));
1534
+ }
1535
+ // The context is built HERE, not when the run was armed: a run that has not
1536
+ // started has nothing to read, and `context.trigger` should only ever mean
1537
+ // the payload the run actually acts on.
1538
+ const context = this.initialContext(workflow, {
1539
+ type: origin.event.type,
1540
+ ...decision.payload,
1541
+ });
1542
+ await this.persistence.persistState(exec.id, {
1543
+ context: JSON.stringify(context),
1544
+ attributes: context.workflow.attributes,
1545
+ });
1546
+ await this.persistence.updateExecutionStatus(exec.id, 'PENDING');
1547
+ this.log.info(`execution ${exec.id} admitted at its scheduled time`);
1548
+ return { kind: 'started' };
1549
+ }
1550
+ /** End a scheduled run that will never act, saying why on the run itself. */
1551
+ async endScheduled(executionId, status, reason) {
1552
+ await this.persistence.updateExecutionStatus(executionId, status, reason);
1553
+ this.log.info(`scheduled execution ${executionId} ended as ${status}: ${reason}`);
1554
+ return { kind: 'ended', status, reason };
1555
+ }
1556
+ /**
1557
+ * Admit one event to one workflow — the single place that decides whether an
1558
+ * event deserves a run, and with what data.
1559
+ *
1560
+ * Every entry path goes through here: a domain dispatch, a webhook delivery, a
1561
+ * cron tick, a manual run. That is the point. Filtering used to happen at two
1562
+ * points on one path, one point on another and nowhere at all on the rest, so
1563
+ * the same author-written filter meant different things depending on how the
1564
+ * run started.
1565
+ *
1566
+ * In order: `accepts` prunes on the raw event — the trigger's one say, and it
1567
+ * is about what arrived — then `hydrate` resolves ids into objects once, and the
1568
+ * author's conditions judge the enriched payload. What this returns is frozen
1569
+ * into `context.trigger` and never re-read: a step that needs the world as it is
1570
+ * later reads it itself.
1571
+ */
1572
+ async admit(workflow, config, event, opts) {
1573
+ const trigger = this.connectors.getTrigger(config.trigger.type);
1574
+ const triggerConfig = config.trigger.config;
1575
+ if (!trigger.accepts(event, triggerConfig))
1576
+ return { kind: 'rejected', by: 'scope' };
1577
+ let payload = { ...event.payload };
1578
+ const hydrate = trigger.hydrate?.bind(trigger);
1579
+ if (hydrate) {
1580
+ const hydrated = await this.hydrateFor(workflow, trigger, hydrate, event, triggerConfig, opts);
1581
+ if (hydrated.kind === 'gone')
1582
+ return hydrated;
1583
+ payload = hydrated.payload;
1584
+ }
1585
+ if (!matchesTriggerFilter(config.trigger.filter, payload, config.variables)) {
1586
+ return { kind: 'rejected', by: 'conditions' };
1587
+ }
1588
+ return { kind: 'ok', payload };
1589
+ }
1590
+ /** One hydration, shared by key across the candidates of a single dispatch. */
1591
+ async hydrateFor(workflow, trigger, hydrate, event, triggerConfig, opts) {
1592
+ const key = trigger.hydrationKey?.(event);
1593
+ const memoKey = key === undefined ? undefined : `${trigger.type}:${key}`;
1594
+ const cached = memoKey === undefined ? undefined : opts?.hydrations?.get(memoKey);
1595
+ if (cached)
1596
+ return cached;
1597
+ let result;
1598
+ try {
1599
+ const ctx = opts?.triggerCtx ?? (await this.triggerCtxFor(workflow, trigger, triggerConfig));
1600
+ result = await hydrate(event, triggerConfig, ctx);
1601
+ }
1602
+ catch (err) {
1603
+ // Enrichment failing is no licence to run on bare ids — drop the event and
1604
+ // say why, the same outcome as a subject that no longer exists.
1605
+ this.log.warn(`hydrate failed for workflow ${workflow.id}: ${messageOf(err)}`);
1606
+ result = { kind: 'gone', reason: `hydration failed: ${messageOf(err)}` };
1607
+ }
1608
+ if (memoKey !== undefined)
1609
+ opts?.hydrations?.set(memoKey, result);
1610
+ return result;
1611
+ }
1612
+ /** Credentials and tenant for a trigger's server-side hooks. */
1613
+ async triggerCtxFor(workflow, trigger, triggerConfig) {
1614
+ const attributes = workflow.attributes;
1615
+ const credentials = await this.credentialResolver.resolve(attributes, trigger, triggerConfig);
1616
+ return { credentials, attributes, baseUrl: this.baseUrl };
1617
+ }
1618
+ /**
1619
+ * The context a run starts with — its identity, its tenant, the author's
1620
+ * constants, and the trigger payload it acts on.
1621
+ *
1622
+ * Built in one place because it is built at two moments: when an immediate run
1623
+ * is created, and when a delayed one is admitted. Those must produce the same
1624
+ * shape, or a rule would behave differently for having waited.
1625
+ */
1626
+ initialContext(workflow, triggerPayload) {
1270
1627
  const metadata = JSON.parse(workflow.metadata ?? '{}');
1271
- // Mark executions started by an UNAUTHENTICATED public trigger (webhook) so
1272
- // privileged steps (e.g. SEND_EMAIL, code execution) can refuse to run for
1273
- // them. Set server-side on a per-execution copy of the metadata — never
1274
- // sourced from attacker-controlled trigger data.
1275
- if (opts?.publicTrigger)
1276
- metadata['publicTrigger'] = true;
1277
- const initialContext = {
1628
+ return {
1278
1629
  workflow: {
1279
1630
  id: workflow.id,
1280
1631
  // The execution's tenant, carried by the resource. The executor
@@ -1287,12 +1638,34 @@ export class WorkflowRuntime {
1287
1638
  steps: {},
1288
1639
  vars: projectVariables(parseWorkflowConfig(workflow.config).variables),
1289
1640
  };
1641
+ }
1642
+ async createAndEnqueue(workflow, triggerPayload, opts) {
1643
+ const scheduled = opts?.scheduled;
1644
+ const attributes = workflow.attributes;
1290
1645
  const execId = await this.persistence.createExecution({
1291
1646
  workflowId: workflow.id,
1292
- status: 'PENDING',
1293
- context: JSON.stringify(initialContext),
1294
- attributes: initialContext.workflow.attributes,
1647
+ status: scheduled ? 'SCHEDULED' : 'PENDING',
1648
+ context: scheduled ? '{}' : JSON.stringify(this.initialContext(workflow, triggerPayload)),
1649
+ attributes,
1650
+ ...(scheduled
1651
+ ? {
1652
+ fireAt: scheduled.when.at,
1653
+ origin: { event: scheduled.event, anchor: scheduled.when.anchor },
1654
+ }
1655
+ : {}),
1295
1656
  });
1657
+ if (scheduled) {
1658
+ // The delay is the fast path; `fireAt` on the row is the truth. A host that
1659
+ // sweeps for overdue runs makes a lost job late rather than lost.
1660
+ await this.queue.enqueue({
1661
+ executionId: execId,
1662
+ delay: Math.max(0, scheduled.when.at.getTime() - Date.now()),
1663
+ singletonKey: execId,
1664
+ });
1665
+ this.log.info(`scheduled execution ${execId} for workflow ${workflow.id} at ` +
1666
+ `${scheduled.when.at.toISOString()} (anchor: ${scheduled.when.anchor.source})`);
1667
+ return execId;
1668
+ }
1296
1669
  await this.queue.enqueue({ executionId: execId });
1297
1670
  this.log.info(`created execution ${execId} for workflow ${workflow.id}`);
1298
1671
  return execId;