-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathengine.ts
More file actions
2199 lines (2037 loc) · 95.3 KB
/
Copy pathengine.ts
File metadata and controls
2199 lines (2037 loc) · 95.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import type { FlowParsed, FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automation';
import type { ExecutionLog, ActionDescriptor } from '@objectstack/spec/automation';
import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, ScreenSpec } from '@objectstack/spec/contracts';
import type { Logger } from '@objectstack/spec/contracts';
import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, findRegionEntry, defineActionDescriptor } from '@objectstack/spec/automation';
import type { FlowRegionParsed } from '@objectstack/spec/automation';
import type { Connector } from '@objectstack/spec/integration';
import { ConnectorSchema } from '@objectstack/spec/integration';
// Static import (not a lazy `require`): the engine ships as ESM ("type":"module"),
// where a CommonJS `require('@objectstack/formula')` resolves to tsup's throwing
// `__require` stub. That threw on every CEL evaluation and the catch below
// silently returned `false`, so EVERY start-node / edge condition (record-change
// `previous.*`, `budget > 100000`, …) skipped its flow. A static import binds the
// engine at module load in both ESM and CJS builds.
import { ExpressionEngine, validateExpression } from '@objectstack/formula';
// ─── Node Executor Interface (Plugin Extension Point) ───────────────
/**
* Each node type corresponds to a NodeExecutor.
* Third-party plugins only need to implement this interface and register
* it with the engine to extend automation capabilities.
*/
export interface NodeExecutor {
/** Registry node type (built-in id or plugin-defined) */
readonly type: string;
/**
* Optional ADR-0018 action descriptor. When present, it is published into
* the engine's action registry and surfaced via {@link AutomationEngine.getActionDescriptors}
* — feeding flow validation and the designer palette. Plugins SHOULD publish
* one so their node appears in the palette and validates as a legal flow node.
*/
readonly descriptor?: ActionDescriptor;
/**
* Execute a node
* @param node - Current node definition
* @param variables - Flow variable context (read/write)
* @param context - Trigger context
* @returns Execution result (may include output data, branch conditions, etc.)
*/
execute(
node: FlowNodeParsed,
variables: Map<string, unknown>,
context: AutomationContext,
): Promise<NodeExecutionResult>;
}
export interface NodeExecutionResult {
success: boolean;
output?: Record<string, unknown>;
error?: string;
/** Used by decision nodes — returns the selected branch label */
branchLabel?: string;
/**
* ADR-0019 durable pause. When `true`, the node has done its on-entry work
* (e.g. opened an approval request) and the run should **suspend** here: the
* engine persists a continuation, stops traversal, and `execute()` returns
* `{ status: 'paused', runId }`. The run is continued later via
* {@link AutomationEngine.resume}. Any `output` is written to variables
* before suspending. The node reads its own run id from the `$runId`
* flow variable so it can map the run to external state.
*/
suspend?: boolean;
/**
* Optional correlation key surfaced on the suspended-run record (e.g. an
* approval request id). For observability / lookup; not required to resume.
*/
correlation?: string;
/**
* Screen to render — set by a `screen` node that suspends to collect input.
* Surfaced on the paused {@link AutomationResult} so a UI runner can render
* the form and `resume()` with the values.
*/
screen?: ScreenSpec;
/**
* #1479: step logs produced inside the node's structured region(s). A
* container node (`loop` / `parallel` / `try_catch`) collects the
* {@link AutomationEngine.runRegion} return value(s) here; {@link AutomationEngine.executeNode}
* appends them to the parent run log right after the container's own step,
* so per-iteration / per-branch body steps surface in run observability.
*/
childSteps?: StepLogEntry[];
}
// ─── Trigger Interface (Plugin Extension Point) ─────────────────────
/**
* A normalized description of *what* fires a flow, derived by the engine from
* the flow's `start` node and handed to the matching {@link FlowTrigger} when a
* flow is activated. Concrete triggers (record-change, schedule, …) read the
* fields they care about and ignore the rest.
*
* The engine — not the trigger — owns parsing the start node, so trigger
* plugins stay decoupled from flow-definition internals (mirrors how
* `connector_action` keeps connectors decoupled from node config).
*/
export interface FlowTriggerBinding {
/** Flow this binding activates. */
readonly flowName: string;
/** record-change: the object whose mutations fire the flow. */
readonly object?: string;
/** record-change: the start node's `triggerType` (e.g. 'record-after-update'). */
readonly event?: string;
/**
* Optional trigger predicate copied from the start node's `condition`. The
* engine evaluates it before running the flow; triggers may ignore it.
*/
readonly condition?: string | { dialect?: string; source?: string; ast?: unknown };
/** schedule: cron/interval descriptor (parsed but not yet acted on here). */
readonly schedule?: unknown;
/** The raw start-node `config`, for trigger-specific fields not modeled above. */
readonly config?: Record<string, unknown>;
}
/**
* Trigger interface. Schedule/Event/API triggers are registered via plugins.
*
* The engine completes the wiring: when a flow whose start node maps to this
* trigger's {@link type} is registered (or when this trigger is registered
* after such flows already exist), the engine calls {@link start} with the
* parsed {@link FlowTriggerBinding} and a `callback` that runs the flow. The
* trigger subscribes to its event source (e.g. an ObjectQL lifecycle hook) and
* invokes `callback(ctx)` when it fires. {@link stop} tears that subscription
* down when the flow is unregistered/disabled or the trigger is removed.
*/
export interface FlowTrigger {
readonly type: string;
start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise<void>): void;
stop(flowName: string): void;
}
// ─── Connector Registry (Plugin Extension Point) ────────────────────
/**
* Context handed to a connector action handler. Carries the live flow variable
* map and the trigger context so a handler can read prior-node output, plus a
* logger. The platform ships the registry + the `connector_action` dispatch
* node (baseline, ADR-0018 §Addendum); *concrete* connectors — `connector-rest`,
* `connector-slack`, … — are plugins that register handlers here.
*/
export interface ConnectorActionContext {
readonly variables: Map<string, unknown>;
readonly automation: AutomationContext;
readonly logger: Logger;
}
/**
* A handler for one connector action. Receives the (already-resolved) input
* mapped from the flow node and returns the action's output, which the
* `connector_action` node writes back into flow variables.
*/
export type ConnectorActionHandler = (
input: Record<string, unknown>,
ctx: ConnectorActionContext,
) => Promise<Record<string, unknown>>;
/**
* A connector registered on the engine: its validated {@link Connector}
* definition plus the handler for each action it declares.
*/
export interface RegisteredConnector {
readonly def: Connector;
readonly handlers: Record<string, ConnectorActionHandler>;
}
/**
* A designer-facing view of one connector action — identity + its JSON-Schema
* input/output. The runtime handler is intentionally omitted; this is metadata.
*/
export interface ConnectorActionDescriptor {
readonly key: string;
readonly label: string;
readonly description?: string;
readonly inputSchema?: Record<string, unknown>;
readonly outputSchema?: Record<string, unknown>;
}
/**
* A designer-facing descriptor for a registered connector: its identity plus
* the actions it exposes. Served by `GET /api/v1/automation/connectors` so the
* flow designer can populate the `connector_action` node's connector → action
* → input pickers (ADR-0018 §Addendum, ADR-0022). Mirrors `ActionDescriptor`'s
* role for node types, but for the connector registry.
*/
export interface ConnectorDescriptor {
readonly name: string;
readonly label: string;
readonly type: string;
readonly description?: string;
readonly icon?: string;
readonly actions: ConnectorActionDescriptor[];
}
// ─── Core Automation Engine ─────────────────────────────────────────
/**
* Execution step log entry. Part of a {@link SuspendedRun}'s persisted state, so
* it survives serialization to a durable {@link SuspendedRunStore}.
*/
export interface StepLogEntry {
nodeId: string;
nodeType: string;
nodeLabel?: string;
status: 'success' | 'failure' | 'skipped';
startedAt: string;
completedAt?: string;
durationMs?: number;
error?: { code: string; message: string; stack?: string };
/**
* #1479: structured-region grouping. When a step ran inside a `loop` /
* `parallel` / `try_catch` body region, these tag it with its **immediate**
* container so run observability can distinguish per-iteration / per-branch
* body steps from top-level ones. Set by {@link AutomationEngine.runRegion}
* (innermost wins — never overwritten as steps bubble through nested regions).
*/
parentNodeId?: string;
/** Zero-based loop iteration or parallel branch index of the enclosing region. */
iteration?: number;
/** Which region kind the step ran in: `loop-body` | `parallel-branch` | `try` | `catch`. */
regionKind?: string;
}
/**
* Internal execution log entry — compatible with ExecutionLog from spec.
*/
interface ExecutionLogEntry {
id: string;
flowName: string;
flowVersion?: number;
status: ExecutionLog['status'];
startedAt: string;
completedAt?: string;
durationMs?: number;
trigger: { type: string; userId?: string; object?: string; recordId?: string };
steps: StepLogEntry[];
variables?: Record<string, unknown>;
output?: unknown;
error?: string;
}
/**
* Internal sentinel thrown by {@link AutomationEngine.executeNode} when a node
* signals `suspend`. It unwinds the synchronous DAG recursion up to
* `execute()` / `resume()`, which converts it into a persisted continuation
* rather than a failed run. (Not exported — callers see `status: 'paused'`.)
*
* NOTE: suspend is supported on the serial / main execution path. A node that
* suspends inside a `Promise.all` parallel branch will unwind that branch, but
* sibling parallel branches already in flight are not cancelled — durable
* pause across parallel gateways is out of scope for ADR-0019 M1.
*/
class FlowSuspendSignal {
readonly __flowSuspend = true as const;
constructor(readonly nodeId: string, readonly correlation?: string, readonly screen?: ScreenSpec) {}
}
function isSuspendSignal(err: unknown): err is FlowSuspendSignal {
return typeof err === 'object' && err !== null && (err as FlowSuspendSignal).__flowSuspend === true;
}
/**
* A run paused at a node, awaiting {@link AutomationEngine.resume} (ADR-0019).
*
* Held in an in-memory hot cache and — when a {@link SuspendedRunStore} is
* configured — mirrored to durable storage so the pause survives a process
* restart. Every field is JSON-serializable (the engine's variable `Map` is
* snapshotted as a plain object) so the whole record round-trips through a
* store.
*/
export interface SuspendedRun {
runId: string;
flowName: string;
flowVersion?: number;
/** The node the run paused at; resume continues from its out-edges. */
nodeId: string;
/** Snapshot of the flow variable map at suspend time. */
variables: Record<string, unknown>;
steps: StepLogEntry[];
context: AutomationContext;
startedAt: string;
startTime: number;
correlation?: string;
/** Screen the run paused on (screen-flow runtime), for re-fetch + UI render. */
screen?: ScreenSpec;
}
/**
* Pluggable durable store for suspended runs (ADR-0019). The engine persists a
* {@link SuspendedRun} on suspend and deletes it on terminal completion; on
* {@link AutomationEngine.resume} of a run not in the in-memory cache (e.g.
* after a process restart) it rehydrates from here.
*
* The default is purely in-memory (no store); a host wires a DB-backed store
* (`ObjectStoreSuspendedRunStore`, on `sys_automation_run`) for production /
* serverless deployments where the process hibernates between suspend and
* resume.
*/
export interface SuspendedRunStore {
/** Persist (insert or replace) a suspended run. */
save(run: SuspendedRun): Promise<void>;
/** Load a suspended run by id, or `null` if not stored. */
load(runId: string): Promise<SuspendedRun | null>;
/** Remove a suspended run's durable record (idempotent). */
delete(runId: string): Promise<void>;
/** List all currently-stored suspended runs. */
list(): Promise<SuspendedRun[]>;
}
export class AutomationEngine implements IAutomationService {
/**
* ADR-0044: maximum times a single node may be (re-)entered at the top
* level of one run before the engine aborts it as a runaway back-edge
* loop. Generous on purpose — the product guard (`maxRevisions`) sits
* orders of magnitude lower.
*/
static readonly MAX_NODE_REENTRIES = 100;
private flows = new Map<string, FlowParsed>();
private flowEnabled = new Map<string, boolean>();
private flowVersionHistory = new Map<string, Array<{ version: number; definition: FlowParsed; createdAt: string }>>();
private nodeExecutors = new Map<string, NodeExecutor>();
private actionDescriptors = new Map<string, ActionDescriptor>();
private triggers = new Map<string, FlowTrigger>();
/**
* Flows currently wired to a trigger, keyed by flow name → the trigger
* `type` that owns the binding. Used to avoid double-binding and to know
* which trigger to `stop()` when a flow is unregistered/disabled.
*/
private boundFlowTriggers = new Map<string, string>();
/** Connectors registered by integration plugins, keyed by connector name (ADR-0018 §Addendum). */
private connectors = new Map<string, RegisteredConnector>();
private executionLogs: ExecutionLogEntry[] = [];
private maxLogSize = 1000;
private logger: Logger;
/**
* Runs paused at a node, keyed by runId (ADR-0019). In-memory hot cache —
* mirrored to {@link store} when one is configured, so a pause survives a
* process restart. See {@link SuspendedRun}.
*/
private suspendedRuns = new Map<string, SuspendedRun>();
/**
* Optional durable backing for {@link suspendedRuns}. When set, suspended
* runs are persisted on suspend and rehydrated on resume after a restart;
* when absent, behaviour is purely in-memory (the historical default).
*/
private store?: SuspendedRunStore;
/**
* Run ids currently mid-resume — an in-process idempotency guard so a
* duplicate `resume(runId)` can't re-enter and double-run side effects.
*/
private resuming = new Set<string>();
constructor(logger: Logger, store?: SuspendedRunStore) {
this.logger = logger;
this.store = store;
}
/**
* Attach (or replace) the durable {@link SuspendedRunStore}. Used by the
* service plugin to upgrade the engine to DB-backed persistence once the
* ObjectQL engine is available (the engine is constructed earlier, during
* `init`, before services are wired).
*/
setSuspendedRunStore(store: SuspendedRunStore): void {
this.store = store;
}
/**
* Generate a process-unique run id. Includes a random component so ids do
* not collide with runs persisted by a previous process lifetime (a plain
* incrementing counter would reissue `run_1` after a restart, clashing with
* a still-suspended durable run).
*/
private nextRunId(): string {
const g = globalThis as { crypto?: { randomUUID?: () => string } };
const rand = g.crypto?.randomUUID
? g.crypto.randomUUID()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
return `run_${rand}`;
}
/**
* Persist a suspended run to the in-memory cache and (best-effort) the
* durable store. A store failure is logged but does not fail the run — the
* in-memory copy still allows in-process resume; only cross-restart
* durability is lost.
*/
private async persistSuspendedRun(run: SuspendedRun): Promise<void> {
this.suspendedRuns.set(run.runId, run);
if (this.store) {
try {
await this.store.save(run);
} catch (err) {
this.logger.warn(
`[automation] failed to persist suspended run '${run.runId}' to durable store (kept in memory only): ${(err as Error).message}`,
);
}
}
}
/**
* Drop a suspended run from the in-memory cache and (best-effort) the
* durable store. Called once the run is claimed for resume or reaches a
* terminal state.
*/
private async forgetSuspendedRun(runId: string): Promise<void> {
this.suspendedRuns.delete(runId);
if (this.store) {
try {
await this.store.delete(runId);
} catch (err) {
this.logger.warn(
`[automation] failed to delete suspended run '${runId}' from durable store: ${(err as Error).message}`,
);
}
}
}
// ── Plugin Extension API ──────────────────────────────
/** Register a node executor (called by plugins) */
registerNodeExecutor(executor: NodeExecutor): void {
if (this.nodeExecutors.has(executor.type)) {
this.logger.warn(`Node executor '${executor.type}' replaced`);
}
this.nodeExecutors.set(executor.type, executor);
// Publish the ADR-0018 action descriptor into the registry, so the
// type validates as a legal flow node and appears in the designer
// palette. A descriptor's `type` should match the executor's; we key
// on the descriptor's `type` and warn on mismatch rather than silently
// diverging.
if (executor.descriptor) {
const descriptorType = executor.descriptor.type;
if (descriptorType !== executor.type) {
this.logger.warn(
`Node executor '${executor.type}' publishes a descriptor for type '${descriptorType}' — registering under both.`,
);
}
this.actionDescriptors.set(descriptorType, executor.descriptor);
}
this.logger.info(`Node executor registered: ${executor.type}`);
}
/**
* Register a **deprecated alias** of a canonical node type (ADR-0018 M3).
*
* The alias is a real registered executor, so old saved flows whose nodes
* use the alias type keep validating and running with no migration. At
* execute time it delegates to the canonical executor (resolved live, so the
* canonical may be registered before or after the alias), logging a one-time
* deprecation warning. Its published descriptor is flagged `deprecated` +
* `aliasOf` so the designer palette can hide or mark it while the canonical
* type is the one offered for new authoring.
*
* This is how ADR-0018 collapses the five outbound verbs onto `http` /
* `notify`: `http_request` / `http_call` / `webhook` become aliases of
* `http`.
*/
registerNodeAlias(
alias: string,
canonicalType: string,
meta?: { name?: string; category?: ActionDescriptor['category']; paradigms?: ActionDescriptor['paradigms']; needsOutbox?: boolean },
): void {
const engine = this;
let warned = false;
this.registerNodeExecutor({
type: alias,
descriptor: defineActionDescriptor({
type: alias,
version: '1.0.0',
name: meta?.name ?? alias,
description: `Deprecated alias of '${canonicalType}' (ADR-0018 M3). Author new flows with '${canonicalType}'.`,
category: meta?.category ?? 'io',
source: 'builtin',
paradigms: meta?.paradigms ?? ['flow', 'approval'],
supportsRetry: true,
needsOutbox: meta?.needsOutbox ?? false,
deprecated: true,
aliasOf: canonicalType,
}),
async execute(node, variables, context) {
if (!warned) {
warned = true;
engine.logger.warn(
`Node type '${alias}' is deprecated; use '${canonicalType}' (ADR-0018 M3). Existing flows keep running via the alias.`,
);
}
const target = engine.nodeExecutors.get(canonicalType);
if (!target) {
return {
success: false,
error: `alias '${alias}' → '${canonicalType}': canonical executor not registered`,
};
}
return target.execute(node, variables, context);
},
});
this.logger.info(`Node alias registered: ${alias} → ${canonicalType} (deprecated)`);
}
/** Unregister a node executor (hot-unplug) */
unregisterNodeExecutor(type: string): void {
const executor = this.nodeExecutors.get(type);
this.nodeExecutors.delete(type);
// Drop the published descriptor (keyed by descriptor.type, which may
// differ from the executor type).
this.actionDescriptors.delete(type);
if (executor?.descriptor) {
this.actionDescriptors.delete(executor.descriptor.type);
}
this.logger.info(`Node executor unregistered: ${type}`);
}
/** Register a trigger (called by plugins) */
registerTrigger(trigger: FlowTrigger): void {
this.triggers.set(trigger.type, trigger);
this.logger.info(`Trigger registered: ${trigger.type}`);
// A trigger may be registered *after* its flows (e.g. AutomationServicePlugin
// pulls flows at start(); a trigger plugin wires up on kernel:ready, which
// fires later). Activate any already-registered flow that maps to this type.
for (const name of this.flows.keys()) {
if (this.boundFlowTriggers.has(name)) continue;
const resolved = this.resolveTriggerBinding(name);
if (resolved?.triggerType === trigger.type) {
this.activateFlowTrigger(name);
}
}
}
/** Unregister a trigger (hot-unplug) */
unregisterTrigger(type: string): void {
// Tear down every flow bound to this trigger before dropping it.
for (const [name, boundType] of [...this.boundFlowTriggers]) {
if (boundType !== type) continue;
try {
this.triggers.get(type)?.stop(name);
} catch (err) {
this.logger.warn(`Trigger '${type}' stop('${name}') failed: ${(err as Error).message}`);
}
this.boundFlowTriggers.delete(name);
}
this.triggers.delete(type);
this.logger.info(`Trigger unregistered: ${type}`);
}
/**
* Derive a flow's trigger binding from its `start` node, or `undefined` if
* the flow has no auto-trigger (manual / screen). The convention —
* established by the showcase flows — is that the start node carries the
* trigger details in its `config`: `{ objectName, triggerType, condition }`
* for record-change, or a `schedule` descriptor for time-based flows.
*/
private resolveTriggerBinding(
flowName: string,
): { triggerType: string; binding: FlowTriggerBinding } | undefined {
const flow = this.flows.get(flowName);
if (!flow) return undefined;
const startNode = flow.nodes.find(n => n.type === 'start');
const config = (startNode?.config ?? {}) as Record<string, unknown>;
const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined;
if (triggerType && triggerType.startsWith('record-')) {
return {
triggerType: 'record_change',
binding: {
flowName,
object: typeof config.objectName === 'string' ? config.objectName : undefined,
event: triggerType,
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
config,
},
};
}
if (config.schedule != null || flow.type === 'schedule') {
return {
triggerType: 'schedule',
binding: { flowName, schedule: config.schedule, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
// Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external
// POST. The concrete trigger (`@objectstack/trigger-api`) mounts the
// endpoint and enqueues; the binding's `config` carries the hook
// details (`hookId`, `secret`) from the start node.
if (flow.type === 'api' || triggerType === 'api') {
return {
triggerType: 'api',
binding: { flowName, condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, config },
};
}
return undefined;
}
/**
* Bind a flow to its matching registered trigger (idempotent). No-op when
* the flow has no trigger binding or no trigger is registered for its type
* yet — {@link registerTrigger} re-attempts activation when one arrives.
*/
private activateFlowTrigger(flowName: string): void {
if (this.boundFlowTriggers.has(flowName)) return;
const resolved = this.resolveTriggerBinding(flowName);
if (!resolved) return;
const trigger = this.triggers.get(resolved.triggerType);
if (!trigger) return;
try {
trigger.start(resolved.binding, (ctx: AutomationContext) => this.execute(flowName, ctx).then(() => undefined));
this.boundFlowTriggers.set(flowName, resolved.triggerType);
this.logger.info(`Flow '${flowName}' bound to trigger '${resolved.triggerType}'`);
} catch (err) {
this.logger.warn(`Failed to bind flow '${flowName}' to trigger '${resolved.triggerType}': ${(err as Error).message}`);
}
}
/** Unbind a flow from its trigger, if bound. */
private deactivateFlowTrigger(flowName: string): void {
const boundType = this.boundFlowTriggers.get(flowName);
if (!boundType) return;
try {
this.triggers.get(boundType)?.stop(flowName);
} catch (err) {
this.logger.warn(`Trigger '${boundType}' stop('${flowName}') failed: ${(err as Error).message}`);
}
this.boundFlowTriggers.delete(flowName);
}
/** Active flow→trigger bindings (observability / tests). */
getActiveTriggerBindings(): Array<{ flowName: string; triggerType: string }> {
return [...this.boundFlowTriggers].map(([flowName, triggerType]) => ({ flowName, triggerType }));
}
/**
* Register a connector (called by integration plugins, ADR-0018 §Addendum).
* Validates the definition against {@link ConnectorSchema} and asserts every
* declared action has a handler, so a half-wired connector fails loudly at
* registration rather than silently at dispatch. Re-registering the same
* name replaces (mirrors {@link registerNodeExecutor}).
*/
registerConnector(def: Connector, handlers: Record<string, ConnectorActionHandler>): void {
const parsed = ConnectorSchema.parse(def);
for (const action of parsed.actions ?? []) {
if (typeof handlers[action.key] !== 'function') {
throw new Error(
`Connector '${parsed.name}': action '${action.key}' is declared but no handler was provided`,
);
}
}
if (this.connectors.has(parsed.name)) {
this.logger.warn(`Connector '${parsed.name}' replaced`);
}
this.connectors.set(parsed.name, { def: parsed, handlers });
this.logger.info(
`Connector registered: ${parsed.name} (${Object.keys(handlers).length} action handlers)`,
);
}
/** Unregister a connector (hot-unplug). */
unregisterConnector(name: string): void {
this.connectors.delete(name);
this.logger.info(`Connector unregistered: ${name}`);
}
/**
* Resolve the handler for a connector action, used by the baseline
* `connector_action` node. Returns `undefined` when the connector or action
* is not registered, so the node can fail the step with a clear error.
*/
resolveConnectorAction(connectorId: string, actionId: string): ConnectorActionHandler | undefined {
return this.connectors.get(connectorId)?.handlers[actionId];
}
/** Get all registered connector names. */
getRegisteredConnectors(): string[] {
return [...this.connectors.keys()];
}
/**
* Get a designer-facing descriptor for every registered connector — its
* identity plus the actions it exposes (input/output JSON Schema). Backs
* `GET /api/v1/automation/connectors` so the designer can fill the
* `connector_action` node's connector / action / input pickers (ADR-0022).
* Handlers are omitted — they are runtime code, not metadata.
*/
getConnectorDescriptors(): ConnectorDescriptor[] {
return [...this.connectors.values()].map(({ def }) => ({
name: def.name,
label: def.label,
type: def.type,
description: def.description,
icon: def.icon,
actions: (def.actions ?? []).map((a) => ({
key: a.key,
label: a.label,
description: a.description,
inputSchema: a.inputSchema,
outputSchema: a.outputSchema,
})),
}));
}
/** Get all registered node types */
getRegisteredNodeTypes(): string[] {
return [...this.nodeExecutors.keys()];
}
/**
* Get all published action descriptors (ADR-0018). Backs both flow
* validation and the designer palette (`GET /api/v1/automation/actions`).
* Only executors that published a descriptor appear here.
*/
getActionDescriptors(): ActionDescriptor[] {
return [...this.actionDescriptors.values()];
}
/** Get the action descriptor for a single node type, if published. */
getActionDescriptor(type: string): ActionDescriptor | undefined {
return this.actionDescriptors.get(type);
}
/** Get all registered trigger types */
getRegisteredTriggerTypes(): string[] {
return [...this.triggers.keys()];
}
// ── IAutomationService Contract Implementation ────────
registerFlow(name: string, definition: unknown): void {
const parsed = FlowSchema.parse(definition);
// DAG cycle detection
this.detectCycles(parsed);
// ADR-0031 — validate structured control-flow constructs (loop bodies,
// parallel branches, try/catch regions) are well-formed (single-entry/
// single-exit, acyclic). Reject the malformed before it can run.
validateControlFlow(parsed);
// ADR-0018 §M1 — validate node types against the live action registry.
// The protocol no longer gates `type` with a closed enum; membership is
// checked here instead. Soft-fail (warn, don't throw): a flow authored
// against a plugin that is currently disabled should still register, and
// executeNode() already throws NO_EXECUTOR at run time for unknown types.
this.validateNodeTypes(name, parsed);
// ADR-0032 §Decision 1a — parse-validate every predicate at registration,
// so a malformed condition (e.g. the #1491 `{record.x}` template-brace-in-
// CEL mistake) is a LOUD registration error with the offending source,
// not a silent runtime `false`. Hard-fail: a broken predicate is never
// safe to run.
this.validateFlowExpressions(name, parsed);
// Version history management
const history = this.flowVersionHistory.get(name) ?? [];
history.push({
version: parsed.version,
definition: parsed,
createdAt: new Date().toISOString(),
});
this.flowVersionHistory.set(name, history);
this.flows.set(name, parsed);
if (!this.flowEnabled.has(name)) {
this.flowEnabled.set(name, true);
}
this.logger.info(`Flow registered: ${name} (version ${parsed.version})`);
// Re-bind in case the definition changed its trigger, then (re)activate.
this.deactivateFlowTrigger(name);
if (this.flowEnabled.get(name) !== false) {
this.activateFlowTrigger(name);
}
}
unregisterFlow(name: string): void {
this.deactivateFlowTrigger(name);
this.flows.delete(name);
this.flowEnabled.delete(name);
this.flowVersionHistory.delete(name);
this.logger.info(`Flow unregistered: ${name}`);
}
async listFlows(): Promise<string[]> {
return [...this.flows.keys()];
}
async getFlow(name: string): Promise<FlowParsed | null> {
return this.flows.get(name) ?? null;
}
async toggleFlow(name: string, enabled: boolean): Promise<void> {
if (!this.flows.has(name)) {
throw new Error(`Flow '${name}' not found`);
}
this.flowEnabled.set(name, enabled);
this.logger.info(`Flow '${name}' ${enabled ? 'enabled' : 'disabled'}`);
// A disabled flow should stop receiving trigger events; a re-enabled one
// should resume. execute() also guards disabled flows, but unbinding
// avoids firing the trigger (and its event-source subscription) at all.
if (enabled) {
this.activateFlowTrigger(name);
} else {
this.deactivateFlowTrigger(name);
}
}
/** Get flow version history */
getFlowVersionHistory(name: string): Array<{ version: number; definition: FlowParsed; createdAt: string }> {
return this.flowVersionHistory.get(name) ?? [];
}
/** Rollback flow to a specific version */
rollbackFlow(name: string, version: number): void {
const history = this.flowVersionHistory.get(name);
if (!history) {
throw new Error(`Flow '${name}' has no version history`);
}
const entry = history.find(h => h.version === version);
if (!entry) {
throw new Error(`Version ${version} not found for flow '${name}'`);
}
this.flows.set(name, entry.definition);
this.logger.info(`Flow '${name}' rolled back to version ${version}`);
}
async listRuns(flowName: string, options?: { limit?: number; cursor?: string }): Promise<ExecutionLogEntry[]> {
const limit = options?.limit ?? 20;
const logs = this.executionLogs.filter(l => l.flowName === flowName);
return logs.slice(-limit).reverse();
}
async getRun(runId: string): Promise<ExecutionLogEntry | null> {
return this.executionLogs.find(l => l.id === runId) ?? null;
}
async execute(flowName: string, context?: AutomationContext): Promise<AutomationResult> {
const startTime = Date.now();
const flow = this.flows.get(flowName);
if (!flow) {
return { success: false, error: `Flow '${flowName}' not found` };
}
// Check if flow is disabled
if (this.flowEnabled.get(flowName) === false) {
return { success: false, error: `Flow '${flowName}' is disabled` };
}
// Initialize variable context
const variables = new Map<string, unknown>();
if (flow.variables) {
for (const v of flow.variables) {
if (v.isInput && context?.params?.[v.name] !== undefined) {
variables.set(v.name, context.params[v.name]);
}
}
}
// Inject trigger record. `$record` is the canonical handle; `record` is a
// friendlier alias so templates/conditions can write `{record.title}` and
// `record.status`. We also flatten the record's own fields to top-level
// variables (so bare references like `status`/`budget` resolve in start
// conditions and edge predicates) WITHOUT clobbering flow inputs already
// seeded above. `previous` exposes the pre-update row for transition gates.
if (context?.record) {
variables.set('$record', context.record);
variables.set('record', context.record);
for (const [k, v] of Object.entries(context.record)) {
if (!variables.has(k)) variables.set(k, v);
}
}
if (context?.previous) {
variables.set('previous', context.previous);
}
const runId = this.nextRunId();
// Expose the run id to executors (ADR-0019): a pausing node (e.g. Approval)
// reads `$runId` to map its external state back to this run for resume.
variables.set('$runId', runId);
// Expose flow identity to executors so externalized state (e.g. an
// approval request row) can carry a human-readable origin. Captured in
// the variable snapshot, so still present after a suspend/resume.
variables.set('$flowName', flowName);
variables.set('$flowLabel', flow.label ?? flowName);
const startedAt = new Date().toISOString();
const steps: StepLogEntry[] = [];
try {
// Find the start node
const startNode = flow.nodes.find(n => n.type === 'start');
if (!startNode) {
return { success: false, error: 'Flow has no start node' };
}
// Trigger-condition gate. The start node's `condition` is the predicate
// that decides whether the trigger event should launch this flow (e.g.
// `status == "done" && previous.status != "done"`). The engine — not the
// trigger — owns evaluating it, so every trigger type (record-change,
// schedule, …) and a manual `execute()` share one gate. Plain-string
// conditions are routed through CEL so bare field references resolve.
const startCondition = (startNode.config as Record<string, unknown> | undefined)?.condition as
| string
| { dialect?: string; source?: string; ast?: unknown }
| undefined;
if (startCondition !== undefined && startCondition !== null && startCondition !== '') {
const condExpr =
typeof startCondition === 'string' ? { dialect: 'cel', source: startCondition } : startCondition;
if (!this.evaluateCondition(condExpr, variables)) {
this.logger.debug(`Flow '${flowName}' skipped: start condition not met`);
return { success: true, output: { skipped: true, reason: 'condition_not_met' } };
}
}
// Validate node input schemas before execution
this.validateNodeInputSchemas(flow, variables);
// DAG traversal execution
await this.executeNode(startNode, flow, variables, context ?? {}, steps);
// Collect output variables
const output: Record<string, unknown> = {};
if (flow.variables) {
for (const v of flow.variables) {
if (v.isOutput) {
output[v.name] = variables.get(v.name);
}
}
}
const durationMs = Date.now() - startTime;
// Record execution log
this.recordLog({
id: runId,
flowName,
flowVersion: flow.version,
status: 'completed',
startedAt,
completedAt: new Date().toISOString(),
durationMs,
trigger: {
type: context?.event ?? 'manual',
userId: context?.userId,
object: context?.object,
},
steps,
output,
});
return {
success: true,
output,
durationMs,
};
} catch (err: unknown) {
// A node asked to suspend the run (ADR-0019 durable pause). Snapshot
// the live state, record a `paused` log, and return the run id so the
// caller can later `resume()` it. This is NOT a failure.
if (isSuspendSignal(err)) {
const durationMs = Date.now() - startTime;
await this.persistSuspendedRun({
runId,
flowName,
flowVersion: flow.version,
nodeId: err.nodeId,
variables: Object.fromEntries(variables),
steps,
context: context ?? {},
startedAt,
startTime,
correlation: err.correlation,
screen: err.screen,
});
this.recordLog({
id: runId,
flowName,
flowVersion: flow.version,
status: 'paused',
startedAt,
durationMs,
trigger: {
type: context?.event ?? 'manual',
userId: context?.userId,
object: context?.object,
},
steps,
});
return {
success: true,
status: 'paused',
runId,
durationMs,
screen: err.screen,
};
}