@objectstack/service-automation 3.0.7

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/src/engine.ts ADDED
@@ -0,0 +1,257 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { FlowParsed, FlowNodeParsed } from '@objectstack/spec/automation';
4
+ import type { AutomationContext, AutomationResult, IAutomationService } from '@objectstack/spec/contracts';
5
+ import type { Logger } from '@objectstack/spec/contracts';
6
+ import { FlowSchema } from '@objectstack/spec/automation';
7
+
8
+ // ─── Node Executor Interface (Plugin Extension Point) ───────────────
9
+
10
+ /**
11
+ * Each node type corresponds to a NodeExecutor.
12
+ * Third-party plugins only need to implement this interface and register
13
+ * it with the engine to extend automation capabilities.
14
+ */
15
+ export interface NodeExecutor {
16
+ /** Corresponds to FlowNodeAction enum value */
17
+ readonly type: string;
18
+
19
+ /**
20
+ * Execute a node
21
+ * @param node - Current node definition
22
+ * @param variables - Flow variable context (read/write)
23
+ * @param context - Trigger context
24
+ * @returns Execution result (may include output data, branch conditions, etc.)
25
+ */
26
+ execute(
27
+ node: FlowNodeParsed,
28
+ variables: Map<string, unknown>,
29
+ context: AutomationContext,
30
+ ): Promise<NodeExecutionResult>;
31
+ }
32
+
33
+ export interface NodeExecutionResult {
34
+ success: boolean;
35
+ output?: Record<string, unknown>;
36
+ error?: string;
37
+ /** Used by decision nodes — returns the selected branch label */
38
+ branchLabel?: string;
39
+ }
40
+
41
+ // ─── Trigger Interface (Plugin Extension Point) ─────────────────────
42
+
43
+ /**
44
+ * Trigger interface. Schedule/Event/API triggers are registered via plugins.
45
+ */
46
+ export interface FlowTrigger {
47
+ readonly type: string;
48
+ start(flowName: string, callback: (ctx: AutomationContext) => Promise<void>): void;
49
+ stop(flowName: string): void;
50
+ }
51
+
52
+ // ─── Core Automation Engine ─────────────────────────────────────────
53
+
54
+ export class AutomationEngine implements IAutomationService {
55
+ private flows = new Map<string, FlowParsed>();
56
+ private nodeExecutors = new Map<string, NodeExecutor>();
57
+ private triggers = new Map<string, FlowTrigger>();
58
+ private logger: Logger;
59
+
60
+ constructor(logger: Logger) {
61
+ this.logger = logger;
62
+ }
63
+
64
+ // ── Plugin Extension API ──────────────────────────────
65
+
66
+ /** Register a node executor (called by plugins) */
67
+ registerNodeExecutor(executor: NodeExecutor): void {
68
+ if (this.nodeExecutors.has(executor.type)) {
69
+ this.logger.warn(`Node executor '${executor.type}' replaced`);
70
+ }
71
+ this.nodeExecutors.set(executor.type, executor);
72
+ this.logger.info(`Node executor registered: ${executor.type}`);
73
+ }
74
+
75
+ /** Unregister a node executor (hot-unplug) */
76
+ unregisterNodeExecutor(type: string): void {
77
+ this.nodeExecutors.delete(type);
78
+ this.logger.info(`Node executor unregistered: ${type}`);
79
+ }
80
+
81
+ /** Register a trigger (called by plugins) */
82
+ registerTrigger(trigger: FlowTrigger): void {
83
+ this.triggers.set(trigger.type, trigger);
84
+ this.logger.info(`Trigger registered: ${trigger.type}`);
85
+ }
86
+
87
+ /** Unregister a trigger (hot-unplug) */
88
+ unregisterTrigger(type: string): void {
89
+ this.triggers.delete(type);
90
+ this.logger.info(`Trigger unregistered: ${type}`);
91
+ }
92
+
93
+ /** Get all registered node types */
94
+ getRegisteredNodeTypes(): string[] {
95
+ return [...this.nodeExecutors.keys()];
96
+ }
97
+
98
+ /** Get all registered trigger types */
99
+ getRegisteredTriggerTypes(): string[] {
100
+ return [...this.triggers.keys()];
101
+ }
102
+
103
+ // ── IAutomationService Contract Implementation ────────
104
+
105
+ registerFlow(name: string, definition: unknown): void {
106
+ const parsed = FlowSchema.parse(definition);
107
+ this.flows.set(name, parsed);
108
+ this.logger.info(`Flow registered: ${name}`);
109
+ }
110
+
111
+ unregisterFlow(name: string): void {
112
+ this.flows.delete(name);
113
+ this.logger.info(`Flow unregistered: ${name}`);
114
+ }
115
+
116
+ async listFlows(): Promise<string[]> {
117
+ return [...this.flows.keys()];
118
+ }
119
+
120
+ async execute(flowName: string, context?: AutomationContext): Promise<AutomationResult> {
121
+ const startTime = Date.now();
122
+ const flow = this.flows.get(flowName);
123
+
124
+ if (!flow) {
125
+ return { success: false, error: `Flow '${flowName}' not found` };
126
+ }
127
+
128
+ // Initialize variable context
129
+ const variables = new Map<string, unknown>();
130
+ if (flow.variables) {
131
+ for (const v of flow.variables) {
132
+ if (v.isInput && context?.params?.[v.name] !== undefined) {
133
+ variables.set(v.name, context.params[v.name]);
134
+ }
135
+ }
136
+ }
137
+ // Inject trigger record
138
+ if (context?.record) {
139
+ variables.set('$record', context.record);
140
+ }
141
+
142
+ try {
143
+ // Find the start node
144
+ const startNode = flow.nodes.find(n => n.type === 'start');
145
+ if (!startNode) {
146
+ return { success: false, error: 'Flow has no start node' };
147
+ }
148
+
149
+ // DAG traversal execution
150
+ await this.executeNode(startNode, flow, variables, context ?? {});
151
+
152
+ // Collect output variables
153
+ const output: Record<string, unknown> = {};
154
+ if (flow.variables) {
155
+ for (const v of flow.variables) {
156
+ if (v.isOutput) {
157
+ output[v.name] = variables.get(v.name);
158
+ }
159
+ }
160
+ }
161
+
162
+ return {
163
+ success: true,
164
+ output,
165
+ durationMs: Date.now() - startTime,
166
+ };
167
+ } catch (err: unknown) {
168
+ const errorMessage = err instanceof Error ? err.message : String(err);
169
+
170
+ // Error handling strategy
171
+ if (flow.errorHandling?.strategy === 'retry') {
172
+ return this.retryExecution(flowName, context, startTime, flow.errorHandling);
173
+ }
174
+ return {
175
+ success: false,
176
+ error: errorMessage,
177
+ durationMs: Date.now() - startTime,
178
+ };
179
+ }
180
+ }
181
+
182
+ // ── DAG Traversal Core ──────────────────────────────────
183
+
184
+ private async executeNode(
185
+ node: FlowNodeParsed,
186
+ flow: FlowParsed,
187
+ variables: Map<string, unknown>,
188
+ context: AutomationContext,
189
+ ): Promise<void> {
190
+ if (node.type === 'end') return;
191
+
192
+ // Find executor
193
+ const executor = this.nodeExecutors.get(node.type);
194
+ if (!executor) {
195
+ // start node without executor is fine — just skip
196
+ if (node.type !== 'start') {
197
+ throw new Error(`No executor registered for node type '${node.type}'`);
198
+ }
199
+ } else {
200
+ // Execute node
201
+ const result = await executor.execute(node, variables, context);
202
+ if (!result.success) {
203
+ throw new Error(`Node '${node.id}' failed: ${result.error}`);
204
+ }
205
+ // Write back output variables
206
+ if (result.output) {
207
+ for (const [key, value] of Object.entries(result.output)) {
208
+ variables.set(`${node.id}.${key}`, value);
209
+ }
210
+ }
211
+ }
212
+
213
+ // Find next nodes (filter by edge conditions)
214
+ const outEdges = flow.edges.filter(e => e.source === node.id);
215
+ for (const edge of outEdges) {
216
+ if (edge.condition && !this.evaluateCondition(edge.condition, variables)) {
217
+ continue;
218
+ }
219
+ const nextNode = flow.nodes.find(n => n.id === edge.target);
220
+ if (nextNode) {
221
+ await this.executeNode(nextNode, flow, variables, context);
222
+ }
223
+ }
224
+ }
225
+
226
+ private evaluateCondition(expression: string, variables: Map<string, unknown>): boolean {
227
+ // MVP: Simple template replacement + expression evaluation.
228
+ // Flow definitions are authored by trusted developers/admins.
229
+ // TODO: Replace with safe expression evaluator (e.g., jexl) for production.
230
+ let resolved = expression;
231
+ for (const [key, value] of variables) {
232
+ resolved = resolved.split(`{${key}}`).join(String(value));
233
+ }
234
+ try {
235
+ return new Function(`return (${resolved})`)() as boolean;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+
241
+ private async retryExecution(
242
+ flowName: string,
243
+ context: AutomationContext | undefined,
244
+ startTime: number,
245
+ errorHandling: { maxRetries?: number; retryDelayMs?: number },
246
+ ): Promise<AutomationResult> {
247
+ const maxRetries = errorHandling.maxRetries ?? 3;
248
+ const delay = errorHandling.retryDelayMs ?? 1000;
249
+
250
+ for (let i = 0; i < maxRetries; i++) {
251
+ await new Promise(r => setTimeout(r, delay));
252
+ const result = await this.execute(flowName, context);
253
+ if (result.success) return result;
254
+ }
255
+ return { success: false, error: 'Max retries exceeded', durationMs: Date.now() - startTime };
256
+ }
257
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ // Core engine
4
+ export { AutomationEngine } from './engine.js';
5
+ export type { NodeExecutor, NodeExecutionResult, FlowTrigger } from './engine.js';
6
+
7
+ // Kernel plugin
8
+ export { AutomationServicePlugin } from './plugin.js';
9
+ export type { AutomationServicePluginOptions } from './plugin.js';
10
+
11
+ // Node plugins
12
+ export { CrudNodesPlugin } from './plugins/crud-nodes-plugin.js';
13
+ export { LogicNodesPlugin } from './plugins/logic-nodes-plugin.js';
14
+ export { HttpConnectorPlugin } from './plugins/http-connector-plugin.js';
package/src/plugin.ts ADDED
@@ -0,0 +1,80 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import { AutomationEngine } from './engine.js';
5
+
6
+ /**
7
+ * Configuration options for the AutomationServicePlugin.
8
+ */
9
+ export interface AutomationServicePluginOptions {
10
+ /** Enable debug logging for flow execution */
11
+ debug?: boolean;
12
+ }
13
+
14
+ /**
15
+ * AutomationServicePlugin — Core engine plugin
16
+ *
17
+ * Responsibilities:
18
+ * 1. init phase: Create engine instance, register as 'automation' service
19
+ * 2. start phase: Trigger 'automation:ready' hook for node plugin registration
20
+ * 3. destroy phase: Clean up resources
21
+ *
22
+ * Does NOT implement any specific nodes — nodes are registered by other plugins
23
+ * via the engine's extension API.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * import { LiteKernel } from '@objectstack/core';
28
+ * import { AutomationServicePlugin } from '@objectstack/service-automation';
29
+ *
30
+ * const kernel = new LiteKernel();
31
+ * kernel.use(new AutomationServicePlugin());
32
+ * await kernel.bootstrap();
33
+ *
34
+ * const automation = kernel.getService('automation');
35
+ * ```
36
+ */
37
+ export class AutomationServicePlugin implements Plugin {
38
+ name = 'com.objectstack.service-automation';
39
+ version = '1.0.0';
40
+ type = 'standard' as const;
41
+ dependencies: string[] = [];
42
+
43
+ private engine?: AutomationEngine;
44
+ private readonly options: AutomationServicePluginOptions;
45
+
46
+ constructor(options: AutomationServicePluginOptions = {}) {
47
+ this.options = options;
48
+ }
49
+
50
+ async init(ctx: PluginContext): Promise<void> {
51
+ this.engine = new AutomationEngine(ctx.logger);
52
+
53
+ // Register as global service — other plugins access via ctx.getService('automation')
54
+ ctx.registerService('automation', this.engine);
55
+
56
+ if (this.options.debug) {
57
+ ctx.hook('automation:beforeExecute', async (flowName: string) => {
58
+ ctx.logger.debug(`[Automation] Before execute: ${flowName}`);
59
+ });
60
+ }
61
+
62
+ ctx.logger.info('[Automation] Engine initialized');
63
+ }
64
+
65
+ async start(ctx: PluginContext): Promise<void> {
66
+ if (!this.engine) return;
67
+
68
+ // Trigger hook to notify engine is ready — other plugins can start registering nodes
69
+ await ctx.trigger('automation:ready', this.engine);
70
+
71
+ const nodeTypes = this.engine.getRegisteredNodeTypes();
72
+ ctx.logger.info(
73
+ `[Automation] Engine started with ${nodeTypes.length} node types: ${nodeTypes.join(', ') || '(none)'}`,
74
+ );
75
+ }
76
+
77
+ async destroy(): Promise<void> {
78
+ this.engine = undefined;
79
+ }
80
+ }
@@ -0,0 +1,68 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import type { AutomationEngine } from '../engine.js';
5
+
6
+ /**
7
+ * CRUD Node Plugin — Provides get_record / create_record / update_record / delete_record
8
+ *
9
+ * Dependencies: service-automation (engine)
10
+ *
11
+ * In a full runtime environment these nodes would delegate to ObjectQL (data layer).
12
+ * This MVP implementation provides the extension point structure.
13
+ */
14
+ export class CrudNodesPlugin implements Plugin {
15
+ name = 'com.objectstack.automation.crud-nodes';
16
+ version = '1.0.0';
17
+ type = 'standard' as const;
18
+ dependencies = ['com.objectstack.service-automation'];
19
+
20
+ async init(ctx: PluginContext): Promise<void> {
21
+ const engine = ctx.getService<AutomationEngine>('automation');
22
+
23
+ // get_record node executor
24
+ engine.registerNodeExecutor({
25
+ type: 'get_record',
26
+ async execute(node, _variables, _context) {
27
+ const config = node.config as Record<string, unknown> | undefined;
28
+ // In production, this would query via ObjectQL:
29
+ // const ql = ctx.getService('objectql');
30
+ // const records = await ql.find(config.object, config.filters);
31
+ return {
32
+ success: true,
33
+ output: { records: [], object: config?.object },
34
+ };
35
+ },
36
+ });
37
+
38
+ // create_record node executor
39
+ engine.registerNodeExecutor({
40
+ type: 'create_record',
41
+ async execute(node, _variables, _context) {
42
+ const config = node.config as Record<string, unknown> | undefined;
43
+ return {
44
+ success: true,
45
+ output: { id: 'new-record-id', object: config?.object },
46
+ };
47
+ },
48
+ });
49
+
50
+ // update_record node executor
51
+ engine.registerNodeExecutor({
52
+ type: 'update_record',
53
+ async execute(_node, _variables, _context) {
54
+ return { success: true };
55
+ },
56
+ });
57
+
58
+ // delete_record node executor
59
+ engine.registerNodeExecutor({
60
+ type: 'delete_record',
61
+ async execute(_node, _variables, _context) {
62
+ return { success: true };
63
+ },
64
+ });
65
+
66
+ ctx.logger.info('[CRUD Nodes] 4 node executors registered');
67
+ }
68
+ }
@@ -0,0 +1,70 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import type { AutomationEngine } from '../engine.js';
5
+
6
+ /**
7
+ * HTTP + Connector Node Plugin — Provides http_request / connector_action nodes
8
+ *
9
+ * Dependencies: service-automation (engine)
10
+ */
11
+ export class HttpConnectorPlugin implements Plugin {
12
+ name = 'com.objectstack.automation.http-connector';
13
+ version = '1.0.0';
14
+ type = 'standard' as const;
15
+ dependencies = ['com.objectstack.service-automation'];
16
+
17
+ async init(ctx: PluginContext): Promise<void> {
18
+ const engine = ctx.getService<AutomationEngine>('automation');
19
+
20
+ // http_request node executor
21
+ engine.registerNodeExecutor({
22
+ type: 'http_request',
23
+ async execute(node, _variables, _context) {
24
+ const config = node.config as Record<string, unknown> | undefined;
25
+ const url = config?.url as string | undefined;
26
+ const method = (config?.method as string) ?? 'GET';
27
+ const headers = config?.headers as Record<string, string> | undefined;
28
+ const body = config?.body;
29
+
30
+ if (!url) {
31
+ return { success: false, error: 'http_request: url is required' };
32
+ }
33
+
34
+ const response = await fetch(url, {
35
+ method,
36
+ headers,
37
+ body: body ? JSON.stringify(body) : undefined,
38
+ });
39
+ const data = await response.json();
40
+
41
+ return {
42
+ success: response.ok,
43
+ output: { response: data, status: response.status },
44
+ error: response.ok ? undefined : `HTTP ${response.status}`,
45
+ };
46
+ },
47
+ });
48
+
49
+ // connector_action node — calls a registered connector
50
+ engine.registerNodeExecutor({
51
+ type: 'connector_action',
52
+ async execute(node, _variables, _context) {
53
+ const connectorConfig = node.connectorConfig;
54
+ if (!connectorConfig) {
55
+ return { success: false, error: 'connector_action: connectorConfig is required' };
56
+ }
57
+
58
+ ctx.logger.info(
59
+ `Connector action: ${connectorConfig.connectorId}.${connectorConfig.actionId}`,
60
+ );
61
+
62
+ // In production, this would look up the connector from a registry
63
+ // and execute the specified action with the mapped inputs
64
+ return { success: true, output: { connectorResult: {} } };
65
+ },
66
+ });
67
+
68
+ ctx.logger.info('[HTTP Connector] 2 node executors registered');
69
+ }
70
+ }
@@ -0,0 +1,78 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import type { AutomationEngine } from '../engine.js';
5
+
6
+ /**
7
+ * Logic Node Plugin — Provides decision / assignment / loop nodes
8
+ *
9
+ * Dependencies: service-automation (engine)
10
+ */
11
+ export class LogicNodesPlugin implements Plugin {
12
+ name = 'com.objectstack.automation.logic-nodes';
13
+ version = '1.0.0';
14
+ type = 'standard' as const;
15
+ dependencies = ['com.objectstack.service-automation'];
16
+
17
+ async init(ctx: PluginContext): Promise<void> {
18
+ const engine = ctx.getService<AutomationEngine>('automation');
19
+
20
+ // decision node — conditional branching
21
+ engine.registerNodeExecutor({
22
+ type: 'decision',
23
+ async execute(node, variables, _context) {
24
+ const config = node.config as Record<string, unknown> | undefined;
25
+ const conditions = (config?.conditions ?? []) as Array<{ label: string; expression: string }>;
26
+
27
+ for (const cond of conditions) {
28
+ // MVP: Simple template replacement + expression evaluation.
29
+ // Flow definitions are authored by trusted developers/admins.
30
+ // TODO: Replace with safe expression evaluator (e.g., jexl) for production.
31
+ let expr = cond.expression;
32
+ for (const [k, v] of variables) {
33
+ expr = expr.split(`{${k}}`).join(String(v));
34
+ }
35
+ try {
36
+ if (new Function(`return (${expr})`)()) {
37
+ return { success: true, branchLabel: cond.label };
38
+ }
39
+ } catch {
40
+ // Continue to next condition
41
+ }
42
+ }
43
+ return { success: true, branchLabel: 'default' };
44
+ },
45
+ });
46
+
47
+ // assignment node — set variables
48
+ engine.registerNodeExecutor({
49
+ type: 'assignment',
50
+ async execute(node, variables, _context) {
51
+ const config = (node.config ?? {}) as Record<string, unknown>;
52
+ for (const [key, value] of Object.entries(config)) {
53
+ variables.set(key, value);
54
+ }
55
+ return { success: true };
56
+ },
57
+ });
58
+
59
+ // loop node — iterate over a collection
60
+ engine.registerNodeExecutor({
61
+ type: 'loop',
62
+ async execute(node, variables, _context) {
63
+ const config = node.config as Record<string, unknown> | undefined;
64
+ const collectionName = config?.collection as string | undefined;
65
+ if (collectionName) {
66
+ const collection = variables.get(collectionName);
67
+ if (Array.isArray(collection)) {
68
+ variables.set('$loopItems', collection);
69
+ variables.set('$loopIndex', 0);
70
+ }
71
+ }
72
+ return { success: true };
73
+ },
74
+ });
75
+
76
+ ctx.logger.info('[Logic Nodes] 3 node executors registered');
77
+ }
78
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }