@objectstack/service-automation 7.3.0 → 7.4.1

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