@objectstack/service-automation 7.3.0 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,22 +1,39 @@
1
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
- }) : x)(function(x) {
4
- if (typeof require !== "undefined") return require.apply(this, arguments);
5
- throw Error('Dynamic require of "' + x + '" is not supported');
6
- });
7
-
8
1
  // src/engine.ts
9
- import { FlowSchema } from "@objectstack/spec/automation";
2
+ import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES } from "@objectstack/spec/automation";
3
+ import { ConnectorSchema } from "@objectstack/spec/integration";
4
+ import { ExpressionEngine } from "@objectstack/formula";
5
+ var FlowSuspendSignal = class {
6
+ constructor(nodeId, correlation, screen) {
7
+ this.nodeId = nodeId;
8
+ this.correlation = correlation;
9
+ this.screen = screen;
10
+ this.__flowSuspend = true;
11
+ }
12
+ };
13
+ function isSuspendSignal(err) {
14
+ return typeof err === "object" && err !== null && err.__flowSuspend === true;
15
+ }
10
16
  var AutomationEngine = class {
11
17
  constructor(logger) {
12
18
  this.flows = /* @__PURE__ */ new Map();
13
19
  this.flowEnabled = /* @__PURE__ */ new Map();
14
20
  this.flowVersionHistory = /* @__PURE__ */ new Map();
15
21
  this.nodeExecutors = /* @__PURE__ */ new Map();
22
+ this.actionDescriptors = /* @__PURE__ */ new Map();
16
23
  this.triggers = /* @__PURE__ */ new Map();
24
+ /**
25
+ * Flows currently wired to a trigger, keyed by flow name → the trigger
26
+ * `type` that owns the binding. Used to avoid double-binding and to know
27
+ * which trigger to `stop()` when a flow is unregistered/disabled.
28
+ */
29
+ this.boundFlowTriggers = /* @__PURE__ */ new Map();
30
+ /** Connectors registered by integration plugins, keyed by connector name (ADR-0018 §Addendum). */
31
+ this.connectors = /* @__PURE__ */ new Map();
17
32
  this.executionLogs = [];
18
33
  this.maxLogSize = 1e3;
19
34
  this.runCounter = 0;
35
+ /** Runs paused at a node, keyed by runId (ADR-0019). In-memory, see {@link SuspendedRun}. */
36
+ this.suspendedRuns = /* @__PURE__ */ new Map();
20
37
  this.logger = logger;
21
38
  }
22
39
  // ── Plugin Extension API ──────────────────────────────
@@ -26,27 +43,200 @@ var AutomationEngine = class {
26
43
  this.logger.warn(`Node executor '${executor.type}' replaced`);
27
44
  }
28
45
  this.nodeExecutors.set(executor.type, executor);
46
+ if (executor.descriptor) {
47
+ const descriptorType = executor.descriptor.type;
48
+ if (descriptorType !== executor.type) {
49
+ this.logger.warn(
50
+ `Node executor '${executor.type}' publishes a descriptor for type '${descriptorType}' \u2014 registering under both.`
51
+ );
52
+ }
53
+ this.actionDescriptors.set(descriptorType, executor.descriptor);
54
+ }
29
55
  this.logger.info(`Node executor registered: ${executor.type}`);
30
56
  }
31
57
  /** Unregister a node executor (hot-unplug) */
32
58
  unregisterNodeExecutor(type) {
59
+ const executor = this.nodeExecutors.get(type);
33
60
  this.nodeExecutors.delete(type);
61
+ this.actionDescriptors.delete(type);
62
+ if (executor?.descriptor) {
63
+ this.actionDescriptors.delete(executor.descriptor.type);
64
+ }
34
65
  this.logger.info(`Node executor unregistered: ${type}`);
35
66
  }
36
67
  /** Register a trigger (called by plugins) */
37
68
  registerTrigger(trigger) {
38
69
  this.triggers.set(trigger.type, trigger);
39
70
  this.logger.info(`Trigger registered: ${trigger.type}`);
71
+ for (const name of this.flows.keys()) {
72
+ if (this.boundFlowTriggers.has(name)) continue;
73
+ const resolved = this.resolveTriggerBinding(name);
74
+ if (resolved?.triggerType === trigger.type) {
75
+ this.activateFlowTrigger(name);
76
+ }
77
+ }
40
78
  }
41
79
  /** Unregister a trigger (hot-unplug) */
42
80
  unregisterTrigger(type) {
81
+ for (const [name, boundType] of [...this.boundFlowTriggers]) {
82
+ if (boundType !== type) continue;
83
+ try {
84
+ this.triggers.get(type)?.stop(name);
85
+ } catch (err) {
86
+ this.logger.warn(`Trigger '${type}' stop('${name}') failed: ${err.message}`);
87
+ }
88
+ this.boundFlowTriggers.delete(name);
89
+ }
43
90
  this.triggers.delete(type);
44
91
  this.logger.info(`Trigger unregistered: ${type}`);
45
92
  }
93
+ /**
94
+ * Derive a flow's trigger binding from its `start` node, or `undefined` if
95
+ * the flow has no auto-trigger (manual / screen / api). The convention —
96
+ * established by the showcase flows — is that the start node carries the
97
+ * trigger details in its `config`: `{ objectName, triggerType, condition }`
98
+ * for record-change, or a `schedule` descriptor for time-based flows.
99
+ */
100
+ resolveTriggerBinding(flowName) {
101
+ const flow = this.flows.get(flowName);
102
+ if (!flow) return void 0;
103
+ const startNode = flow.nodes.find((n) => n.type === "start");
104
+ const config = startNode?.config ?? {};
105
+ const triggerType = typeof config.triggerType === "string" ? config.triggerType : void 0;
106
+ if (triggerType && triggerType.startsWith("record-")) {
107
+ return {
108
+ triggerType: "record_change",
109
+ binding: {
110
+ flowName,
111
+ object: typeof config.objectName === "string" ? config.objectName : void 0,
112
+ event: triggerType,
113
+ condition: config.condition ?? void 0,
114
+ config
115
+ }
116
+ };
117
+ }
118
+ if (config.schedule != null || flow.type === "schedule") {
119
+ return {
120
+ triggerType: "schedule",
121
+ binding: { flowName, schedule: config.schedule, condition: config.condition ?? void 0, config }
122
+ };
123
+ }
124
+ return void 0;
125
+ }
126
+ /**
127
+ * Bind a flow to its matching registered trigger (idempotent). No-op when
128
+ * the flow has no trigger binding or no trigger is registered for its type
129
+ * yet — {@link registerTrigger} re-attempts activation when one arrives.
130
+ */
131
+ activateFlowTrigger(flowName) {
132
+ if (this.boundFlowTriggers.has(flowName)) return;
133
+ const resolved = this.resolveTriggerBinding(flowName);
134
+ if (!resolved) return;
135
+ const trigger = this.triggers.get(resolved.triggerType);
136
+ if (!trigger) return;
137
+ try {
138
+ trigger.start(resolved.binding, (ctx) => this.execute(flowName, ctx).then(() => void 0));
139
+ this.boundFlowTriggers.set(flowName, resolved.triggerType);
140
+ this.logger.info(`Flow '${flowName}' bound to trigger '${resolved.triggerType}'`);
141
+ } catch (err) {
142
+ this.logger.warn(`Failed to bind flow '${flowName}' to trigger '${resolved.triggerType}': ${err.message}`);
143
+ }
144
+ }
145
+ /** Unbind a flow from its trigger, if bound. */
146
+ deactivateFlowTrigger(flowName) {
147
+ const boundType = this.boundFlowTriggers.get(flowName);
148
+ if (!boundType) return;
149
+ try {
150
+ this.triggers.get(boundType)?.stop(flowName);
151
+ } catch (err) {
152
+ this.logger.warn(`Trigger '${boundType}' stop('${flowName}') failed: ${err.message}`);
153
+ }
154
+ this.boundFlowTriggers.delete(flowName);
155
+ }
156
+ /** Active flow→trigger bindings (observability / tests). */
157
+ getActiveTriggerBindings() {
158
+ return [...this.boundFlowTriggers].map(([flowName, triggerType]) => ({ flowName, triggerType }));
159
+ }
160
+ /**
161
+ * Register a connector (called by integration plugins, ADR-0018 §Addendum).
162
+ * Validates the definition against {@link ConnectorSchema} and asserts every
163
+ * declared action has a handler, so a half-wired connector fails loudly at
164
+ * registration rather than silently at dispatch. Re-registering the same
165
+ * name replaces (mirrors {@link registerNodeExecutor}).
166
+ */
167
+ registerConnector(def, handlers) {
168
+ const parsed = ConnectorSchema.parse(def);
169
+ for (const action of parsed.actions ?? []) {
170
+ if (typeof handlers[action.key] !== "function") {
171
+ throw new Error(
172
+ `Connector '${parsed.name}': action '${action.key}' is declared but no handler was provided`
173
+ );
174
+ }
175
+ }
176
+ if (this.connectors.has(parsed.name)) {
177
+ this.logger.warn(`Connector '${parsed.name}' replaced`);
178
+ }
179
+ this.connectors.set(parsed.name, { def: parsed, handlers });
180
+ this.logger.info(
181
+ `Connector registered: ${parsed.name} (${Object.keys(handlers).length} action handlers)`
182
+ );
183
+ }
184
+ /** Unregister a connector (hot-unplug). */
185
+ unregisterConnector(name) {
186
+ this.connectors.delete(name);
187
+ this.logger.info(`Connector unregistered: ${name}`);
188
+ }
189
+ /**
190
+ * Resolve the handler for a connector action, used by the baseline
191
+ * `connector_action` node. Returns `undefined` when the connector or action
192
+ * is not registered, so the node can fail the step with a clear error.
193
+ */
194
+ resolveConnectorAction(connectorId, actionId) {
195
+ return this.connectors.get(connectorId)?.handlers[actionId];
196
+ }
197
+ /** Get all registered connector names. */
198
+ getRegisteredConnectors() {
199
+ return [...this.connectors.keys()];
200
+ }
201
+ /**
202
+ * Get a designer-facing descriptor for every registered connector — its
203
+ * identity plus the actions it exposes (input/output JSON Schema). Backs
204
+ * `GET /api/v1/automation/connectors` so the designer can fill the
205
+ * `connector_action` node's connector / action / input pickers (ADR-0022).
206
+ * Handlers are omitted — they are runtime code, not metadata.
207
+ */
208
+ getConnectorDescriptors() {
209
+ return [...this.connectors.values()].map(({ def }) => ({
210
+ name: def.name,
211
+ label: def.label,
212
+ type: def.type,
213
+ description: def.description,
214
+ icon: def.icon,
215
+ actions: (def.actions ?? []).map((a) => ({
216
+ key: a.key,
217
+ label: a.label,
218
+ description: a.description,
219
+ inputSchema: a.inputSchema,
220
+ outputSchema: a.outputSchema
221
+ }))
222
+ }));
223
+ }
46
224
  /** Get all registered node types */
47
225
  getRegisteredNodeTypes() {
48
226
  return [...this.nodeExecutors.keys()];
49
227
  }
228
+ /**
229
+ * Get all published action descriptors (ADR-0018). Backs both flow
230
+ * validation and the designer palette (`GET /api/v1/automation/actions`).
231
+ * Only executors that published a descriptor appear here.
232
+ */
233
+ getActionDescriptors() {
234
+ return [...this.actionDescriptors.values()];
235
+ }
236
+ /** Get the action descriptor for a single node type, if published. */
237
+ getActionDescriptor(type) {
238
+ return this.actionDescriptors.get(type);
239
+ }
50
240
  /** Get all registered trigger types */
51
241
  getRegisteredTriggerTypes() {
52
242
  return [...this.triggers.keys()];
@@ -55,6 +245,7 @@ var AutomationEngine = class {
55
245
  registerFlow(name, definition) {
56
246
  const parsed = FlowSchema.parse(definition);
57
247
  this.detectCycles(parsed);
248
+ this.validateNodeTypes(name, parsed);
58
249
  const history = this.flowVersionHistory.get(name) ?? [];
59
250
  history.push({
60
251
  version: parsed.version,
@@ -67,8 +258,13 @@ var AutomationEngine = class {
67
258
  this.flowEnabled.set(name, true);
68
259
  }
69
260
  this.logger.info(`Flow registered: ${name} (version ${parsed.version})`);
261
+ this.deactivateFlowTrigger(name);
262
+ if (this.flowEnabled.get(name) !== false) {
263
+ this.activateFlowTrigger(name);
264
+ }
70
265
  }
71
266
  unregisterFlow(name) {
267
+ this.deactivateFlowTrigger(name);
72
268
  this.flows.delete(name);
73
269
  this.flowEnabled.delete(name);
74
270
  this.flowVersionHistory.delete(name);
@@ -86,6 +282,11 @@ var AutomationEngine = class {
86
282
  }
87
283
  this.flowEnabled.set(name, enabled);
88
284
  this.logger.info(`Flow '${name}' ${enabled ? "enabled" : "disabled"}`);
285
+ if (enabled) {
286
+ this.activateFlowTrigger(name);
287
+ } else {
288
+ this.deactivateFlowTrigger(name);
289
+ }
89
290
  }
90
291
  /** Get flow version history */
91
292
  getFlowVersionHistory(name) {
@@ -131,8 +332,16 @@ var AutomationEngine = class {
131
332
  }
132
333
  if (context?.record) {
133
334
  variables.set("$record", context.record);
335
+ variables.set("record", context.record);
336
+ for (const [k, v] of Object.entries(context.record)) {
337
+ if (!variables.has(k)) variables.set(k, v);
338
+ }
339
+ }
340
+ if (context?.previous) {
341
+ variables.set("previous", context.previous);
134
342
  }
135
343
  const runId = `run_${++this.runCounter}`;
344
+ variables.set("$runId", runId);
136
345
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
137
346
  const steps = [];
138
347
  try {
@@ -140,6 +349,14 @@ var AutomationEngine = class {
140
349
  if (!startNode) {
141
350
  return { success: false, error: "Flow has no start node" };
142
351
  }
352
+ const startCondition = startNode.config?.condition;
353
+ if (startCondition !== void 0 && startCondition !== null && startCondition !== "") {
354
+ const condExpr = typeof startCondition === "string" ? { dialect: "cel", source: startCondition } : startCondition;
355
+ if (!this.evaluateCondition(condExpr, variables)) {
356
+ this.logger.debug(`Flow '${flowName}' skipped: start condition not met`);
357
+ return { success: true, output: { skipped: true, reason: "condition_not_met" } };
358
+ }
359
+ }
143
360
  this.validateNodeInputSchemas(flow, variables);
144
361
  await this.executeNode(startNode, flow, variables, context ?? {}, steps);
145
362
  const output = {};
@@ -173,6 +390,43 @@ var AutomationEngine = class {
173
390
  durationMs
174
391
  };
175
392
  } catch (err) {
393
+ if (isSuspendSignal(err)) {
394
+ const durationMs2 = Date.now() - startTime;
395
+ this.suspendedRuns.set(runId, {
396
+ runId,
397
+ flowName,
398
+ flowVersion: flow.version,
399
+ nodeId: err.nodeId,
400
+ variables: Object.fromEntries(variables),
401
+ steps,
402
+ context: context ?? {},
403
+ startedAt,
404
+ startTime,
405
+ correlation: err.correlation,
406
+ screen: err.screen
407
+ });
408
+ this.recordLog({
409
+ id: runId,
410
+ flowName,
411
+ flowVersion: flow.version,
412
+ status: "paused",
413
+ startedAt,
414
+ durationMs: durationMs2,
415
+ trigger: {
416
+ type: context?.event ?? "manual",
417
+ userId: context?.userId,
418
+ object: context?.object
419
+ },
420
+ steps
421
+ });
422
+ return {
423
+ success: true,
424
+ status: "paused",
425
+ runId,
426
+ durationMs: durationMs2,
427
+ screen: err.screen
428
+ };
429
+ }
176
430
  const errorMessage = err instanceof Error ? err.message : String(err);
177
431
  const durationMs = Date.now() - startTime;
178
432
  this.recordLog({
@@ -201,6 +455,135 @@ var AutomationEngine = class {
201
455
  };
202
456
  }
203
457
  }
458
+ /**
459
+ * Resume a run suspended at a node (ADR-0019 durable pause). Restores the
460
+ * snapshotted variables, merges `signal.output` under the suspended node's
461
+ * id, and continues traversal from that node's out-edges — optionally
462
+ * restricted to the edge labelled `signal.branchLabel` (e.g. the approval
463
+ * decision). The continuation may itself suspend again, in which case this
464
+ * returns `{ status: 'paused', runId }` afresh.
465
+ */
466
+ async resume(runId, signal) {
467
+ const run = this.suspendedRuns.get(runId);
468
+ if (!run) {
469
+ return { success: false, error: `No suspended run '${runId}'` };
470
+ }
471
+ const flow = this.flows.get(run.flowName);
472
+ if (!flow) {
473
+ return { success: false, error: `Flow '${run.flowName}' not found for run '${runId}'` };
474
+ }
475
+ const node = flow.nodes.find((n) => n.id === run.nodeId);
476
+ if (!node) {
477
+ return { success: false, error: `Suspended node '${run.nodeId}' no longer exists in flow '${run.flowName}'` };
478
+ }
479
+ this.suspendedRuns.delete(runId);
480
+ const variables = new Map(Object.entries(run.variables));
481
+ if (signal?.output) {
482
+ for (const [key, value] of Object.entries(signal.output)) {
483
+ variables.set(`${run.nodeId}.${key}`, value);
484
+ }
485
+ }
486
+ if (signal?.variables) {
487
+ for (const [key, value] of Object.entries(signal.variables)) {
488
+ variables.set(key, value);
489
+ }
490
+ }
491
+ const steps = run.steps;
492
+ const context = run.context;
493
+ try {
494
+ await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
495
+ const output = {};
496
+ if (flow.variables) {
497
+ for (const v of flow.variables) {
498
+ if (v.isOutput) output[v.name] = variables.get(v.name);
499
+ }
500
+ }
501
+ const durationMs = Date.now() - run.startTime;
502
+ this.recordLog({
503
+ id: runId,
504
+ flowName: run.flowName,
505
+ flowVersion: run.flowVersion,
506
+ status: "completed",
507
+ startedAt: run.startedAt,
508
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
509
+ durationMs,
510
+ trigger: {
511
+ type: context.event ?? "manual",
512
+ userId: context.userId,
513
+ object: context.object
514
+ },
515
+ steps,
516
+ output
517
+ });
518
+ return { success: true, output, durationMs };
519
+ } catch (err) {
520
+ if (isSuspendSignal(err)) {
521
+ const durationMs2 = Date.now() - run.startTime;
522
+ this.suspendedRuns.set(runId, {
523
+ ...run,
524
+ nodeId: err.nodeId,
525
+ variables: Object.fromEntries(variables),
526
+ steps,
527
+ correlation: err.correlation,
528
+ screen: err.screen
529
+ });
530
+ this.recordLog({
531
+ id: runId,
532
+ flowName: run.flowName,
533
+ flowVersion: run.flowVersion,
534
+ status: "paused",
535
+ startedAt: run.startedAt,
536
+ durationMs: durationMs2,
537
+ trigger: {
538
+ type: context.event ?? "manual",
539
+ userId: context.userId,
540
+ object: context.object
541
+ },
542
+ steps
543
+ });
544
+ return { success: true, status: "paused", runId, durationMs: durationMs2, screen: err.screen };
545
+ }
546
+ const errorMessage = err instanceof Error ? err.message : String(err);
547
+ const durationMs = Date.now() - run.startTime;
548
+ this.recordLog({
549
+ id: runId,
550
+ flowName: run.flowName,
551
+ flowVersion: run.flowVersion,
552
+ status: "failed",
553
+ startedAt: run.startedAt,
554
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
555
+ durationMs,
556
+ trigger: {
557
+ type: context.event ?? "manual",
558
+ userId: context.userId,
559
+ object: context.object
560
+ },
561
+ steps,
562
+ error: errorMessage
563
+ });
564
+ return { success: false, error: errorMessage, durationMs };
565
+ }
566
+ }
567
+ /**
568
+ * List the runs currently suspended awaiting {@link resume} (ADR-0019).
569
+ * Backs operability surfaces such as a "pending approvals" view.
570
+ */
571
+ listSuspendedRuns() {
572
+ return [...this.suspendedRuns.values()].map((r) => ({
573
+ runId: r.runId,
574
+ flowName: r.flowName,
575
+ nodeId: r.nodeId,
576
+ correlation: r.correlation
577
+ }));
578
+ }
579
+ /**
580
+ * The screen a paused run is currently waiting on (screen-flow runtime), or
581
+ * `null` if the run isn't suspended / didn't pause at a screen node. Lets a
582
+ * UI flow-runner re-fetch the form after a refresh.
583
+ */
584
+ getSuspendedScreen(runId) {
585
+ return this.suspendedRuns.get(runId)?.screen ?? null;
586
+ }
204
587
  // ── DAG Traversal Core ──────────────────────────────────
205
588
  recordLog(entry) {
206
589
  this.executionLogs.push(entry);
@@ -208,6 +591,29 @@ var AutomationEngine = class {
208
591
  this.executionLogs.splice(0, this.executionLogs.length - this.maxLogSize);
209
592
  }
210
593
  }
594
+ /**
595
+ * Validate each node's `type` against the live action registry (ADR-0018).
596
+ * A type is known if it is structural (start/end), has a registered
597
+ * executor, or has a published action descriptor. Unknown types are
598
+ * warned about (not rejected) so flows authored against a temporarily
599
+ * absent plugin still register; the runtime surfaces a hard NO_EXECUTOR
600
+ * error if such a node is actually executed.
601
+ */
602
+ validateNodeTypes(flowName, flow) {
603
+ const known = /* @__PURE__ */ new Set([
604
+ ...FLOW_STRUCTURAL_NODE_TYPES,
605
+ ...this.nodeExecutors.keys(),
606
+ ...this.actionDescriptors.keys()
607
+ ]);
608
+ const unknown = [...new Set(
609
+ flow.nodes.map((n) => n.type).filter((t) => !known.has(t))
610
+ )];
611
+ if (unknown.length > 0) {
612
+ this.logger.warn(
613
+ `Flow '${flowName}' references node type(s) with no registered executor or descriptor: ${unknown.join(", ")}. They will fail at execution time unless a plugin registers them. Registered types: ${[...known].join(", ") || "(none)"}`
614
+ );
615
+ }
616
+ }
211
617
  /**
212
618
  * Detect cycles in the flow graph (DAG validation).
213
619
  * Uses DFS with coloring (white/gray/black) to detect back edges.
@@ -389,10 +795,30 @@ var AutomationEngine = class {
389
795
  variables.set(`${node.id}.${key}`, value);
390
796
  }
391
797
  }
798
+ if (result.suspend) {
799
+ throw new FlowSuspendSignal(node.id, result.correlation, result.screen);
800
+ }
392
801
  }
393
- const outEdges = flow.edges.filter(
802
+ await this.traverseNext(node, flow, variables, context, steps);
803
+ }
804
+ /**
805
+ * Traverse a node's out-edges and execute its successors. Split out of
806
+ * {@link executeNode} so {@link resume} can re-enter traversal from a
807
+ * suspended node without re-running the node body.
808
+ *
809
+ * @param branchLabel - When set (e.g. from a resume signal), restrict
810
+ * traversal to out-edges whose `label` matches — this is how an Approval
811
+ * node's `approve`/`reject` decision selects its downstream branch. When
812
+ * no edge carries the label, traversal falls back to the normal edge set.
813
+ */
814
+ async traverseNext(node, flow, variables, context, steps, branchLabel) {
815
+ let outEdges = flow.edges.filter(
394
816
  (e) => e.source === node.id && e.type !== "fault"
395
817
  );
818
+ if (branchLabel) {
819
+ const labeled = outEdges.filter((e) => e.label === branchLabel);
820
+ if (labeled.length > 0) outEdges = labeled;
821
+ }
396
822
  const conditionalEdges = [];
397
823
  const unconditionalEdges = [];
398
824
  for (const edge of outEdges) {
@@ -441,7 +867,6 @@ var AutomationEngine = class {
441
867
  }
442
868
  if (dialect === "cel" || isEnvelope && !dialect) {
443
869
  try {
444
- const { ExpressionEngine } = __require("@objectstack/formula");
445
870
  const vars = {};
446
871
  for (const [key, value] of variables) {
447
872
  const segs = key.split(".");
@@ -456,7 +881,7 @@ var AutomationEngine = class {
456
881
  }
457
882
  const result = ExpressionEngine.evaluate(
458
883
  { dialect: "cel", source: exprStr },
459
- { extra: { vars }, record: vars }
884
+ { extra: { ...vars, vars }, record: vars }
460
885
  );
461
886
  if (!result.ok) return false;
462
887
  return Boolean(result.value);
@@ -639,76 +1064,81 @@ var AutomationEngine = class {
639
1064
  }
640
1065
  };
641
1066
 
642
- // src/plugin.ts
643
- var AutomationServicePlugin = class {
644
- constructor(options = {}) {
645
- this.name = "com.objectstack.service-automation";
646
- this.version = "1.0.0";
647
- this.type = "standard";
648
- // Soft dependency on metadata: we look it up at start() and tolerate absence.
649
- // Do NOT declare a hard kernel dependency, so this plugin works in environments
650
- // where MetadataPlugin is not registered.
651
- this.dependencies = [];
652
- this.options = options;
653
- }
654
- async init(ctx) {
655
- this.engine = new AutomationEngine(ctx.logger);
656
- ctx.registerService("automation", this.engine);
657
- if (this.options.debug) {
658
- ctx.hook("automation:beforeExecute", async (flowName) => {
659
- ctx.logger.debug(`[Automation] Before execute: ${flowName}`);
660
- });
661
- }
662
- ctx.logger.info("[Automation] Engine initialized");
663
- }
664
- async start(ctx) {
665
- console.warn("[Automation:start] entering start()");
666
- if (!this.engine) {
667
- console.warn("[Automation:start] engine missing, bailing");
668
- return;
1067
+ // src/builtin/logic-nodes.ts
1068
+ import { defineActionDescriptor } from "@objectstack/spec/automation";
1069
+ function registerLogicNodes(engine, ctx) {
1070
+ engine.registerNodeExecutor({
1071
+ type: "decision",
1072
+ descriptor: defineActionDescriptor({
1073
+ type: "decision",
1074
+ version: "1.0.0",
1075
+ name: "Decision",
1076
+ description: "Branch execution based on conditions.",
1077
+ icon: "git-branch",
1078
+ category: "logic",
1079
+ source: "builtin"
1080
+ }),
1081
+ async execute(node, variables, _context) {
1082
+ const config = node.config;
1083
+ const conditions = config?.conditions ?? [];
1084
+ for (const cond of conditions) {
1085
+ if (engine.evaluateCondition(cond.expression, variables)) {
1086
+ return { success: true, branchLabel: cond.label };
1087
+ }
1088
+ }
1089
+ return { success: true, branchLabel: "default" };
669
1090
  }
670
- await ctx.trigger("automation:ready", this.engine);
671
- const nodeTypes = this.engine.getRegisteredNodeTypes();
672
- ctx.logger.info(
673
- `[Automation] Engine started with ${nodeTypes.length} node types: ${nodeTypes.join(", ") || "(none)"}`
674
- );
675
- try {
676
- const ql = ctx.getService("objectql");
677
- if (!ql) {
678
- console.warn("[Automation] objectql service not found at start()");
679
- } else if (!ql.registry) {
680
- console.warn("[Automation] objectql.registry is undefined at start()");
681
- } else if (typeof ql.registry.listItems !== "function") {
682
- console.warn("[Automation] objectql.registry.listItems is not a function");
1091
+ });
1092
+ engine.registerNodeExecutor({
1093
+ type: "assignment",
1094
+ descriptor: defineActionDescriptor({
1095
+ type: "assignment",
1096
+ version: "1.0.0",
1097
+ name: "Assignment",
1098
+ description: "Set flow variables.",
1099
+ icon: "variable",
1100
+ category: "logic",
1101
+ source: "builtin"
1102
+ }),
1103
+ async execute(node, variables, _context) {
1104
+ const config = node.config ?? {};
1105
+ for (const [key, value] of Object.entries(config)) {
1106
+ variables.set(key, value);
683
1107
  }
684
- const flows = ql?.registry?.listItems?.("flow") ?? [];
685
- console.warn(`[Automation] flow pull: registry returned ${flows.length} flow(s)`);
686
- let registered = 0;
687
- for (const f of flows) {
688
- const def = f;
689
- if (!def?.name) continue;
690
- try {
691
- this.engine.registerFlow(def.name, def);
692
- registered++;
693
- } catch (e) {
694
- const msg = e instanceof Error ? e.message : String(e);
695
- ctx.logger.warn(`[Automation] failed to register flow ${def.name}: ${msg}`);
1108
+ return { success: true };
1109
+ }
1110
+ });
1111
+ engine.registerNodeExecutor({
1112
+ type: "loop",
1113
+ descriptor: defineActionDescriptor({
1114
+ type: "loop",
1115
+ version: "1.0.0",
1116
+ name: "Loop",
1117
+ description: "Iterate over a collection.",
1118
+ icon: "repeat",
1119
+ category: "logic",
1120
+ source: "builtin"
1121
+ }),
1122
+ async execute(node, variables, _context) {
1123
+ const config = node.config;
1124
+ const collectionName = config?.collection;
1125
+ if (collectionName) {
1126
+ const collection = variables.get(collectionName);
1127
+ if (Array.isArray(collection)) {
1128
+ variables.set("$loopItems", collection);
1129
+ variables.set("$loopIndex", 0);
696
1130
  }
697
1131
  }
698
- if (registered > 0) {
699
- ctx.logger.info(`[Automation] Pulled ${registered} flow(s) from ObjectQL registry`);
700
- }
701
- } catch (err) {
702
- const msg = err instanceof Error ? err.message : String(err);
703
- ctx.logger.warn(`[Automation] flow pull from ObjectQL registry failed: ${msg}`);
1132
+ return { success: true };
704
1133
  }
705
- }
706
- async destroy() {
707
- this.engine = void 0;
708
- }
709
- };
1134
+ });
1135
+ ctx.logger.info("[Logic Nodes] 3 built-in node executors registered");
1136
+ }
710
1137
 
711
- // src/plugins/template.ts
1138
+ // src/builtin/crud-nodes.ts
1139
+ import { defineActionDescriptor as defineActionDescriptor2 } from "@objectstack/spec/automation";
1140
+
1141
+ // src/builtin/template.ts
712
1142
  function resolvePath(base, path) {
713
1143
  let cur = base;
714
1144
  for (const seg of path) {
@@ -805,268 +1235,497 @@ function interpolate(value, variables, context) {
805
1235
  return value;
806
1236
  }
807
1237
 
808
- // src/plugins/crud-nodes-plugin.ts
809
- var CrudNodesPlugin = class {
810
- constructor() {
811
- this.name = "com.objectstack.automation.crud-nodes";
812
- this.version = "1.0.0";
813
- this.type = "standard";
814
- this.dependencies = ["com.objectstack.service-automation"];
815
- }
816
- async init(ctx) {
817
- const engine = ctx.getService("automation");
818
- const getData = () => {
819
- try {
820
- return ctx.getService("data") ?? ctx.getService("objectql");
821
- } catch {
822
- return void 0;
823
- }
824
- };
825
- engine.registerNodeExecutor({
1238
+ // src/builtin/crud-nodes.ts
1239
+ function registerCrudNodes(engine, ctx) {
1240
+ const getData = () => {
1241
+ try {
1242
+ return ctx.getService("data") ?? ctx.getService("objectql");
1243
+ } catch {
1244
+ return void 0;
1245
+ }
1246
+ };
1247
+ engine.registerNodeExecutor({
1248
+ type: "get_record",
1249
+ descriptor: defineActionDescriptor2({
826
1250
  type: "get_record",
827
- async execute(node, variables, context) {
828
- const cfg = node.config ?? {};
829
- const objectName = String(cfg.objectName ?? cfg.object ?? "");
830
- if (!objectName) return { success: false, error: "get_record: objectName required" };
831
- const filter = interpolate(cfg.filter ?? cfg.filters ?? {}, variables, context);
832
- const fields = cfg.fields;
833
- const limit = typeof cfg.limit === "number" ? cfg.limit : void 0;
834
- const outputVariable = cfg.outputVariable;
835
- const data = getData();
836
- if (!data) {
837
- ctx.logger.warn(`[get_record] no data engine; skipping ${objectName}`);
838
- return { success: true, output: { records: [], object: objectName } };
839
- }
840
- try {
841
- if (limit && limit > 1) {
842
- const records = await data.find(objectName, { where: filter, fields, limit });
843
- if (outputVariable) variables.set(outputVariable, records);
844
- return { success: true, output: { records, object: objectName } };
845
- }
846
- const record = await data.findOne(objectName, { where: filter, fields });
847
- if (outputVariable) variables.set(outputVariable, record);
848
- return { success: true, output: { record, id: record?.id, object: objectName } };
849
- } catch (err) {
850
- return { success: false, error: `get_record(${objectName}) failed: ${err.message}` };
1251
+ version: "1.0.0",
1252
+ name: "Get Records",
1253
+ description: "Query records from an object.",
1254
+ icon: "search",
1255
+ category: "data",
1256
+ source: "builtin"
1257
+ }),
1258
+ async execute(node, variables, context) {
1259
+ const cfg = node.config ?? {};
1260
+ const objectName = String(cfg.objectName ?? cfg.object ?? "");
1261
+ if (!objectName) return { success: false, error: "get_record: objectName required" };
1262
+ const filter = interpolate(cfg.filter ?? cfg.filters ?? {}, variables, context);
1263
+ const fields = cfg.fields;
1264
+ const limit = typeof cfg.limit === "number" ? cfg.limit : void 0;
1265
+ const outputVariable = cfg.outputVariable;
1266
+ const data = getData();
1267
+ if (!data) {
1268
+ ctx.logger.warn(`[get_record] no data engine; skipping ${objectName}`);
1269
+ return { success: true, output: { records: [], object: objectName } };
1270
+ }
1271
+ try {
1272
+ if (limit && limit > 1) {
1273
+ const records = await data.find(objectName, { where: filter, fields, limit });
1274
+ if (outputVariable) variables.set(outputVariable, records);
1275
+ return { success: true, output: { records, object: objectName } };
851
1276
  }
1277
+ const record = await data.findOne(objectName, { where: filter, fields });
1278
+ if (outputVariable) variables.set(outputVariable, record);
1279
+ return { success: true, output: { record, id: record?.id, object: objectName } };
1280
+ } catch (err) {
1281
+ return { success: false, error: `get_record(${objectName}) failed: ${err.message}` };
852
1282
  }
853
- });
854
- engine.registerNodeExecutor({
1283
+ }
1284
+ });
1285
+ engine.registerNodeExecutor({
1286
+ type: "create_record",
1287
+ descriptor: defineActionDescriptor2({
855
1288
  type: "create_record",
856
- async execute(node, variables, context) {
857
- const cfg = node.config ?? {};
858
- const objectName = String(cfg.objectName ?? cfg.object ?? "");
859
- if (!objectName) return { success: false, error: "create_record: objectName required" };
860
- const fields = interpolate(cfg.fields ?? {}, variables, context);
861
- const outputVariable = cfg.outputVariable;
862
- const data = getData();
863
- if (!data) {
864
- ctx.logger.warn(`[create_record] no data engine; skipping ${objectName}`);
865
- if (outputVariable) variables.set(outputVariable, `mock-${objectName}-${Date.now()}`);
866
- return { success: true, output: { id: `mock-${objectName}-${Date.now()}`, object: objectName } };
867
- }
868
- try {
869
- const created = await data.insert(objectName, fields);
870
- const insertedId = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
871
- if (outputVariable) variables.set(outputVariable, insertedId);
872
- return { success: true, output: { id: insertedId, record: created, object: objectName } };
873
- } catch (err) {
874
- return { success: false, error: `create_record(${objectName}) failed: ${err.message}` };
875
- }
1289
+ version: "1.0.0",
1290
+ name: "Create Record",
1291
+ description: "Insert a new record into an object.",
1292
+ icon: "plus-circle",
1293
+ category: "data",
1294
+ source: "builtin"
1295
+ }),
1296
+ async execute(node, variables, context) {
1297
+ const cfg = node.config ?? {};
1298
+ const objectName = String(cfg.objectName ?? cfg.object ?? "");
1299
+ if (!objectName) return { success: false, error: "create_record: objectName required" };
1300
+ const fields = interpolate(cfg.fields ?? {}, variables, context);
1301
+ const outputVariable = cfg.outputVariable;
1302
+ const data = getData();
1303
+ if (!data) {
1304
+ ctx.logger.warn(`[create_record] no data engine; skipping ${objectName}`);
1305
+ if (outputVariable) variables.set(outputVariable, `mock-${objectName}-${Date.now()}`);
1306
+ return { success: true, output: { id: `mock-${objectName}-${Date.now()}`, object: objectName } };
876
1307
  }
877
- });
878
- engine.registerNodeExecutor({
1308
+ try {
1309
+ const created = await data.insert(objectName, fields);
1310
+ const insertedId = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
1311
+ if (outputVariable) variables.set(outputVariable, insertedId);
1312
+ return { success: true, output: { id: insertedId, record: created, object: objectName } };
1313
+ } catch (err) {
1314
+ return { success: false, error: `create_record(${objectName}) failed: ${err.message}` };
1315
+ }
1316
+ }
1317
+ });
1318
+ engine.registerNodeExecutor({
1319
+ type: "update_record",
1320
+ descriptor: defineActionDescriptor2({
879
1321
  type: "update_record",
880
- async execute(node, variables, context) {
881
- const cfg = node.config ?? {};
882
- const objectName = String(cfg.objectName ?? cfg.object ?? "");
883
- if (!objectName) return { success: false, error: "update_record: objectName required" };
884
- const filter = interpolate(cfg.filter ?? cfg.filters ?? {}, variables, context);
885
- const fields = interpolate(cfg.fields ?? {}, variables, context);
886
- const data = getData();
887
- if (!data) {
888
- ctx.logger.warn(`[update_record] no data engine; skipping ${objectName}`);
889
- return { success: true };
890
- }
891
- try {
892
- const result = await data.update(objectName, fields, { where: filter });
893
- return { success: true, output: { result, object: objectName } };
894
- } catch (err) {
895
- return { success: false, error: `update_record(${objectName}) failed: ${err.message}` };
896
- }
1322
+ version: "1.0.0",
1323
+ name: "Update Records",
1324
+ description: "Update records matching a filter.",
1325
+ icon: "edit",
1326
+ category: "data",
1327
+ source: "builtin"
1328
+ }),
1329
+ async execute(node, variables, context) {
1330
+ const cfg = node.config ?? {};
1331
+ const objectName = String(cfg.objectName ?? cfg.object ?? "");
1332
+ if (!objectName) return { success: false, error: "update_record: objectName required" };
1333
+ const filter = interpolate(cfg.filter ?? cfg.filters ?? {}, variables, context);
1334
+ const fields = interpolate(cfg.fields ?? {}, variables, context);
1335
+ const data = getData();
1336
+ if (!data) {
1337
+ ctx.logger.warn(`[update_record] no data engine; skipping ${objectName}`);
1338
+ return { success: true };
897
1339
  }
898
- });
899
- engine.registerNodeExecutor({
1340
+ try {
1341
+ const result = await data.update(objectName, fields, { where: filter });
1342
+ return { success: true, output: { result, object: objectName } };
1343
+ } catch (err) {
1344
+ return { success: false, error: `update_record(${objectName}) failed: ${err.message}` };
1345
+ }
1346
+ }
1347
+ });
1348
+ engine.registerNodeExecutor({
1349
+ type: "delete_record",
1350
+ descriptor: defineActionDescriptor2({
900
1351
  type: "delete_record",
901
- async execute(node, variables, context) {
902
- const cfg = node.config ?? {};
903
- const objectName = String(cfg.objectName ?? cfg.object ?? "");
904
- if (!objectName) return { success: false, error: "delete_record: objectName required" };
905
- const filter = interpolate(cfg.filter ?? cfg.filters ?? {}, variables, context);
906
- const data = getData();
907
- if (!data) return { success: true };
908
- try {
909
- const result = await data.delete(objectName, { where: filter });
910
- return { success: true, output: { result, object: objectName } };
911
- } catch (err) {
912
- return { success: false, error: `delete_record(${objectName}) failed: ${err.message}` };
913
- }
1352
+ version: "1.0.0",
1353
+ name: "Delete Records",
1354
+ description: "Delete records matching a filter.",
1355
+ icon: "trash",
1356
+ category: "data",
1357
+ source: "builtin"
1358
+ }),
1359
+ async execute(node, variables, context) {
1360
+ const cfg = node.config ?? {};
1361
+ const objectName = String(cfg.objectName ?? cfg.object ?? "");
1362
+ if (!objectName) return { success: false, error: "delete_record: objectName required" };
1363
+ const filter = interpolate(cfg.filter ?? cfg.filters ?? {}, variables, context);
1364
+ const data = getData();
1365
+ if (!data) return { success: true };
1366
+ try {
1367
+ const result = await data.delete(objectName, { where: filter });
1368
+ return { success: true, output: { result, object: objectName } };
1369
+ } catch (err) {
1370
+ return { success: false, error: `delete_record(${objectName}) failed: ${err.message}` };
914
1371
  }
915
- });
916
- ctx.logger.info("[CRUD Nodes] 4 node executors registered (data-backed)");
917
- }
918
- };
1372
+ }
1373
+ });
1374
+ ctx.logger.info("[CRUD Nodes] 4 built-in node executors registered (data-backed)");
1375
+ }
919
1376
 
920
- // src/plugins/logic-nodes-plugin.ts
921
- var LogicNodesPlugin = class {
922
- constructor() {
923
- this.name = "com.objectstack.automation.logic-nodes";
924
- this.version = "1.0.0";
925
- this.type = "standard";
926
- this.dependencies = ["com.objectstack.service-automation"];
927
- }
928
- async init(ctx) {
929
- const engine = ctx.getService("automation");
930
- engine.registerNodeExecutor({
931
- type: "decision",
932
- async execute(node, variables, _context) {
933
- const config = node.config;
934
- const conditions = config?.conditions ?? [];
935
- for (const cond of conditions) {
936
- if (engine.evaluateCondition(cond.expression, variables)) {
937
- return { success: true, branchLabel: cond.label };
938
- }
939
- }
940
- return { success: true, branchLabel: "default" };
941
- }
942
- });
943
- engine.registerNodeExecutor({
944
- type: "assignment",
945
- async execute(node, variables, _context) {
946
- const config = node.config ?? {};
947
- for (const [key, value] of Object.entries(config)) {
948
- variables.set(key, value);
949
- }
1377
+ // src/builtin/screen-nodes.ts
1378
+ import { defineActionDescriptor as defineActionDescriptor3 } from "@objectstack/spec/automation";
1379
+ function registerScreenNodes(engine, ctx) {
1380
+ engine.registerNodeExecutor({
1381
+ type: "screen",
1382
+ descriptor: defineActionDescriptor3({
1383
+ type: "screen",
1384
+ version: "1.0.0",
1385
+ name: "Screen",
1386
+ description: "Collect user input via a screen (human-input element).",
1387
+ icon: "window",
1388
+ category: "human",
1389
+ source: "builtin",
1390
+ // Human-input nodes suspend the flow awaiting input.
1391
+ supportsPause: true,
1392
+ isAsync: true
1393
+ }),
1394
+ async execute(node, _variables, _context) {
1395
+ const cfg = node.config ?? {};
1396
+ const rawFields = Array.isArray(cfg.fields) ? cfg.fields : [];
1397
+ const hasFields = rawFields.length > 0;
1398
+ const shouldPause = cfg.waitForInput === true || hasFields && cfg.waitForInput !== false;
1399
+ if (!shouldPause) {
950
1400
  return { success: true };
951
1401
  }
952
- });
953
- engine.registerNodeExecutor({
954
- type: "loop",
955
- async execute(node, variables, _context) {
956
- const config = node.config;
957
- const collectionName = config?.collection;
958
- if (collectionName) {
959
- const collection = variables.get(collectionName);
960
- if (Array.isArray(collection)) {
961
- variables.set("$loopItems", collection);
962
- variables.set("$loopIndex", 0);
963
- }
1402
+ const fields = rawFields.map((f) => ({
1403
+ name: String(f.name ?? ""),
1404
+ label: f.label != null ? String(f.label) : void 0,
1405
+ type: f.type != null ? String(f.type) : void 0,
1406
+ required: f.required === true,
1407
+ options: Array.isArray(f.options) ? f.options : void 0,
1408
+ defaultValue: f.defaultValue,
1409
+ placeholder: f.placeholder != null ? String(f.placeholder) : void 0
1410
+ })).filter((f) => f.name.length > 0);
1411
+ return {
1412
+ success: true,
1413
+ suspend: true,
1414
+ screen: {
1415
+ nodeId: node.id,
1416
+ title: cfg.title ?? node.label ?? "Input",
1417
+ description: cfg.description,
1418
+ fields
964
1419
  }
965
- return { success: true };
1420
+ };
1421
+ }
1422
+ });
1423
+ engine.registerNodeExecutor({
1424
+ type: "script",
1425
+ descriptor: defineActionDescriptor3({
1426
+ type: "script",
1427
+ version: "1.0.0",
1428
+ name: "Script",
1429
+ description: "Run a custom script action.",
1430
+ icon: "code",
1431
+ category: "logic",
1432
+ source: "builtin"
1433
+ }),
1434
+ async execute(node, _variables, _context) {
1435
+ const cfg = node.config ?? {};
1436
+ const actionType = cfg.actionType ?? "noop";
1437
+ if (actionType === "email") {
1438
+ ctx.logger.info(
1439
+ `[Script:email] template=${String(cfg.template)} recipients=${JSON.stringify(cfg.recipients)} vars=${JSON.stringify(cfg.variables)}`
1440
+ );
1441
+ return {
1442
+ success: true,
1443
+ output: {
1444
+ actionType,
1445
+ template: cfg.template,
1446
+ recipients: cfg.recipients
1447
+ }
1448
+ };
966
1449
  }
967
- });
968
- ctx.logger.info("[Logic Nodes] 3 node executors registered");
969
- }
970
- };
1450
+ ctx.logger.info(`[Script:${actionType}] node=${node.id} executed (no-op handler)`);
1451
+ return { success: true, output: { actionType } };
1452
+ }
1453
+ });
1454
+ ctx.logger.info("[Screen/Script Nodes] 2 built-in node executors registered");
1455
+ }
971
1456
 
972
- // src/plugins/http-connector-plugin.ts
973
- var HttpConnectorPlugin = class {
974
- constructor() {
975
- this.name = "com.objectstack.automation.http-connector";
976
- this.version = "1.0.0";
977
- this.type = "standard";
978
- this.dependencies = ["com.objectstack.service-automation"];
979
- }
980
- async init(ctx) {
981
- const engine = ctx.getService("automation");
982
- engine.registerNodeExecutor({
1457
+ // src/builtin/http-nodes.ts
1458
+ import { defineActionDescriptor as defineActionDescriptor4 } from "@objectstack/spec/automation";
1459
+ function registerHttpNodes(engine, ctx) {
1460
+ engine.registerNodeExecutor({
1461
+ type: "http_request",
1462
+ descriptor: defineActionDescriptor4({
983
1463
  type: "http_request",
984
- async execute(node, _variables, _context) {
985
- const config = node.config;
986
- const url = config?.url;
987
- const method = config?.method ?? "GET";
988
- const headers = config?.headers;
989
- const body = config?.body;
990
- if (!url) {
991
- return { success: false, error: "http_request: url is required" };
1464
+ version: "1.0.0",
1465
+ name: "HTTP Request",
1466
+ description: "Call an external HTTP endpoint. (ADR-0018: migrates to outbox-backed `http`.)",
1467
+ icon: "globe",
1468
+ category: "io",
1469
+ source: "builtin",
1470
+ // ADR-0018 §M3 target: route via service-messaging outbox for
1471
+ // retry/idempotency/dead-letter. Today this is a bare fetch().
1472
+ needsOutbox: false,
1473
+ supportsRetry: true,
1474
+ paradigms: ["flow", "workflow_rule", "approval"]
1475
+ }),
1476
+ async execute(node, _variables, _context) {
1477
+ const config = node.config;
1478
+ const url = config?.url;
1479
+ const method = config?.method ?? "GET";
1480
+ const headers = config?.headers;
1481
+ const body = config?.body;
1482
+ if (!url) {
1483
+ return { success: false, error: "http_request: url is required" };
1484
+ }
1485
+ const response = await fetch(url, {
1486
+ method,
1487
+ headers,
1488
+ body: body ? JSON.stringify(body) : void 0
1489
+ });
1490
+ const data = await response.json();
1491
+ return {
1492
+ success: response.ok,
1493
+ output: { response: data, status: response.status },
1494
+ error: response.ok ? void 0 : `HTTP ${response.status}`
1495
+ };
1496
+ }
1497
+ });
1498
+ ctx.logger.info("[HTTP] 1 built-in node executor registered (http_request)");
1499
+ }
1500
+
1501
+ // src/builtin/connector-nodes.ts
1502
+ import { defineActionDescriptor as defineActionDescriptor5 } from "@objectstack/spec/automation";
1503
+ function registerConnectorNodes(engine, ctx) {
1504
+ engine.registerNodeExecutor({
1505
+ type: "connector_action",
1506
+ descriptor: defineActionDescriptor5({
1507
+ type: "connector_action",
1508
+ version: "1.0.0",
1509
+ name: "Connector Action",
1510
+ description: "Invoke an action on a registered connector (Slack, Salesforce, a REST API, \u2026). The connector itself is contributed by an integration plugin via registerConnector().",
1511
+ icon: "plug",
1512
+ category: "io",
1513
+ source: "builtin",
1514
+ supportsRetry: true,
1515
+ // Present in all three authoring paradigms (ADR-0018 §registry table).
1516
+ paradigms: ["flow", "workflow_rule", "approval"],
1517
+ // Config contract — drives the Studio property form and flow validation.
1518
+ configSchema: {
1519
+ type: "object",
1520
+ required: ["connectorId", "actionId"],
1521
+ properties: {
1522
+ connectorId: { type: "string", description: "Registered connector name" },
1523
+ actionId: { type: "string", description: "Action key declared by the connector" },
1524
+ input: { type: "object", description: "Mapped inputs for the action" }
992
1525
  }
993
- const response = await fetch(url, {
994
- method,
995
- headers,
996
- body: body ? JSON.stringify(body) : void 0
997
- });
998
- const data = await response.json();
1526
+ }
1527
+ }),
1528
+ async execute(node, variables, context) {
1529
+ const cfg = node.connectorConfig;
1530
+ if (!cfg?.connectorId || !cfg?.actionId) {
999
1531
  return {
1000
- success: response.ok,
1001
- output: { response: data, status: response.status },
1002
- error: response.ok ? void 0 : `HTTP ${response.status}`
1532
+ success: false,
1533
+ error: `connector_action '${node.id}': connectorConfig.connectorId and .actionId are required`
1003
1534
  };
1004
1535
  }
1005
- });
1006
- engine.registerNodeExecutor({
1007
- type: "connector_action",
1008
- async execute(node, _variables, _context) {
1009
- const connectorConfig = node.connectorConfig;
1010
- if (!connectorConfig) {
1011
- return { success: false, error: "connector_action: connectorConfig is required" };
1012
- }
1013
- ctx.logger.info(
1014
- `Connector action: ${connectorConfig.connectorId}.${connectorConfig.actionId}`
1536
+ const handler = engine.resolveConnectorAction(cfg.connectorId, cfg.actionId);
1537
+ if (!handler) {
1538
+ return {
1539
+ success: false,
1540
+ error: `connector_action '${node.id}': no handler for '${cfg.connectorId}.${cfg.actionId}' \u2014 is the connector plugin registered?`
1541
+ };
1542
+ }
1543
+ const handlerCtx = {
1544
+ variables,
1545
+ automation: context,
1546
+ logger: ctx.logger
1547
+ };
1548
+ try {
1549
+ const output = await handler(cfg.input ?? {}, handlerCtx);
1550
+ return { success: true, output };
1551
+ } catch (err) {
1552
+ return {
1553
+ success: false,
1554
+ error: `connector_action(${cfg.connectorId}.${cfg.actionId}) failed: ${err.message}`
1555
+ };
1556
+ }
1557
+ }
1558
+ });
1559
+ ctx.logger.info("[Connector] 1 built-in node executor registered (connector_action)");
1560
+ }
1561
+
1562
+ // src/builtin/notify-node.ts
1563
+ import { defineActionDescriptor as defineActionDescriptor6 } from "@objectstack/spec/automation";
1564
+ function toStringList(value) {
1565
+ if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean);
1566
+ if (typeof value === "string" && value.trim()) return [value.trim()];
1567
+ return [];
1568
+ }
1569
+ function registerNotifyNode(engine, ctx) {
1570
+ const getMessaging = () => {
1571
+ try {
1572
+ return ctx.getService("messaging");
1573
+ } catch {
1574
+ return void 0;
1575
+ }
1576
+ };
1577
+ engine.registerNodeExecutor({
1578
+ type: "notify",
1579
+ descriptor: defineActionDescriptor6({
1580
+ type: "notify",
1581
+ version: "1.0.0",
1582
+ name: "Notify",
1583
+ description: "Send an outbound notification to users via the messaging service (inbox / email / push / \u2026).",
1584
+ icon: "bell",
1585
+ category: "io",
1586
+ source: "builtin",
1587
+ supportsRetry: true,
1588
+ paradigms: ["flow", "workflow_rule", "approval"]
1589
+ }),
1590
+ async execute(node, variables, context) {
1591
+ const cfg = node.config ?? {};
1592
+ const recipients = toStringList(interpolate(cfg.recipients ?? cfg.to ?? [], variables, context));
1593
+ const title = String(interpolate(cfg.title ?? cfg.subject ?? "", variables, context) ?? "");
1594
+ const body = String(interpolate(cfg.message ?? cfg.body ?? "", variables, context) ?? "");
1595
+ const channels = toStringList(cfg.channels);
1596
+ const topic = cfg.topic ? String(cfg.topic) : void 0;
1597
+ const severity = cfg.severity ? String(cfg.severity) : void 0;
1598
+ const actionUrl = cfg.actionUrl ? String(interpolate(cfg.actionUrl, variables, context) ?? "") : void 0;
1599
+ const payload = cfg.payload ? interpolate(cfg.payload, variables, context) : void 0;
1600
+ if (!title) return { success: false, error: "notify: title (or subject) is required" };
1601
+ if (recipients.length === 0) {
1602
+ return { success: false, error: "notify: at least one recipient is required" };
1603
+ }
1604
+ const messaging = getMessaging();
1605
+ if (!messaging) {
1606
+ ctx.logger.warn(
1607
+ `[notify] no messaging service registered; notification "${title}" not delivered`
1015
1608
  );
1016
- return { success: true, output: { connectorResult: {} } };
1609
+ return {
1610
+ success: true,
1611
+ output: { delivered: 0, failed: 0, skipped: true }
1612
+ };
1017
1613
  }
1018
- });
1019
- ctx.logger.info("[HTTP Connector] 2 node executors registered");
1020
- }
1021
- };
1614
+ try {
1615
+ const result = await messaging.emit({
1616
+ topic: topic ?? "notify",
1617
+ audience: recipients,
1618
+ payload: { ...payload ?? {}, title, body, url: actionUrl },
1619
+ severity,
1620
+ channels: channels.length ? channels : void 0
1621
+ });
1622
+ return {
1623
+ success: true,
1624
+ output: {
1625
+ notificationId: result.notificationId,
1626
+ delivered: result.delivered,
1627
+ failed: result.failed
1628
+ }
1629
+ };
1630
+ } catch (err) {
1631
+ return { success: false, error: `notify failed: ${err.message}` };
1632
+ }
1633
+ }
1634
+ });
1635
+ ctx.logger.info("[Notify] 1 built-in node executor registered (notify)");
1636
+ }
1022
1637
 
1023
- // src/plugins/screen-nodes-plugin.ts
1024
- var ScreenNodesPlugin = class {
1025
- constructor() {
1026
- this.name = "com.objectstack.automation.screen-nodes";
1638
+ // src/builtin/index.ts
1639
+ function installBuiltinNodes(engine, ctx) {
1640
+ registerLogicNodes(engine, ctx);
1641
+ registerCrudNodes(engine, ctx);
1642
+ registerScreenNodes(engine, ctx);
1643
+ registerHttpNodes(engine, ctx);
1644
+ registerConnectorNodes(engine, ctx);
1645
+ registerNotifyNode(engine, ctx);
1646
+ const types = engine.getRegisteredNodeTypes();
1647
+ ctx.logger.info(
1648
+ `[Automation] ${types.length} built-in node executors installed: ${types.join(", ")}`
1649
+ );
1650
+ }
1651
+
1652
+ // src/plugin.ts
1653
+ var AutomationServicePlugin = class {
1654
+ constructor(options = {}) {
1655
+ this.name = "com.objectstack.service-automation";
1027
1656
  this.version = "1.0.0";
1028
1657
  this.type = "standard";
1029
- this.dependencies = ["com.objectstack.service-automation"];
1658
+ // Soft dependency on metadata: we look it up at start() and tolerate absence.
1659
+ // Do NOT declare a hard kernel dependency, so this plugin works in environments
1660
+ // where MetadataPlugin is not registered.
1661
+ this.dependencies = [];
1662
+ this.options = options;
1030
1663
  }
1031
1664
  async init(ctx) {
1032
- const engine = ctx.getService("automation");
1033
- engine.registerNodeExecutor({
1034
- type: "screen",
1035
- async execute(_node, _variables, _context) {
1036
- return { success: true };
1665
+ this.engine = new AutomationEngine(ctx.logger);
1666
+ ctx.registerService("automation", this.engine);
1667
+ installBuiltinNodes(this.engine, ctx);
1668
+ if (this.options.debug) {
1669
+ ctx.hook("automation:beforeExecute", async (flowName) => {
1670
+ ctx.logger.debug(`[Automation] Before execute: ${flowName}`);
1671
+ });
1672
+ }
1673
+ ctx.logger.info("[Automation] Engine initialized");
1674
+ }
1675
+ async start(ctx) {
1676
+ console.warn("[Automation:start] entering start()");
1677
+ if (!this.engine) {
1678
+ console.warn("[Automation:start] engine missing, bailing");
1679
+ return;
1680
+ }
1681
+ await ctx.trigger("automation:ready", this.engine);
1682
+ const nodeTypes = this.engine.getRegisteredNodeTypes();
1683
+ ctx.logger.info(
1684
+ `[Automation] Engine started with ${nodeTypes.length} node types: ${nodeTypes.join(", ") || "(none)"}`
1685
+ );
1686
+ try {
1687
+ const ql = ctx.getService("objectql");
1688
+ if (!ql) {
1689
+ console.warn("[Automation] objectql service not found at start()");
1690
+ } else if (!ql.registry) {
1691
+ console.warn("[Automation] objectql.registry is undefined at start()");
1692
+ } else if (typeof ql.registry.listItems !== "function") {
1693
+ console.warn("[Automation] objectql.registry.listItems is not a function");
1037
1694
  }
1038
- });
1039
- engine.registerNodeExecutor({
1040
- type: "script",
1041
- async execute(node, _variables, _context) {
1042
- const cfg = node.config ?? {};
1043
- const actionType = cfg.actionType ?? "noop";
1044
- if (actionType === "email") {
1045
- ctx.logger.info(
1046
- `[Script:email] template=${String(cfg.template)} recipients=${JSON.stringify(cfg.recipients)} vars=${JSON.stringify(cfg.variables)}`
1047
- );
1048
- return {
1049
- success: true,
1050
- output: {
1051
- actionType,
1052
- template: cfg.template,
1053
- recipients: cfg.recipients
1054
- }
1055
- };
1695
+ const flows = ql?.registry?.listItems?.("flow") ?? [];
1696
+ console.warn(`[Automation] flow pull: registry returned ${flows.length} flow(s)`);
1697
+ let registered = 0;
1698
+ for (const f of flows) {
1699
+ const def = f;
1700
+ if (!def?.name) continue;
1701
+ try {
1702
+ this.engine.registerFlow(def.name, def);
1703
+ registered++;
1704
+ } catch (e) {
1705
+ const msg = e instanceof Error ? e.message : String(e);
1706
+ ctx.logger.warn(`[Automation] failed to register flow ${def.name}: ${msg}`);
1056
1707
  }
1057
- ctx.logger.info(`[Script:${actionType}] node=${node.id} executed (no-op handler)`);
1058
- return { success: true, output: { actionType } };
1059
1708
  }
1060
- });
1061
- ctx.logger.info("[Screen/Script Nodes] 2 node executors registered");
1709
+ if (registered > 0) {
1710
+ ctx.logger.info(`[Automation] Pulled ${registered} flow(s) from ObjectQL registry`);
1711
+ }
1712
+ } catch (err) {
1713
+ const msg = err instanceof Error ? err.message : String(err);
1714
+ ctx.logger.warn(`[Automation] flow pull from ObjectQL registry failed: ${msg}`);
1715
+ }
1716
+ }
1717
+ async destroy() {
1718
+ this.engine = void 0;
1062
1719
  }
1063
1720
  };
1064
1721
  export {
1065
1722
  AutomationEngine,
1066
1723
  AutomationServicePlugin,
1067
- CrudNodesPlugin,
1068
- HttpConnectorPlugin,
1069
- LogicNodesPlugin,
1070
- ScreenNodesPlugin
1724
+ installBuiltinNodes,
1725
+ registerConnectorNodes,
1726
+ registerCrudNodes,
1727
+ registerHttpNodes,
1728
+ registerLogicNodes,
1729
+ registerScreenNodes
1071
1730
  };
1072
1731
  //# sourceMappingURL=index.js.map