@xyne/workflow-sdk 3.2.39 → 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 (122) 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/steps/http-request.step.d.ts +2 -2
  12. package/dist/connectors/builtin/steps/switch.step.d.ts +12 -12
  13. package/dist/connectors/builtin/steps/wait.step.d.ts +90 -90
  14. package/dist/connectors/builtin/triggers/default-cron-trigger.d.ts +9 -1
  15. package/dist/connectors/builtin/triggers/default-cron-trigger.d.ts.map +1 -1
  16. package/dist/connectors/builtin/triggers/default-cron-trigger.js +3 -3
  17. package/dist/connectors/builtin/triggers/default-cron-trigger.js.map +1 -1
  18. package/dist/connectors/builtin/triggers/default-event-trigger.d.ts +12 -1
  19. package/dist/connectors/builtin/triggers/default-event-trigger.d.ts.map +1 -1
  20. package/dist/connectors/builtin/triggers/default-event-trigger.js +13 -1
  21. package/dist/connectors/builtin/triggers/default-event-trigger.js.map +1 -1
  22. package/dist/connectors/builtin/triggers/default-manual-trigger.d.ts +4 -1
  23. package/dist/connectors/builtin/triggers/default-manual-trigger.d.ts.map +1 -1
  24. package/dist/connectors/builtin/triggers/default-manual-trigger.js +3 -1
  25. package/dist/connectors/builtin/triggers/default-manual-trigger.js.map +1 -1
  26. package/dist/connectors/core/connector-registry.d.ts.map +1 -1
  27. package/dist/connectors/core/connector-registry.js +19 -1
  28. package/dist/connectors/core/connector-registry.js.map +1 -1
  29. package/dist/engine/condition-evaluator.d.ts.map +1 -1
  30. package/dist/engine/condition-evaluator.js +41 -5
  31. package/dist/engine/condition-evaluator.js.map +1 -1
  32. package/dist/engine/config-validator.d.ts +8 -0
  33. package/dist/engine/config-validator.d.ts.map +1 -1
  34. package/dist/engine/config-validator.js +61 -4
  35. package/dist/engine/config-validator.js.map +1 -1
  36. package/dist/engine/workflow-executor.d.ts.map +1 -1
  37. package/dist/engine/workflow-executor.js +5 -28
  38. package/dist/engine/workflow-executor.js.map +1 -1
  39. package/dist/index.d.ts +8 -4
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +3 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/ingest/start-policy.d.ts +202 -0
  44. package/dist/ingest/start-policy.d.ts.map +1 -0
  45. package/dist/ingest/start-policy.js +199 -0
  46. package/dist/ingest/start-policy.js.map +1 -0
  47. package/dist/ingest/types.d.ts +25 -6
  48. package/dist/ingest/types.d.ts.map +1 -1
  49. package/dist/persistence/in-memory-adapter.d.ts +4 -2
  50. package/dist/persistence/in-memory-adapter.d.ts.map +1 -1
  51. package/dist/persistence/in-memory-adapter.js +9 -1
  52. package/dist/persistence/in-memory-adapter.js.map +1 -1
  53. package/dist/persistence/types.d.ts +53 -1
  54. package/dist/persistence/types.d.ts.map +1 -1
  55. package/dist/router/types.d.ts +1 -0
  56. package/dist/router/types.d.ts.map +1 -1
  57. package/dist/router/workflow-router.d.ts.map +1 -1
  58. package/dist/router/workflow-router.js +35 -0
  59. package/dist/router/workflow-router.js.map +1 -1
  60. package/dist/runtime/types.d.ts +5 -0
  61. package/dist/runtime/types.d.ts.map +1 -1
  62. package/dist/runtime/workflow-runtime.d.ts +114 -3
  63. package/dist/runtime/workflow-runtime.d.ts.map +1 -1
  64. package/dist/runtime/workflow-runtime.js +411 -53
  65. package/dist/runtime/workflow-runtime.js.map +1 -1
  66. package/dist/schema/options.d.ts +101 -0
  67. package/dist/schema/options.d.ts.map +1 -0
  68. package/dist/schema/options.js +14 -0
  69. package/dist/schema/options.js.map +1 -0
  70. package/dist/steps/base-step.d.ts +11 -1
  71. package/dist/steps/base-step.d.ts.map +1 -1
  72. package/dist/steps/base-step.js.map +1 -1
  73. package/dist/triggers/api-trigger.d.ts +1 -1
  74. package/dist/triggers/api-trigger.d.ts.map +1 -1
  75. package/dist/triggers/api-trigger.js.map +1 -1
  76. package/dist/triggers/base-trigger.d.ts +66 -8
  77. package/dist/triggers/base-trigger.d.ts.map +1 -1
  78. package/dist/triggers/base-trigger.js +14 -3
  79. package/dist/triggers/base-trigger.js.map +1 -1
  80. package/dist/triggers/cron-trigger.d.ts +1 -1
  81. package/dist/triggers/cron-trigger.d.ts.map +1 -1
  82. package/dist/triggers/cron-trigger.js.map +1 -1
  83. package/dist/triggers/event-trigger.d.ts +3 -3
  84. package/dist/triggers/event-trigger.d.ts.map +1 -1
  85. package/dist/triggers/event-trigger.js +2 -2
  86. package/dist/triggers/event-trigger.js.map +1 -1
  87. package/dist/triggers/manual-trigger.d.ts +1 -1
  88. package/dist/triggers/manual-trigger.d.ts.map +1 -1
  89. package/dist/triggers/manual-trigger.js.map +1 -1
  90. package/dist/triggers/webhook-trigger.d.ts +3 -3
  91. package/dist/triggers/webhook-trigger.d.ts.map +1 -1
  92. package/dist/triggers/webhook-trigger.js.map +1 -1
  93. package/dist/types/config-path.d.ts +24 -0
  94. package/dist/types/config-path.d.ts.map +1 -0
  95. package/dist/types/config-path.js +18 -0
  96. package/dist/types/config-path.js.map +1 -0
  97. package/dist/types/context.d.ts.map +1 -1
  98. package/dist/types/operators.d.ts +15 -1
  99. package/dist/types/operators.d.ts.map +1 -1
  100. package/dist/types/operators.js +21 -0
  101. package/dist/types/operators.js.map +1 -1
  102. package/dist/types/workflow-config.d.ts +13 -0
  103. package/dist/types/workflow-config.d.ts.map +1 -1
  104. package/dist/types/workflow-config.js +9 -7
  105. package/dist/types/workflow-config.js.map +1 -1
  106. package/dist/util/option-mark.d.ts +44 -0
  107. package/dist/util/option-mark.d.ts.map +1 -0
  108. package/dist/util/option-mark.js +132 -0
  109. package/dist/util/option-mark.js.map +1 -0
  110. package/dist/util/schema-convert.d.ts +5 -0
  111. package/dist/util/schema-convert.d.ts.map +1 -1
  112. package/dist/util/schema-convert.js +7 -1
  113. package/dist/util/schema-convert.js.map +1 -1
  114. package/dist/util/schema-marks.d.ts +58 -0
  115. package/dist/util/schema-marks.d.ts.map +1 -0
  116. package/dist/util/schema-marks.js +161 -0
  117. package/dist/util/schema-marks.js.map +1 -0
  118. package/dist/util/variable-ref.d.ts +0 -5
  119. package/dist/util/variable-ref.d.ts.map +1 -1
  120. package/dist/util/variable-ref.js +0 -5
  121. package/dist/util/variable-ref.js.map +1 -1
  122. package/package.json +1 -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
  }
@@ -432,7 +486,8 @@ export class WorkflowRuntime {
432
486
  * 4. The trigger's own `verify` accepts the request — else 401.
433
487
  * 5. The body is parsed from the raw bytes — else 400.
434
488
  * 6. The trigger turns the request into events and a reply.
435
- * 7. Each event must pass the workflow's trigger filter, or it is dropped.
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.
436
491
  * 8. Survivors are enqueued before the reply is returned.
437
492
  *
438
493
  * Reasons for a rejection go to the log; responses stay generic.
@@ -499,9 +554,12 @@ export class WorkflowRuntime {
499
554
  const translation = await trigger.handleRequest({ ...request, body }, triggerConfig, ctx);
500
555
  const executionIds = [];
501
556
  for (const event of translation.events) {
502
- 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}`);
503
560
  continue;
504
- executionIds.push(await this.createAndEnqueue(workflow, { type: event.type, ...event.payload }, { publicTrigger: true }));
561
+ }
562
+ executionIds.push(armed.executionId);
505
563
  }
506
564
  return {
507
565
  reply: translation.reply ?? { status: 200, body: { ok: true } },
@@ -579,11 +637,11 @@ export class WorkflowRuntime {
579
637
  if (errors.length > 0) {
580
638
  throw new Error(`Invalid trigger payload: ${errors.map((e) => e.message).join('; ')}`);
581
639
  }
582
- const triggerPayload = {
583
- type: config.trigger.type,
584
- ...payload,
585
- };
586
- 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;
587
645
  }
588
646
  /**
589
647
  * Start a workflow from the API trigger route.
@@ -610,7 +668,11 @@ export class WorkflowRuntime {
610
668
  if (errors.length > 0) {
611
669
  throw new Error(`Invalid trigger payload: ${errors.map((e) => e.message).join('; ')}`);
612
670
  }
613
- 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;
614
676
  }
615
677
  /**
616
678
  * What the API trigger route needs before it builds a payload: the trigger's
@@ -1177,7 +1239,13 @@ export class WorkflowRuntime {
1177
1239
  const exec = await this.persistence.getExecution(executionId);
1178
1240
  if (!exec)
1179
1241
  return { status: 'not_found' };
1180
- 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)) {
1181
1249
  return {
1182
1250
  status: 'skipped',
1183
1251
  reason: `execution status is ${exec.status}`,
@@ -1266,30 +1334,298 @@ export class WorkflowRuntime {
1266
1334
  });
1267
1335
  if (data === null)
1268
1336
  return null; // nothing new, skip
1269
- return this.createAndEnqueue(workflow, {
1270
- type: config.trigger.type,
1271
- ...data,
1272
- });
1337
+ return this.admitAndEnqueue(workflow, config, data);
1273
1338
  }
1274
1339
  // Pure cron — always fire
1275
1340
  const payload = trigger.buildPayload
1276
1341
  ? trigger.buildPayload(config.trigger.config)
1277
1342
  : { firedAt: new Date().toISOString() };
1278
- 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 = {
1279
1348
  type: config.trigger.type,
1280
- ...payload,
1281
- });
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');
1282
1444
  }
1283
1445
  // ─── Internal ───
1284
- 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) {
1285
1627
  const metadata = JSON.parse(workflow.metadata ?? '{}');
1286
- // Mark executions started by an UNAUTHENTICATED public trigger (webhook) so
1287
- // privileged steps (e.g. SEND_EMAIL, code execution) can refuse to run for
1288
- // them. Set server-side on a per-execution copy of the metadata — never
1289
- // sourced from attacker-controlled trigger data.
1290
- if (opts?.publicTrigger)
1291
- metadata['publicTrigger'] = true;
1292
- const initialContext = {
1628
+ return {
1293
1629
  workflow: {
1294
1630
  id: workflow.id,
1295
1631
  // The execution's tenant, carried by the resource. The executor
@@ -1302,12 +1638,34 @@ export class WorkflowRuntime {
1302
1638
  steps: {},
1303
1639
  vars: projectVariables(parseWorkflowConfig(workflow.config).variables),
1304
1640
  };
1641
+ }
1642
+ async createAndEnqueue(workflow, triggerPayload, opts) {
1643
+ const scheduled = opts?.scheduled;
1644
+ const attributes = workflow.attributes;
1305
1645
  const execId = await this.persistence.createExecution({
1306
1646
  workflowId: workflow.id,
1307
- status: 'PENDING',
1308
- context: JSON.stringify(initialContext),
1309
- 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
+ : {}),
1310
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
+ }
1311
1669
  await this.queue.enqueue({ executionId: execId });
1312
1670
  this.log.info(`created execution ${execId} for workflow ${workflow.id}`);
1313
1671
  return execId;