@motiadev/core 0.1.0-beta.2 → 0.1.0-beta.20
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.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/src/flows-config-endpoint.js +11 -9
- package/dist/src/mermaid-generator.d.ts +4 -0
- package/dist/src/mermaid-generator.js +203 -0
- package/dist/src/node/middleware-compose.d.ts +1 -0
- package/dist/src/node/middleware-compose.js +12 -0
- package/dist/src/node/node-runner.js +7 -2
- package/dist/src/python/logger.py +2 -2
- package/dist/src/python/python-runner.py +59 -5
- package/dist/src/python/rpc.py +14 -0
- package/dist/src/python/rpc_state_manager.py +39 -2
- package/dist/src/ruby/rpc_state_manager.rb +17 -1
- package/dist/src/ruby/ruby-runner.rb +1 -1
- package/dist/src/server.js +8 -3
- package/dist/src/step-validator.js +7 -3
- package/dist/src/types.d.ts +20 -0
- package/package.json +6 -1
package/dist/index.d.ts
CHANGED
|
@@ -9,3 +9,4 @@ export { isApiStep, isCronStep, isEventStep, isNoopStep } from './src/guards';
|
|
|
9
9
|
export { LockedData } from './src/locked-data';
|
|
10
10
|
export { getStepConfig } from './src/get-step-config';
|
|
11
11
|
export { StateAdapter } from './src/state/state-adapter';
|
|
12
|
+
export { createMermaidGenerator } from './src/mermaid-generator';
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.getStepConfig = exports.LockedData = exports.isNoopStep = exports.isEventStep = exports.isCronStep = exports.isApiStep = exports.setupCronHandlers = exports.createStateAdapter = exports.Logger = exports.globalLogger = exports.createEventManager = exports.createStepHandlers = exports.createServer = void 0;
|
|
17
|
+
exports.createMermaidGenerator = exports.getStepConfig = exports.LockedData = exports.isNoopStep = exports.isEventStep = exports.isCronStep = exports.isApiStep = exports.setupCronHandlers = exports.createStateAdapter = exports.Logger = exports.globalLogger = exports.createEventManager = exports.createStepHandlers = exports.createServer = void 0;
|
|
18
18
|
__exportStar(require("./src/types"), exports);
|
|
19
19
|
var server_1 = require("./src/server");
|
|
20
20
|
Object.defineProperty(exports, "createServer", { enumerable: true, get: function () { return server_1.createServer; } });
|
|
@@ -38,3 +38,5 @@ var locked_data_1 = require("./src/locked-data");
|
|
|
38
38
|
Object.defineProperty(exports, "LockedData", { enumerable: true, get: function () { return locked_data_1.LockedData; } });
|
|
39
39
|
var get_step_config_1 = require("./src/get-step-config");
|
|
40
40
|
Object.defineProperty(exports, "getStepConfig", { enumerable: true, get: function () { return get_step_config_1.getStepConfig; } });
|
|
41
|
+
var mermaid_generator_1 = require("./src/mermaid-generator");
|
|
42
|
+
Object.defineProperty(exports, "createMermaidGenerator", { enumerable: true, get: function () { return mermaid_generator_1.createMermaidGenerator; } });
|
|
@@ -8,13 +8,16 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const flowsConfigEndpoint = (app, baseDir) => {
|
|
10
10
|
const configPath = path_1.default.join(baseDir, 'motia-workbench.json');
|
|
11
|
+
const getConfig = () => {
|
|
12
|
+
if (fs_1.default.existsSync(configPath)) {
|
|
13
|
+
return JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
return {};
|
|
16
|
+
};
|
|
11
17
|
app.post('/flows/:id/config', (req, res) => {
|
|
12
18
|
const newFlowConfig = req.body;
|
|
13
19
|
try {
|
|
14
|
-
|
|
15
|
-
fs_1.default.writeFileSync(configPath, JSON.stringify({}, null, 2));
|
|
16
|
-
}
|
|
17
|
-
const existingConfig = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
|
|
20
|
+
const existingConfig = getConfig();
|
|
18
21
|
const updatedConfig = {
|
|
19
22
|
...existingConfig,
|
|
20
23
|
};
|
|
@@ -28,21 +31,20 @@ const flowsConfigEndpoint = (app, baseDir) => {
|
|
|
28
31
|
res.status(200).send({ message: 'Flow config saved successfully' });
|
|
29
32
|
}
|
|
30
33
|
catch (error) {
|
|
31
|
-
console.error('Error saving flow config:', error);
|
|
34
|
+
console.error('Error saving flow config:', error.message);
|
|
32
35
|
res.status(500).json({ error: 'Failed to save flow config' });
|
|
33
36
|
}
|
|
34
37
|
});
|
|
35
38
|
app.get('/flows/:id/config', (req, res) => {
|
|
36
39
|
const { id } = req.params;
|
|
37
40
|
try {
|
|
38
|
-
const
|
|
39
|
-
const allFlowsConfig = JSON.parse(file);
|
|
41
|
+
const allFlowsConfig = getConfig();
|
|
40
42
|
const flowConfig = allFlowsConfig[id] || {};
|
|
41
43
|
res.status(200).send(flowConfig);
|
|
42
44
|
}
|
|
43
45
|
catch (error) {
|
|
44
|
-
console.error('Error reading flow config:', error);
|
|
45
|
-
res.status(400).send({});
|
|
46
|
+
console.error('Error reading flow config:', error.message);
|
|
47
|
+
res.status(400).send({ error: 'Failed to read flow config' });
|
|
46
48
|
}
|
|
47
49
|
});
|
|
48
50
|
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createMermaidGenerator = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const guards_1 = require("./guards");
|
|
10
|
+
// Pure function to ensure diagrams directory exists
|
|
11
|
+
const ensureDiagramsDirectory = (diagramsDir) => {
|
|
12
|
+
if (!fs_1.default.existsSync(diagramsDir)) {
|
|
13
|
+
fs_1.default.mkdirSync(diagramsDir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
// Pure function to get node ID
|
|
17
|
+
const getNodeId = (step, baseDir) => {
|
|
18
|
+
// Get relative path from the base directory
|
|
19
|
+
const relativePath = path_1.default.relative(baseDir, step.filePath);
|
|
20
|
+
// Remove common file extensions
|
|
21
|
+
const pathWithoutExtension = relativePath.replace(/\.(ts|js|tsx|jsx)$/, '');
|
|
22
|
+
// Replace slashes with underscores and dots with underscores
|
|
23
|
+
// Only keep alphanumeric characters and underscores
|
|
24
|
+
return pathWithoutExtension.replace(/[^a-zA-Z0-9]/g, '_');
|
|
25
|
+
};
|
|
26
|
+
// Pure function to get node label
|
|
27
|
+
const getNodeLabel = (step) => {
|
|
28
|
+
// Get display name for node
|
|
29
|
+
const displayName = step.config.name || path_1.default.basename(step.filePath, path_1.default.extname(step.filePath));
|
|
30
|
+
// Add node type prefix to help distinguish types
|
|
31
|
+
let prefix = '';
|
|
32
|
+
if ((0, guards_1.isApiStep)(step))
|
|
33
|
+
prefix = '🌐 ';
|
|
34
|
+
else if ((0, guards_1.isEventStep)(step))
|
|
35
|
+
prefix = '⚡ ';
|
|
36
|
+
else if ((0, guards_1.isCronStep)(step))
|
|
37
|
+
prefix = '⏰ ';
|
|
38
|
+
else if ((0, guards_1.isNoopStep)(step))
|
|
39
|
+
prefix = '⚙️ ';
|
|
40
|
+
// Create a node label with the step name
|
|
41
|
+
return `["${prefix}${displayName}"]`;
|
|
42
|
+
};
|
|
43
|
+
// Pure function to get node style
|
|
44
|
+
const getNodeStyle = (step) => {
|
|
45
|
+
// Apply style class based on step type
|
|
46
|
+
if ((0, guards_1.isApiStep)(step))
|
|
47
|
+
return ':::apiStyle';
|
|
48
|
+
if ((0, guards_1.isEventStep)(step))
|
|
49
|
+
return ':::eventStyle';
|
|
50
|
+
if ((0, guards_1.isCronStep)(step))
|
|
51
|
+
return ':::cronStyle';
|
|
52
|
+
if ((0, guards_1.isNoopStep)(step))
|
|
53
|
+
return ':::noopStyle';
|
|
54
|
+
return '';
|
|
55
|
+
};
|
|
56
|
+
// Pure function to generate connections
|
|
57
|
+
const generateConnections = (emits, sourceStep, steps, sourceId, baseDir) => {
|
|
58
|
+
const connections = [];
|
|
59
|
+
if (!emits || !Array.isArray(emits) || emits.length === 0) {
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
// Helper function to check if a step subscribes to a topic
|
|
63
|
+
const stepSubscribesToTopic = (step, topic) => {
|
|
64
|
+
// Event steps use regular subscribes
|
|
65
|
+
if ((0, guards_1.isEventStep)(step) &&
|
|
66
|
+
step.config.subscribes &&
|
|
67
|
+
Array.isArray(step.config.subscribes) &&
|
|
68
|
+
step.config.subscribes.includes(topic)) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
// Noop and API steps use virtualSubscribes
|
|
72
|
+
if (((0, guards_1.isNoopStep)(step) || (0, guards_1.isApiStep)(step)) &&
|
|
73
|
+
step.config.virtualSubscribes &&
|
|
74
|
+
Array.isArray(step.config.virtualSubscribes) &&
|
|
75
|
+
step.config.virtualSubscribes.includes(topic)) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
80
|
+
emits.forEach((emit) => {
|
|
81
|
+
const topic = typeof emit === 'string' ? emit : emit.topic;
|
|
82
|
+
const label = typeof emit === 'string' ? topic : emit.label || topic;
|
|
83
|
+
steps.forEach((targetStep) => {
|
|
84
|
+
if (stepSubscribesToTopic(targetStep, topic)) {
|
|
85
|
+
const targetId = getNodeId(targetStep, baseDir);
|
|
86
|
+
connections.push(` ${sourceId} -->|${label}| ${targetId}`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
return connections.join('\n');
|
|
91
|
+
};
|
|
92
|
+
// Pure function to generate flow diagram
|
|
93
|
+
const generateFlowDiagram = (flowName, steps, baseDir) => {
|
|
94
|
+
// Start mermaid flowchart with top-down direction
|
|
95
|
+
let diagram = `flowchart TD\n`;
|
|
96
|
+
// Add class definitions for styling with explicit text color
|
|
97
|
+
const classDefinitions = [
|
|
98
|
+
` classDef apiStyle fill:#f96,stroke:#333,stroke-width:2px,color:#fff`,
|
|
99
|
+
` classDef eventStyle fill:#69f,stroke:#333,stroke-width:2px,color:#fff`,
|
|
100
|
+
` classDef cronStyle fill:#9c6,stroke:#333,stroke-width:2px,color:#fff`,
|
|
101
|
+
` classDef noopStyle fill:#3f3a50,stroke:#333,stroke-width:2px,color:#fff`,
|
|
102
|
+
];
|
|
103
|
+
diagram += classDefinitions.join('\n') + '\n';
|
|
104
|
+
// Check if we have any steps
|
|
105
|
+
if (!steps || steps.length === 0) {
|
|
106
|
+
return diagram + ' empty[No steps in this flow]';
|
|
107
|
+
}
|
|
108
|
+
// Create node definitions with proper format
|
|
109
|
+
steps.forEach((step) => {
|
|
110
|
+
const nodeId = getNodeId(step, baseDir);
|
|
111
|
+
const nodeLabel = getNodeLabel(step);
|
|
112
|
+
const nodeStyle = getNodeStyle(step);
|
|
113
|
+
diagram += ` ${nodeId}${nodeLabel}${nodeStyle}\n`;
|
|
114
|
+
});
|
|
115
|
+
// Create connections between nodes
|
|
116
|
+
let connectionsStr = '';
|
|
117
|
+
steps.forEach((sourceStep) => {
|
|
118
|
+
const sourceId = getNodeId(sourceStep, baseDir);
|
|
119
|
+
// Helper function to process emissions if they exist
|
|
120
|
+
function processEmissions(emissionsArray, stepSource, stepsCollection, sourceIdentifier) {
|
|
121
|
+
if (emissionsArray && Array.isArray(emissionsArray)) {
|
|
122
|
+
return generateConnections(emissionsArray, stepSource, stepsCollection, sourceIdentifier, baseDir);
|
|
123
|
+
}
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
// Semantic variables to clarify which step types support which emission types
|
|
127
|
+
const supportsEmits = (0, guards_1.isApiStep)(sourceStep) || (0, guards_1.isEventStep)(sourceStep) || (0, guards_1.isCronStep)(sourceStep);
|
|
128
|
+
const supportsVirtualEmits = supportsEmits || (0, guards_1.isNoopStep)(sourceStep);
|
|
129
|
+
// Process regular emissions if supported
|
|
130
|
+
if (supportsEmits) {
|
|
131
|
+
const emitConnections = processEmissions(sourceStep.config.emits, sourceStep, steps, sourceId);
|
|
132
|
+
if (emitConnections) {
|
|
133
|
+
connectionsStr += emitConnections + '\n';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Process virtual emissions if supported
|
|
137
|
+
if (supportsVirtualEmits) {
|
|
138
|
+
const virtualEmitConnections = processEmissions(sourceStep.config.virtualEmits, sourceStep, steps, sourceId);
|
|
139
|
+
if (virtualEmitConnections) {
|
|
140
|
+
connectionsStr += virtualEmitConnections + '\n';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
// Add connections to the diagram
|
|
145
|
+
diagram += connectionsStr;
|
|
146
|
+
return diagram;
|
|
147
|
+
};
|
|
148
|
+
// Function to save a diagram to a file
|
|
149
|
+
const saveDiagram = (diagramsDir, flowName, diagram) => {
|
|
150
|
+
const filePath = path_1.default.join(diagramsDir, `${flowName}.mmd`);
|
|
151
|
+
fs_1.default.writeFileSync(filePath, diagram);
|
|
152
|
+
};
|
|
153
|
+
// Function to remove a diagram file
|
|
154
|
+
const removeDiagram = (diagramsDir, flowName) => {
|
|
155
|
+
const filePath = path_1.default.join(diagramsDir, `${flowName}.mmd`);
|
|
156
|
+
if (fs_1.default.existsSync(filePath)) {
|
|
157
|
+
fs_1.default.unlinkSync(filePath);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
// Function to generate and save a diagram
|
|
161
|
+
const generateAndSaveDiagram = (diagramsDir, flowName, flow, baseDir) => {
|
|
162
|
+
const diagram = generateFlowDiagram(flowName, flow.steps, baseDir);
|
|
163
|
+
saveDiagram(diagramsDir, flowName, diagram);
|
|
164
|
+
};
|
|
165
|
+
// Main exported function that creates the mermaid generator
|
|
166
|
+
const createMermaidGenerator = (baseDir) => {
|
|
167
|
+
const diagramsDir = path_1.default.join(baseDir, '.mermaid');
|
|
168
|
+
ensureDiagramsDirectory(diagramsDir);
|
|
169
|
+
// Event handlers
|
|
170
|
+
const handleFlowCreated = (flowName, flow) => {
|
|
171
|
+
generateAndSaveDiagram(diagramsDir, flowName, flow, baseDir);
|
|
172
|
+
};
|
|
173
|
+
const handleFlowUpdated = (flowName, flow) => {
|
|
174
|
+
generateAndSaveDiagram(diagramsDir, flowName, flow, baseDir);
|
|
175
|
+
};
|
|
176
|
+
const handleFlowRemoved = (flowName) => {
|
|
177
|
+
removeDiagram(diagramsDir, flowName);
|
|
178
|
+
};
|
|
179
|
+
// Initialize function to hook into LockedData events
|
|
180
|
+
const initialize = (lockedData) => {
|
|
181
|
+
// Hook into flow events
|
|
182
|
+
lockedData.on('flow-created', (flowName) => {
|
|
183
|
+
handleFlowCreated(flowName, lockedData.flows[flowName]);
|
|
184
|
+
});
|
|
185
|
+
lockedData.on('flow-updated', (flowName) => {
|
|
186
|
+
handleFlowUpdated(flowName, lockedData.flows[flowName]);
|
|
187
|
+
});
|
|
188
|
+
lockedData.on('flow-removed', (flowName) => {
|
|
189
|
+
handleFlowRemoved(flowName);
|
|
190
|
+
});
|
|
191
|
+
// Generate diagrams for all existing flows
|
|
192
|
+
if (lockedData.flows && typeof lockedData.flows === 'object') {
|
|
193
|
+
Object.entries(lockedData.flows).forEach(([flowName, flow]) => {
|
|
194
|
+
generateAndSaveDiagram(diagramsDir, flowName, flow, baseDir);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
// Return the public API
|
|
199
|
+
return {
|
|
200
|
+
initialize,
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
exports.createMermaidGenerator = createMermaidGenerator;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const composeMiddleware: (...middlewares: any[]) => (req: any, ctx: any, handler: () => Promise<any>) => Promise<any>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.composeMiddleware = void 0;
|
|
4
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
5
|
+
const composeMiddleware = (...middlewares) => {
|
|
6
|
+
return async (req, ctx, handler) => {
|
|
7
|
+
const composedHandler = middlewares.reduceRight((nextHandler, middleware) => () => middleware(req, ctx, nextHandler), handler);
|
|
8
|
+
return composedHandler();
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
exports.composeMiddleware = composeMiddleware;
|
|
12
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
@@ -7,6 +7,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
7
7
|
const logger_1 = require("./logger");
|
|
8
8
|
const rpc_state_manager_1 = require("./rpc-state-manager");
|
|
9
9
|
const rpc_1 = require("./rpc");
|
|
10
|
+
const middleware_compose_1 = require("./middleware-compose");
|
|
10
11
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
11
12
|
require('dotenv').config();
|
|
12
13
|
// Add ts-node registration before dynamic imports
|
|
@@ -38,8 +39,12 @@ async function runTypescriptModule(filePath, event) {
|
|
|
38
39
|
const emit = async (data) => sender.send('emit', data);
|
|
39
40
|
const context = { traceId, flows, logger, state, emit };
|
|
40
41
|
sender.init();
|
|
41
|
-
|
|
42
|
-
const
|
|
42
|
+
const middlewares = Array.isArray(module.config.middleware) ? module.config.middleware : [];
|
|
43
|
+
const composedMiddleware = (0, middleware_compose_1.composeMiddleware)(...middlewares);
|
|
44
|
+
const handlerFn = () => {
|
|
45
|
+
return contextInFirstArg ? module.handler(context) : module.handler(event.data, context);
|
|
46
|
+
};
|
|
47
|
+
const result = await composedMiddleware(event.data, context, handlerFn);
|
|
43
48
|
await sender.send('result', result);
|
|
44
49
|
await sender.close();
|
|
45
50
|
process.exit(0);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import time
|
|
2
2
|
from typing import Any, Dict, Optional
|
|
3
|
-
from rpc import RpcSender
|
|
3
|
+
from rpc import RpcSender, serialize_for_json
|
|
4
4
|
|
|
5
5
|
class Logger:
|
|
6
6
|
def __init__(self, trace_id: str, flows: list[str], rpc: RpcSender):
|
|
@@ -18,7 +18,7 @@ class Logger:
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
if args:
|
|
21
|
-
#
|
|
21
|
+
# Use our serializer to ensure args are JSON-serializable
|
|
22
22
|
if hasattr(args, '__dict__'):
|
|
23
23
|
args = vars(args)
|
|
24
24
|
elif not isinstance(args, dict):
|
|
@@ -5,9 +5,10 @@ import os
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import traceback
|
|
7
7
|
from logger import Logger
|
|
8
|
-
from rpc import RpcSender
|
|
8
|
+
from rpc import RpcSender, serialize_for_json
|
|
9
9
|
from rpc_state_manager import RpcStateManager
|
|
10
|
-
from typing import Any
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
import functools
|
|
11
12
|
|
|
12
13
|
def parse_args(arg: str) -> Any:
|
|
13
14
|
from types import SimpleNamespace
|
|
@@ -19,16 +20,43 @@ def parse_args(arg: str) -> Any:
|
|
|
19
20
|
return arg
|
|
20
21
|
|
|
21
22
|
class Context:
|
|
22
|
-
def __init__(self, args: Any, rpc: RpcSender):
|
|
23
|
+
def __init__(self, args: Any, rpc: RpcSender, is_api_handler: bool = False):
|
|
23
24
|
self.trace_id = args.traceId
|
|
24
25
|
self.traceId = args.traceId
|
|
25
26
|
self.flows = args.flows
|
|
26
27
|
self.rpc = rpc
|
|
27
28
|
self.state = RpcStateManager(rpc)
|
|
28
29
|
self.logger = Logger(self.trace_id, self.flows, rpc)
|
|
30
|
+
self._loop = asyncio.get_event_loop()
|
|
31
|
+
self.is_api_handler = is_api_handler
|
|
32
|
+
self._pending_tasks = []
|
|
29
33
|
|
|
30
34
|
async def emit(self, event: Any):
|
|
31
|
-
|
|
35
|
+
if self.is_api_handler:
|
|
36
|
+
self.rpc.send_no_wait('emit', event)
|
|
37
|
+
return None
|
|
38
|
+
else:
|
|
39
|
+
return await self.rpc.send('emit', event)
|
|
40
|
+
|
|
41
|
+
# Add wrapper to handle non-awaited emit coroutine
|
|
42
|
+
def __getattribute__(self, name):
|
|
43
|
+
attr = super().__getattribute__(name)
|
|
44
|
+
if name == 'emit' and asyncio.iscoroutinefunction(attr):
|
|
45
|
+
@functools.wraps(attr)
|
|
46
|
+
def wrapper(*args, **kwargs):
|
|
47
|
+
coro = attr(*args, **kwargs)
|
|
48
|
+
# Check if this is being awaited
|
|
49
|
+
frame = sys._getframe(1)
|
|
50
|
+
if frame.f_code.co_name != '__await__':
|
|
51
|
+
task = asyncio.create_task(coro)
|
|
52
|
+
def handle_exception(t):
|
|
53
|
+
if t.done() and not t.cancelled() and t.exception():
|
|
54
|
+
print(f"Unhandled exception in background task: {t.exception()}", file=sys.stderr)
|
|
55
|
+
task.add_done_callback(handle_exception)
|
|
56
|
+
return task
|
|
57
|
+
return coro
|
|
58
|
+
return wrapper
|
|
59
|
+
return attr
|
|
32
60
|
|
|
33
61
|
async def run_python_module(file_path: str, rpc: RpcSender, args: Any) -> None:
|
|
34
62
|
try:
|
|
@@ -62,14 +90,40 @@ async def run_python_module(file_path: str, rpc: RpcSender, args: Any) -> None:
|
|
|
62
90
|
|
|
63
91
|
context = Context(args, rpc)
|
|
64
92
|
|
|
93
|
+
# Check if this is an API handler
|
|
94
|
+
is_api_handler = False
|
|
95
|
+
if hasattr(module, 'config'):
|
|
96
|
+
if isinstance(module.config, dict) and module.config.get('type') == 'api':
|
|
97
|
+
is_api_handler = True
|
|
98
|
+
elif hasattr(module.config, 'type') and module.config.type == 'api':
|
|
99
|
+
is_api_handler = True
|
|
100
|
+
|
|
101
|
+
# Create context with is_api_handler flag
|
|
102
|
+
context = Context(args, rpc, is_api_handler)
|
|
103
|
+
|
|
65
104
|
if contextInFirstArg:
|
|
66
105
|
result = await module.handler(context)
|
|
67
106
|
else:
|
|
107
|
+
if hasattr(args.data, 'body'):
|
|
108
|
+
args.data.body = serialize_for_json(args.data.body)
|
|
68
109
|
result = await module.handler(args.data, context)
|
|
69
110
|
|
|
111
|
+
# For API handlers, we want to return immediately without waiting for background tasks
|
|
112
|
+
# This prevents the API from getting stuck
|
|
113
|
+
if not is_api_handler:
|
|
114
|
+
pending = asyncio.all_tasks() - {asyncio.current_task()}
|
|
115
|
+
if pending:
|
|
116
|
+
await asyncio.gather(*pending)
|
|
117
|
+
|
|
70
118
|
if result:
|
|
71
119
|
await rpc.send('result', result)
|
|
72
120
|
|
|
121
|
+
# For non-API handlers, ensure all pending tasks are completed before closing
|
|
122
|
+
if not is_api_handler:
|
|
123
|
+
pending = asyncio.all_tasks() - {asyncio.current_task()}
|
|
124
|
+
if pending:
|
|
125
|
+
await asyncio.gather(*pending)
|
|
126
|
+
|
|
73
127
|
rpc.close()
|
|
74
128
|
rpc.send_no_wait('close', None)
|
|
75
129
|
|
|
@@ -89,4 +143,4 @@ if __name__ == "__main__":
|
|
|
89
143
|
rpc = RpcSender()
|
|
90
144
|
loop = asyncio.get_event_loop()
|
|
91
145
|
tasks = asyncio.gather(rpc.init(), run_python_module(file_path, rpc, parse_args(arg)))
|
|
92
|
-
loop.run_until_complete(tasks)
|
|
146
|
+
loop.run_until_complete(tasks)
|
package/dist/src/python/rpc.py
CHANGED
|
@@ -8,6 +8,20 @@ from typing import Any, Dict, Tuple
|
|
|
8
8
|
# get the FD from ENV
|
|
9
9
|
NODEIPCFD = int(os.environ["NODE_CHANNEL_FD"])
|
|
10
10
|
|
|
11
|
+
def serialize_for_json(obj: Any) -> Any:
|
|
12
|
+
"""Convert Python objects to JSON-serializable types"""
|
|
13
|
+
if hasattr(obj, '__dict__'):
|
|
14
|
+
return obj.__dict__
|
|
15
|
+
elif hasattr(obj, '_asdict'): # For namedtuples
|
|
16
|
+
return obj._asdict()
|
|
17
|
+
elif isinstance(obj, (list, tuple)):
|
|
18
|
+
return [serialize_for_json(item) for item in obj]
|
|
19
|
+
elif isinstance(obj, dict):
|
|
20
|
+
return {k: serialize_for_json(v) for k, v in obj.items()}
|
|
21
|
+
else:
|
|
22
|
+
# Try to return the object as is, letting json handle basic types
|
|
23
|
+
return obj
|
|
24
|
+
|
|
11
25
|
class RpcSender:
|
|
12
26
|
def __init__(self):
|
|
13
27
|
self.executing = True
|
|
@@ -1,19 +1,56 @@
|
|
|
1
1
|
from typing import Any
|
|
2
2
|
import asyncio
|
|
3
3
|
from rpc import RpcSender
|
|
4
|
+
import functools
|
|
5
|
+
import sys
|
|
4
6
|
|
|
5
7
|
class RpcStateManager:
|
|
6
8
|
def __init__(self, rpc: RpcSender):
|
|
7
9
|
self.rpc = rpc
|
|
10
|
+
self._loop = asyncio.get_event_loop()
|
|
8
11
|
|
|
9
12
|
async def get(self, trace_id: str, key: str) -> asyncio.Future[Any]:
|
|
10
|
-
|
|
13
|
+
result = await self.rpc.send('state.get', {'traceId': trace_id, 'key': key})
|
|
14
|
+
|
|
15
|
+
if result is None:
|
|
16
|
+
return {'data': None}
|
|
17
|
+
elif isinstance(result, dict):
|
|
18
|
+
if 'data' not in result:
|
|
19
|
+
return {'data': result}
|
|
20
|
+
|
|
21
|
+
return result
|
|
11
22
|
|
|
12
23
|
async def set(self, trace_id: str, key: str, value: Any) -> asyncio.Future[None]:
|
|
13
|
-
|
|
24
|
+
future = await self.rpc.send('state.set', {'traceId': trace_id, 'key': key, 'value': value})
|
|
25
|
+
return future
|
|
14
26
|
|
|
15
27
|
async def delete(self, trace_id: str, key: str) -> asyncio.Future[None]:
|
|
16
28
|
return await self.rpc.send('state.delete', {'traceId': trace_id, 'key': key})
|
|
17
29
|
|
|
18
30
|
async def clear(self, trace_id: str) -> asyncio.Future[None]:
|
|
19
31
|
return await self.rpc.send('state.clear', {'traceId': trace_id})
|
|
32
|
+
|
|
33
|
+
# Add wrappers to handle non-awaited coroutines
|
|
34
|
+
def __getattribute__(self, name):
|
|
35
|
+
attr = super().__getattribute__(name)
|
|
36
|
+
if name in ('get', 'set', 'delete', 'clear') and asyncio.iscoroutinefunction(attr):
|
|
37
|
+
@functools.wraps(attr)
|
|
38
|
+
def wrapper(*args, **kwargs):
|
|
39
|
+
coro = attr(*args, **kwargs)
|
|
40
|
+
# Check if this is being awaited
|
|
41
|
+
frame = sys._getframe(1)
|
|
42
|
+
if frame.f_code.co_name != '__await__':
|
|
43
|
+
# Not being awaited, schedule in background
|
|
44
|
+
# But we need to make sure this task completes before the handler returns
|
|
45
|
+
# So we'll return the task for the caller to await if needed
|
|
46
|
+
task = asyncio.create_task(coro)
|
|
47
|
+
# Add error handling for the background task
|
|
48
|
+
def handle_exception(t):
|
|
49
|
+
if t.done() and not t.cancelled() and t.exception():
|
|
50
|
+
print(f"Unhandled exception in background task: {t.exception()}", file=sys.stderr)
|
|
51
|
+
task.add_done_callback(handle_exception)
|
|
52
|
+
return task
|
|
53
|
+
# Being awaited, return coroutine as normal
|
|
54
|
+
return coro
|
|
55
|
+
return wrapper
|
|
56
|
+
return attr
|
|
@@ -3,6 +3,22 @@ class RpcStateManager
|
|
|
3
3
|
@sender = sender
|
|
4
4
|
end
|
|
5
5
|
|
|
6
|
+
# Support hash-like access with [:method_name] syntax
|
|
7
|
+
def [](method_name)
|
|
8
|
+
case method_name.to_sym
|
|
9
|
+
when :get
|
|
10
|
+
->(trace_id, key) { get(trace_id, key) }
|
|
11
|
+
when :set
|
|
12
|
+
->(trace_id, key, value) { set(trace_id, key, value) }
|
|
13
|
+
when :delete
|
|
14
|
+
->(trace_id, key) { delete(trace_id, key) }
|
|
15
|
+
when :clear
|
|
16
|
+
->(trace_id) { clear(trace_id) }
|
|
17
|
+
else
|
|
18
|
+
raise NoMethodError, "undefined method `#{method_name}' for #{self.class}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
6
22
|
def get(trace_id, key)
|
|
7
23
|
# Return promise to match Python/Node behavior
|
|
8
24
|
@sender.send('state.get', { traceId: trace_id, key: key })
|
|
@@ -22,4 +38,4 @@ class RpcStateManager
|
|
|
22
38
|
# Return promise to match Python/Node behavior
|
|
23
39
|
@sender.send('state.clear', { traceId: trace_id })
|
|
24
40
|
end
|
|
25
|
-
end
|
|
41
|
+
end
|
|
@@ -20,7 +20,7 @@ class Context
|
|
|
20
20
|
|
|
21
21
|
def emit(event)
|
|
22
22
|
# Add type field if not present to match Node.js/Python behavior
|
|
23
|
-
event = {
|
|
23
|
+
event = { topic: event[:topic] || event['topic'], data: event[:data] || event['data'] } unless event.is_a?(String)
|
|
24
24
|
promise = @rpc.send('emit', event)
|
|
25
25
|
promise # Return promise to maintain async pattern
|
|
26
26
|
end
|
package/dist/src/server.js
CHANGED
|
@@ -8,7 +8,9 @@ const cron_handler_1 = require("./cron-handler");
|
|
|
8
8
|
const body_parser_1 = __importDefault(require("body-parser"));
|
|
9
9
|
const express_1 = __importDefault(require("express"));
|
|
10
10
|
const http_1 = __importDefault(require("http"));
|
|
11
|
+
const multer_1 = __importDefault(require("multer"));
|
|
11
12
|
const socket_io_1 = require("socket.io");
|
|
13
|
+
const cors_1 = __importDefault(require("cors"));
|
|
12
14
|
const flows_endpoint_1 = require("./flows-endpoint");
|
|
13
15
|
const guards_1 = require("./guards");
|
|
14
16
|
const logger_1 = require("./logger");
|
|
@@ -23,6 +25,7 @@ const createServer = async (lockedData, eventManager, state, config) => {
|
|
|
23
25
|
const server = http_1.default.createServer(app);
|
|
24
26
|
const io = new socket_io_1.Server(server);
|
|
25
27
|
const loggerFactory = new LoggerFactory_1.LoggerFactory(config.isVerbose, io);
|
|
28
|
+
const upload = (0, multer_1.default)();
|
|
26
29
|
const allSteps = [...steps_1.systemSteps, ...lockedData.activeSteps];
|
|
27
30
|
const cronManager = (0, cron_handler_1.setupCronHandlers)(lockedData, eventManager, state, loggerFactory);
|
|
28
31
|
const asyncHandler = (step) => {
|
|
@@ -36,6 +39,7 @@ const createServer = async (lockedData, eventManager, state, config) => {
|
|
|
36
39
|
headers: req.headers,
|
|
37
40
|
pathParams: req.params,
|
|
38
41
|
queryParams: req.query,
|
|
42
|
+
files: req.files,
|
|
39
43
|
};
|
|
40
44
|
try {
|
|
41
45
|
const data = request;
|
|
@@ -75,10 +79,10 @@ const createServer = async (lockedData, eventManager, state, config) => {
|
|
|
75
79
|
const handler = asyncHandler(step);
|
|
76
80
|
const methods = {
|
|
77
81
|
GET: () => router.get(path, handler),
|
|
78
|
-
POST: () => router.post(path, handler),
|
|
79
|
-
PUT: () => router.put(path, handler),
|
|
82
|
+
POST: () => router.post(path, upload.any(), handler),
|
|
83
|
+
PUT: () => router.put(path, upload.any(), handler),
|
|
80
84
|
DELETE: () => router.delete(path, handler),
|
|
81
|
-
PATCH: () => router.patch(path, handler),
|
|
85
|
+
PATCH: () => router.patch(path, upload.any(), handler),
|
|
82
86
|
OPTIONS: () => router.options(path, handler),
|
|
83
87
|
HEAD: () => router.head(path, handler),
|
|
84
88
|
};
|
|
@@ -102,6 +106,7 @@ const createServer = async (lockedData, eventManager, state, config) => {
|
|
|
102
106
|
router.stack = filteredStack;
|
|
103
107
|
};
|
|
104
108
|
allSteps.filter(guards_1.isApiStep).forEach(addRoute);
|
|
109
|
+
app.use((0, cors_1.default)());
|
|
105
110
|
app.use(router);
|
|
106
111
|
(0, flows_endpoint_1.flowsEndpoint)(lockedData, app);
|
|
107
112
|
(0, flows_config_endpoint_1.flowsConfigEndpoint)(app, process.cwd());
|
|
@@ -63,8 +63,9 @@ const eventSchema = zod_1.z
|
|
|
63
63
|
subscribes: zod_1.z.array(zod_1.z.string()),
|
|
64
64
|
emits: emits,
|
|
65
65
|
virtualEmits: emits.optional(),
|
|
66
|
-
input: zod_1.z.union([jsonSchema, zod_1.z.null()]).optional(),
|
|
66
|
+
input: zod_1.z.union([jsonSchema, zod_1.z.object({}), zod_1.z.null()]).optional(),
|
|
67
67
|
flows: zod_1.z.array(zod_1.z.string()).optional(),
|
|
68
|
+
includeFiles: zod_1.z.array(zod_1.z.string()).optional(),
|
|
68
69
|
})
|
|
69
70
|
.strict();
|
|
70
71
|
const apiSchema = zod_1.z
|
|
@@ -78,7 +79,9 @@ const apiSchema = zod_1.z
|
|
|
78
79
|
virtualEmits: emits.optional(),
|
|
79
80
|
virtualSubscribes: zod_1.z.array(zod_1.z.string()).optional(),
|
|
80
81
|
flows: zod_1.z.array(zod_1.z.string()).optional(),
|
|
81
|
-
bodySchema: zod_1.z.union([jsonSchema, zod_1.z.null()]).optional(),
|
|
82
|
+
bodySchema: zod_1.z.union([jsonSchema, zod_1.z.object({}), zod_1.z.null()]).optional(),
|
|
83
|
+
includeFiles: zod_1.z.array(zod_1.z.string()).optional(),
|
|
84
|
+
middleware: zod_1.z.array(zod_1.z.any()).optional(),
|
|
82
85
|
})
|
|
83
86
|
.strict();
|
|
84
87
|
const cronSchema = zod_1.z
|
|
@@ -90,6 +93,7 @@ const cronSchema = zod_1.z
|
|
|
90
93
|
virtualEmits: emits.optional(),
|
|
91
94
|
emits: emits,
|
|
92
95
|
flows: zod_1.z.array(zod_1.z.string()).optional(),
|
|
96
|
+
includeFiles: zod_1.z.array(zod_1.z.string()).optional(),
|
|
93
97
|
})
|
|
94
98
|
.strict();
|
|
95
99
|
const validateStep = (step) => {
|
|
@@ -118,7 +122,7 @@ const validateStep = (step) => {
|
|
|
118
122
|
if (error instanceof zod_1.z.ZodError) {
|
|
119
123
|
return {
|
|
120
124
|
success: false,
|
|
121
|
-
error:
|
|
125
|
+
error: error.errors.map((err) => err.message).join(', '),
|
|
122
126
|
errors: error.errors.map((err) => ({ path: err.path.join('.'), message: err.message })),
|
|
123
127
|
};
|
|
124
128
|
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -32,6 +32,11 @@ export type EventConfig<TInput extends ZodObject<any> = any> = {
|
|
|
32
32
|
virtualEmits?: Emit[];
|
|
33
33
|
input: TInput;
|
|
34
34
|
flows?: string[];
|
|
35
|
+
/**
|
|
36
|
+
* Files to include in the step bundle.
|
|
37
|
+
* Needs to be relative to the step file.
|
|
38
|
+
*/
|
|
39
|
+
includeFiles?: string[];
|
|
35
40
|
};
|
|
36
41
|
export type NoopConfig = {
|
|
37
42
|
type: 'noop';
|
|
@@ -42,6 +47,7 @@ export type NoopConfig = {
|
|
|
42
47
|
flows?: string[];
|
|
43
48
|
};
|
|
44
49
|
export type ApiRouteMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD';
|
|
50
|
+
export type ApiMiddleware = (req: ApiRequest, ctx: FlowContext, next: () => Promise<ApiResponse>) => Promise<ApiResponse>;
|
|
45
51
|
export type ApiRouteConfig = {
|
|
46
52
|
type: 'api';
|
|
47
53
|
name: string;
|
|
@@ -52,13 +58,22 @@ export type ApiRouteConfig = {
|
|
|
52
58
|
virtualEmits?: Emit[];
|
|
53
59
|
virtualSubscribes?: string[];
|
|
54
60
|
flows?: string[];
|
|
61
|
+
middleware?: ApiMiddleware[];
|
|
55
62
|
bodySchema?: ZodObject<any>;
|
|
63
|
+
/**
|
|
64
|
+
* Files to include in the step bundle.
|
|
65
|
+
* Needs to be relative to the step file.
|
|
66
|
+
*/
|
|
67
|
+
includeFiles?: string[];
|
|
56
68
|
};
|
|
57
69
|
export type ApiRequest = {
|
|
58
70
|
pathParams: Record<string, string>;
|
|
59
71
|
queryParams: Record<string, string | string[]>;
|
|
60
72
|
body: Record<string, any>;
|
|
61
73
|
headers: Record<string, string | string[]>;
|
|
74
|
+
files?: Express.Multer.File[] | {
|
|
75
|
+
[fieldname: string]: Express.Multer.File[];
|
|
76
|
+
};
|
|
62
77
|
};
|
|
63
78
|
export type ApiResponse = {
|
|
64
79
|
status: number;
|
|
@@ -74,6 +89,11 @@ export type CronConfig = {
|
|
|
74
89
|
virtualEmits?: Emit[];
|
|
75
90
|
emits: Emit[];
|
|
76
91
|
flows?: string[];
|
|
92
|
+
/**
|
|
93
|
+
* Files to include in the step bundle.
|
|
94
|
+
* Needs to be relative to the step file.
|
|
95
|
+
*/
|
|
96
|
+
includeFiles?: string[];
|
|
77
97
|
};
|
|
78
98
|
export type CronHandler = (ctx: FlowContext) => Promise<void>;
|
|
79
99
|
export type StepHandler<T> = T extends EventConfig<any> ? EventHandler<T['input']> : T extends ApiRouteConfig ? ApiRouteHandler : T extends CronConfig ? CronHandler : never;
|
package/package.json
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
"name": "@motiadev/core",
|
|
3
3
|
"description": "Core functionality for the Motia framework, providing the foundation for building event-driven workflows.",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
|
-
"version": "0.1.0-beta.
|
|
5
|
+
"version": "0.1.0-beta.20",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"body-parser": "^1.20.3",
|
|
8
8
|
"colors": "^1.4.0",
|
|
9
|
+
"cors": "^2.8.5",
|
|
10
|
+
"dotenv": "^16.4.7",
|
|
9
11
|
"express": "^4.21.2",
|
|
10
12
|
"ioredis": "^5.4.2",
|
|
13
|
+
"multer": "1.4.5-lts.1",
|
|
11
14
|
"node-cron": "^3.0.3",
|
|
12
15
|
"socket.io": "^4.8.1",
|
|
13
16
|
"ts-node": "^10.9.2",
|
|
@@ -17,8 +20,10 @@
|
|
|
17
20
|
},
|
|
18
21
|
"devDependencies": {
|
|
19
22
|
"@types/body-parser": "^1.19.5",
|
|
23
|
+
"@types/cors": "^2.8.17",
|
|
20
24
|
"@types/express": "^5.0.0",
|
|
21
25
|
"@types/jest": "^29.5.14",
|
|
26
|
+
"@types/multer": "^1.4.12",
|
|
22
27
|
"@types/node-cron": "^3.0.11",
|
|
23
28
|
"@types/supertest": "^6.0.2",
|
|
24
29
|
"jest": "^29.7.0",
|