0nmcp 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +101 -1
- package/index.js +8 -332
- package/lib/badges.json +1 -1
- package/lib/stats.json +4 -2
- package/package.json +32 -9
- package/server.js +272 -0
- package/tools.js +419 -0
- package/workflow.js +589 -0
package/workflow.js
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// 0nMCP — Workflow Runtime
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Loads .0n workflow files and executes them step-by-step.
|
|
5
|
+
// Uses the 0n-spec template engine for variable resolution.
|
|
6
|
+
// ============================================================
|
|
7
|
+
|
|
8
|
+
import { readFileSync, readdirSync, existsSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { randomUUID } from "crypto";
|
|
11
|
+
import { SERVICE_CATALOG } from "./catalog.js";
|
|
12
|
+
import { logExecution, WORKFLOWS_PATH } from "./connections.js";
|
|
13
|
+
|
|
14
|
+
// ── Template resolver (loaded dynamically) ───────────────
|
|
15
|
+
|
|
16
|
+
let resolveTemplate;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load the template resolver from 0n-spec (optional dependency).
|
|
20
|
+
* Falls back to a minimal inline resolver if not available.
|
|
21
|
+
*/
|
|
22
|
+
async function loadResolver() {
|
|
23
|
+
if (resolveTemplate) return;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const spec = await import("0n-spec");
|
|
27
|
+
if (spec.resolve) {
|
|
28
|
+
resolveTemplate = spec.resolve;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// 0n-spec not installed as dependency — try local path
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const { createRequire } = await import("module");
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
const spec = require("0n-spec");
|
|
39
|
+
if (spec.resolve) {
|
|
40
|
+
resolveTemplate = spec.resolve;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Not available via require either
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Try loading resolve.js directly from sibling 0n-spec repo (development)
|
|
48
|
+
try {
|
|
49
|
+
const { createRequire } = await import("module");
|
|
50
|
+
const { join } = await import("path");
|
|
51
|
+
const { homedir } = await import("os");
|
|
52
|
+
const require = createRequire(import.meta.url);
|
|
53
|
+
const specResolve = require(join(homedir(), "Github", "0n-spec", "resolve.js"));
|
|
54
|
+
if (specResolve.resolve) {
|
|
55
|
+
resolveTemplate = specResolve.resolve;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Local 0n-spec repo not available
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Minimal inline fallback — handles basic {{ref}} but no math/conditions
|
|
63
|
+
resolveTemplate = function minimalResolve(template, context) {
|
|
64
|
+
if (template == null) return template;
|
|
65
|
+
if (Array.isArray(template)) return template.map(item => minimalResolve(item, context));
|
|
66
|
+
if (typeof template === 'object') {
|
|
67
|
+
const result = {};
|
|
68
|
+
for (const [k, v] of Object.entries(template)) result[k] = minimalResolve(v, context);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
if (typeof template !== 'string') return template;
|
|
72
|
+
|
|
73
|
+
const singleMatch = template.match(/^\{\{(.+?)\}\}$/);
|
|
74
|
+
if (singleMatch) return resolveRef(singleMatch[1].trim(), context);
|
|
75
|
+
|
|
76
|
+
return template.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
|
|
77
|
+
const val = resolveRef(expr.trim(), context);
|
|
78
|
+
return val == null ? '' : String(val);
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function resolveRef(ref, ctx) {
|
|
83
|
+
if (ref === 'now') return new Date().toISOString();
|
|
84
|
+
if (ref === 'uuid') return randomUUID();
|
|
85
|
+
if (ref.startsWith('env.')) return deepGet(ctx.env || process.env, ref.slice(4));
|
|
86
|
+
if (ref.startsWith('inputs.')) return deepGet(ctx.inputs, ref.slice(7));
|
|
87
|
+
const val = deepGet(ctx.steps, ref);
|
|
88
|
+
if (val !== undefined) return val;
|
|
89
|
+
return deepGet(ctx, ref);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function deepGet(obj, path) {
|
|
93
|
+
if (!obj || !path) return undefined;
|
|
94
|
+
const segs = path.replace(/\[(\d+)\]/g, '.$1').split('.');
|
|
95
|
+
let cur = obj;
|
|
96
|
+
for (const s of segs) {
|
|
97
|
+
if (cur == null) return undefined;
|
|
98
|
+
cur = cur[s];
|
|
99
|
+
}
|
|
100
|
+
return cur;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Compute expression evaluator ─────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Evaluate a compute expression. Handles:
|
|
108
|
+
* - Numbers: 42 → 42
|
|
109
|
+
* - Simple math strings: "30 + 35 + 25" → 90
|
|
110
|
+
* - Ternary patterns: "((x ? 10 : 0) + (y ? 15 : 0))" → 25
|
|
111
|
+
* - Passthrough: anything else returns as-is
|
|
112
|
+
*/
|
|
113
|
+
function evaluateComputeExpression(expr) {
|
|
114
|
+
if (typeof expr === 'number') return expr;
|
|
115
|
+
if (typeof expr !== 'string') return expr;
|
|
116
|
+
|
|
117
|
+
const trimmed = expr.trim();
|
|
118
|
+
|
|
119
|
+
// Simple number
|
|
120
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return parseFloat(trimmed);
|
|
121
|
+
|
|
122
|
+
// Ternary expressions: (value ? trueVal : falseVal)
|
|
123
|
+
// Common in scoring: ((email ? 10 : 0) + (phone ? 15 : 0))
|
|
124
|
+
const ternaryRe = /\(([^()]*?)\s*\?\s*(-?\d+(?:\.\d+)?)\s*:\s*(-?\d+(?:\.\d+)?)\)/g;
|
|
125
|
+
if (ternaryRe.test(trimmed)) {
|
|
126
|
+
ternaryRe.lastIndex = 0;
|
|
127
|
+
let resolved = trimmed;
|
|
128
|
+
resolved = resolved.replace(ternaryRe, (_, condition, trueVal, falseVal) => {
|
|
129
|
+
const cond = condition.trim();
|
|
130
|
+
// Truthy: non-empty, non-"false", non-"null", non-"undefined", non-"0"
|
|
131
|
+
const isTruthy = cond && cond !== 'false' && cond !== 'null' && cond !== 'undefined' && cond !== '0' && cond !== '';
|
|
132
|
+
return isTruthy ? trueVal : falseVal;
|
|
133
|
+
});
|
|
134
|
+
// Now try to evaluate the remaining math
|
|
135
|
+
return evaluateComputeExpression(resolved);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Simple math: "30 + 35 + 25" or "(10 + 15 + 0)"
|
|
139
|
+
// Strip outer parens
|
|
140
|
+
let mathStr = trimmed.replace(/^\(+|\)+$/g, '').trim();
|
|
141
|
+
// Only allow numbers, operators, spaces, parens, and decimal points
|
|
142
|
+
if (/^[\d\s+\-*/().]+$/.test(mathStr)) {
|
|
143
|
+
try {
|
|
144
|
+
// Safe evaluation using Function constructor with no scope access
|
|
145
|
+
// Only processes pre-validated strings containing only math chars
|
|
146
|
+
const fn = new Function(`return (${mathStr});`);
|
|
147
|
+
const val = fn();
|
|
148
|
+
if (typeof val === 'number' && isFinite(val)) return val;
|
|
149
|
+
} catch {
|
|
150
|
+
// Fall through
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return trimmed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Internal action handlers ─────────────────────────────
|
|
158
|
+
|
|
159
|
+
const INTERNAL_ACTIONS = {
|
|
160
|
+
lookup(params) {
|
|
161
|
+
const { table, key, value } = params;
|
|
162
|
+
if (!table || !key) return { matched: false };
|
|
163
|
+
const entry = table[key];
|
|
164
|
+
return entry !== undefined ? { matched: true, value: entry } : { matched: false, key, value };
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
set(params) {
|
|
168
|
+
return { ...params };
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
transform(params) {
|
|
172
|
+
const { value, operation, ...rest } = params;
|
|
173
|
+
switch (operation) {
|
|
174
|
+
case 'uppercase': return { value: String(value).toUpperCase() };
|
|
175
|
+
case 'lowercase': return { value: String(value).toLowerCase() };
|
|
176
|
+
case 'trim': return { value: String(value).trim() };
|
|
177
|
+
case 'round': return { value: Math.round(Number(value)) };
|
|
178
|
+
case 'floor': return { value: Math.floor(Number(value)) };
|
|
179
|
+
case 'ceil': return { value: Math.ceil(Number(value)) };
|
|
180
|
+
case 'to_number': return { value: Number(value) };
|
|
181
|
+
case 'to_string': return { value: String(value) };
|
|
182
|
+
case 'split': return { value: String(value).split(rest.delimiter || ',') };
|
|
183
|
+
case 'join': return { value: Array.isArray(value) ? value.join(rest.delimiter || ',') : String(value) };
|
|
184
|
+
default: return { value };
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
compute(params) {
|
|
189
|
+
const result = { ...params };
|
|
190
|
+
|
|
191
|
+
// ── Pattern 1: Lookup table ── { lookup: { google: 30, ... }, key: "google", default: 0 }
|
|
192
|
+
if (params.lookup && params.key !== undefined) {
|
|
193
|
+
const val = params.lookup[params.key];
|
|
194
|
+
result.value = val !== undefined ? val : (params.default || 0);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Pattern 2: Expression ── { expression: "((x ? 10 : 0) + (y ? 15 : 0))" }
|
|
198
|
+
if (params.expression !== undefined) {
|
|
199
|
+
result.value = evaluateComputeExpression(params.expression);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Pattern 3: Grade thresholds ── { total: "30 + 35 + 25", grade_thresholds: {A:80,...}, ... }
|
|
203
|
+
if (params.grade_thresholds) {
|
|
204
|
+
// Evaluate total: could be a number, or a string like "30 + 35 + 25"
|
|
205
|
+
const total = evaluateComputeExpression(params.total);
|
|
206
|
+
result.total = total;
|
|
207
|
+
|
|
208
|
+
// Find grade — sort thresholds descending, first one where total >= threshold wins
|
|
209
|
+
const sorted = Object.entries(params.grade_thresholds).sort((a, b) => b[1] - a[1]);
|
|
210
|
+
let grade = sorted[sorted.length - 1]?.[0] || 'D';
|
|
211
|
+
for (const [g, threshold] of sorted) {
|
|
212
|
+
if (total >= threshold) { grade = g; break; }
|
|
213
|
+
}
|
|
214
|
+
result.grade = grade;
|
|
215
|
+
|
|
216
|
+
if (params.priority_map) result.priority = params.priority_map[grade] || 'UNKNOWN';
|
|
217
|
+
if (params.action_map) result.action = params.action_map[grade] || '';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return result;
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
condition(params) {
|
|
224
|
+
return { result: Boolean(params.test) };
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
map(params) {
|
|
228
|
+
const { value, mapping } = params;
|
|
229
|
+
if (!mapping) return { value };
|
|
230
|
+
return { value: mapping[value] !== undefined ? mapping[value] : (mapping._default !== undefined ? mapping._default : value) };
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// ── WorkflowRunner ───────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
export class WorkflowRunner {
|
|
237
|
+
/**
|
|
238
|
+
* @param {import("./connections.js").ConnectionManager} connections
|
|
239
|
+
*/
|
|
240
|
+
constructor(connections) {
|
|
241
|
+
this.connections = connections;
|
|
242
|
+
this._resolverReady = loadResolver();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Run a .0n workflow.
|
|
247
|
+
*
|
|
248
|
+
* @param {object} opts
|
|
249
|
+
* @param {string} [opts.workflowPath] — Name or full path to .0n file
|
|
250
|
+
* @param {object} [opts.workflow] — Inline workflow definition
|
|
251
|
+
* @param {object} [opts.inputs] — Input values
|
|
252
|
+
* @returns {Promise<WorkflowResult>}
|
|
253
|
+
*/
|
|
254
|
+
async run({ workflowPath, workflow, inputs = {} }) {
|
|
255
|
+
await this._resolverReady;
|
|
256
|
+
|
|
257
|
+
const startTime = Date.now();
|
|
258
|
+
const executionId = `wf_${Date.now()}_${randomUUID().slice(0, 8)}`;
|
|
259
|
+
|
|
260
|
+
// 1. Load workflow
|
|
261
|
+
const wf = workflow || this._loadWorkflow(workflowPath);
|
|
262
|
+
const workflowName = wf.$0n?.name || workflowPath || 'inline';
|
|
263
|
+
|
|
264
|
+
// 2. Validate
|
|
265
|
+
if (!wf.$0n || wf.$0n.type !== 'workflow') {
|
|
266
|
+
throw new Error(`Invalid workflow: $0n.type must be "workflow", got "${wf.$0n?.type}"`);
|
|
267
|
+
}
|
|
268
|
+
if (!wf.steps || !Array.isArray(wf.steps) || wf.steps.length === 0) {
|
|
269
|
+
throw new Error('Workflow has no steps');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Validate required inputs
|
|
273
|
+
if (wf.inputs) {
|
|
274
|
+
for (const [key, schema] of Object.entries(wf.inputs)) {
|
|
275
|
+
if (schema.required && (inputs[key] === undefined || inputs[key] === null)) {
|
|
276
|
+
throw new Error(`Missing required input: ${key}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 3. Build context
|
|
282
|
+
const context = {
|
|
283
|
+
inputs,
|
|
284
|
+
steps: {},
|
|
285
|
+
env: process.env,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// 4. Execute steps
|
|
289
|
+
const stepResults = [];
|
|
290
|
+
const errors = [];
|
|
291
|
+
|
|
292
|
+
for (const step of wf.steps) {
|
|
293
|
+
const stepId = step.id || `step_${stepResults.length}`;
|
|
294
|
+
|
|
295
|
+
// Evaluate conditions
|
|
296
|
+
if (step.conditions) {
|
|
297
|
+
const resolvedConditions = resolveTemplate(step.conditions, context);
|
|
298
|
+
const shouldRun = Array.isArray(resolvedConditions)
|
|
299
|
+
? resolvedConditions.every(Boolean)
|
|
300
|
+
: Boolean(resolvedConditions);
|
|
301
|
+
|
|
302
|
+
if (!shouldRun) {
|
|
303
|
+
stepResults.push({ id: stepId, status: 'skipped', service: step.service, action: step.action });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Resolve params
|
|
309
|
+
const resolvedParams = step.params ? resolveTemplate(step.params, context) : {};
|
|
310
|
+
|
|
311
|
+
// Execute
|
|
312
|
+
let result;
|
|
313
|
+
try {
|
|
314
|
+
if (step.service === 'internal') {
|
|
315
|
+
result = await this._executeInternal(step.action, resolvedParams);
|
|
316
|
+
} else {
|
|
317
|
+
result = await this._executeService(step.service, step.action, resolvedParams);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Store output in context
|
|
321
|
+
context.steps[stepId] = result.data || result;
|
|
322
|
+
|
|
323
|
+
stepResults.push({
|
|
324
|
+
id: stepId,
|
|
325
|
+
status: 'completed',
|
|
326
|
+
service: step.service,
|
|
327
|
+
action: step.action,
|
|
328
|
+
data: result.data || result,
|
|
329
|
+
});
|
|
330
|
+
} catch (err) {
|
|
331
|
+
const errorInfo = { id: stepId, service: step.service, action: step.action, error: err.message };
|
|
332
|
+
errors.push(errorInfo);
|
|
333
|
+
|
|
334
|
+
// Honor error handling strategy
|
|
335
|
+
const errorStrategy = step.error_handling?.on_error || wf.error_handling?.on_error || 'stop';
|
|
336
|
+
|
|
337
|
+
if (errorStrategy === 'retry') {
|
|
338
|
+
const maxRetries = step.error_handling?.retries || 3;
|
|
339
|
+
const backoff = step.error_handling?.backoff_ms || 1000;
|
|
340
|
+
let retrySuccess = false;
|
|
341
|
+
|
|
342
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
343
|
+
await new Promise(r => setTimeout(r, backoff * attempt));
|
|
344
|
+
try {
|
|
345
|
+
if (step.service === 'internal') {
|
|
346
|
+
result = await this._executeInternal(step.action, resolvedParams);
|
|
347
|
+
} else {
|
|
348
|
+
result = await this._executeService(step.service, step.action, resolvedParams);
|
|
349
|
+
}
|
|
350
|
+
context.steps[stepId] = result.data || result;
|
|
351
|
+
stepResults.push({ id: stepId, status: 'completed', service: step.service, action: step.action, data: result.data || result });
|
|
352
|
+
retrySuccess = true;
|
|
353
|
+
errors.pop(); // Remove error since retry succeeded
|
|
354
|
+
break;
|
|
355
|
+
} catch {
|
|
356
|
+
// Retry failed
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!retrySuccess) {
|
|
361
|
+
stepResults.push({ id: stepId, status: 'failed', ...errorInfo });
|
|
362
|
+
if (errorStrategy !== 'continue') break;
|
|
363
|
+
}
|
|
364
|
+
} else if (errorStrategy === 'continue') {
|
|
365
|
+
stepResults.push({ id: stepId, status: 'failed', ...errorInfo });
|
|
366
|
+
context.steps[stepId] = { error: err.message };
|
|
367
|
+
} else {
|
|
368
|
+
// stop (default)
|
|
369
|
+
stepResults.push({ id: stepId, status: 'failed', ...errorInfo });
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 5. Resolve outputs
|
|
376
|
+
const outputs = wf.outputs ? resolveTemplate(wf.outputs, context) : {};
|
|
377
|
+
|
|
378
|
+
const duration = Date.now() - startTime;
|
|
379
|
+
const successful = stepResults.filter(r => r.status === 'completed').length;
|
|
380
|
+
|
|
381
|
+
// 6. Log execution
|
|
382
|
+
logExecution({
|
|
383
|
+
success: errors.length === 0,
|
|
384
|
+
task: `workflow:${workflowName}`,
|
|
385
|
+
startedAt: new Date(startTime).toISOString(),
|
|
386
|
+
duration,
|
|
387
|
+
steps: stepResults.map(r => ({
|
|
388
|
+
service: r.service,
|
|
389
|
+
endpoint: r.action,
|
|
390
|
+
status: r.status,
|
|
391
|
+
error: r.error || null,
|
|
392
|
+
})),
|
|
393
|
+
servicesUsed: [...new Set(stepResults.map(r => r.service).filter(Boolean))],
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
success: errors.length === 0,
|
|
398
|
+
workflow: workflowName,
|
|
399
|
+
executionId,
|
|
400
|
+
stepsExecuted: stepResults.length,
|
|
401
|
+
stepsSuccessful: successful,
|
|
402
|
+
duration,
|
|
403
|
+
outputs,
|
|
404
|
+
steps: stepResults,
|
|
405
|
+
errors,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* List all .0n workflows in ~/.0n/workflows/.
|
|
411
|
+
*/
|
|
412
|
+
listWorkflows() {
|
|
413
|
+
if (!existsSync(WORKFLOWS_PATH)) return [];
|
|
414
|
+
|
|
415
|
+
const files = readdirSync(WORKFLOWS_PATH);
|
|
416
|
+
const workflows = [];
|
|
417
|
+
|
|
418
|
+
for (const file of files) {
|
|
419
|
+
if (!file.endsWith('.0n') && !file.endsWith('.0n.json')) continue;
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const filePath = join(WORKFLOWS_PATH, file);
|
|
423
|
+
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
424
|
+
|
|
425
|
+
if (!data.$0n || data.$0n.type !== 'workflow') continue;
|
|
426
|
+
|
|
427
|
+
workflows.push({
|
|
428
|
+
name: data.$0n.name || file.replace(/\.0n(\.json)?$/, ''),
|
|
429
|
+
file,
|
|
430
|
+
path: filePath,
|
|
431
|
+
description: data.$0n.description || '',
|
|
432
|
+
version: data.$0n.version || '1.0.0',
|
|
433
|
+
steps: data.steps?.length || 0,
|
|
434
|
+
trigger: data.trigger?.type || 'manual',
|
|
435
|
+
inputs: data.inputs ? Object.keys(data.inputs) : [],
|
|
436
|
+
});
|
|
437
|
+
} catch {
|
|
438
|
+
// Skip invalid files
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return workflows;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── Private methods ────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Load a .0n workflow file by name or path.
|
|
449
|
+
*/
|
|
450
|
+
_loadWorkflow(nameOrPath) {
|
|
451
|
+
if (!nameOrPath) throw new Error('No workflow specified');
|
|
452
|
+
|
|
453
|
+
// Try as full path first
|
|
454
|
+
if (nameOrPath.includes('/') || nameOrPath.includes('\\')) {
|
|
455
|
+
if (!existsSync(nameOrPath)) {
|
|
456
|
+
throw new Error(`Workflow file not found: ${nameOrPath}`);
|
|
457
|
+
}
|
|
458
|
+
return JSON.parse(readFileSync(nameOrPath, 'utf8'));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Try in ~/.0n/workflows/ with various extensions
|
|
462
|
+
const candidates = [
|
|
463
|
+
join(WORKFLOWS_PATH, `${nameOrPath}.0n`),
|
|
464
|
+
join(WORKFLOWS_PATH, `${nameOrPath}.0n.json`),
|
|
465
|
+
join(WORKFLOWS_PATH, nameOrPath),
|
|
466
|
+
];
|
|
467
|
+
|
|
468
|
+
for (const candidate of candidates) {
|
|
469
|
+
if (existsSync(candidate)) {
|
|
470
|
+
return JSON.parse(readFileSync(candidate, 'utf8'));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
throw new Error(`Workflow not found: ${nameOrPath}. Searched in ${WORKFLOWS_PATH}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Execute an internal action (no API call).
|
|
479
|
+
*/
|
|
480
|
+
async _executeInternal(action, params) {
|
|
481
|
+
const handler = INTERNAL_ACTIONS[action];
|
|
482
|
+
if (!handler) {
|
|
483
|
+
throw new Error(`Unknown internal action: ${action}. Available: ${Object.keys(INTERNAL_ACTIONS).join(', ')}`);
|
|
484
|
+
}
|
|
485
|
+
return { data: handler(params) };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Execute a service API call.
|
|
490
|
+
*/
|
|
491
|
+
async _executeService(service, action, params) {
|
|
492
|
+
const catalog = SERVICE_CATALOG[service];
|
|
493
|
+
if (!catalog) {
|
|
494
|
+
throw new Error(`Unknown service: ${service}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Resolve endpoint name
|
|
498
|
+
const endpointKey = this._resolveEndpoint(catalog, action);
|
|
499
|
+
const ep = catalog.endpoints[endpointKey];
|
|
500
|
+
if (!ep) {
|
|
501
|
+
throw new Error(`No endpoint found for ${service}.${action}. Available: ${Object.keys(catalog.endpoints).join(', ')}`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const creds = this.connections.getCredentials(service);
|
|
505
|
+
if (!creds) {
|
|
506
|
+
throw new Error(`Service ${service} not connected. Use connect_service first.`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Build URL
|
|
510
|
+
let url = catalog.baseUrl + ep.path;
|
|
511
|
+
const allParams = { ...creds, ...params };
|
|
512
|
+
url = url.replace(/\{(\w+)\}/g, (_, key) => allParams[key] || `{${key}}`);
|
|
513
|
+
|
|
514
|
+
// Build headers
|
|
515
|
+
const headers = catalog.authHeader(creds);
|
|
516
|
+
const options = { method: ep.method, headers };
|
|
517
|
+
|
|
518
|
+
// Build body
|
|
519
|
+
if (ep.method !== "GET" && params) {
|
|
520
|
+
const contentType = ep.contentType || "application/json";
|
|
521
|
+
if (contentType === "application/x-www-form-urlencoded") {
|
|
522
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
523
|
+
const flat = {};
|
|
524
|
+
for (const [k, v] of Object.entries(params)) {
|
|
525
|
+
if (typeof v !== "object") flat[k] = String(v);
|
|
526
|
+
}
|
|
527
|
+
options.body = new URLSearchParams(flat).toString();
|
|
528
|
+
} else {
|
|
529
|
+
headers["Content-Type"] = "application/json";
|
|
530
|
+
options.body = JSON.stringify(params);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Query string for GET
|
|
535
|
+
if (ep.method === "GET" && params) {
|
|
536
|
+
const flat = {};
|
|
537
|
+
for (const [k, v] of Object.entries(params)) {
|
|
538
|
+
if (typeof v !== "object") flat[k] = String(v);
|
|
539
|
+
}
|
|
540
|
+
const qs = new URLSearchParams(flat).toString();
|
|
541
|
+
if (qs) url += (url.includes("?") ? "&" : "?") + qs;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const response = await fetch(url, options);
|
|
545
|
+
const data = await response.json().catch(() => ({ status: response.status, statusText: response.statusText }));
|
|
546
|
+
|
|
547
|
+
if (!response.ok) {
|
|
548
|
+
throw new Error(`${service}.${action} failed (${response.status}): ${JSON.stringify(data)}`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return { data, status: response.status };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Resolve a dot-notation action (e.g., "customers.search") to a catalog endpoint key.
|
|
556
|
+
* Tries multiple fallback strategies.
|
|
557
|
+
*/
|
|
558
|
+
_resolveEndpoint(catalog, action) {
|
|
559
|
+
const endpoints = catalog.endpoints;
|
|
560
|
+
|
|
561
|
+
// 1. Direct match: action === endpoint key
|
|
562
|
+
if (endpoints[action]) return action;
|
|
563
|
+
|
|
564
|
+
// 2. Dot notation: "customers.search" → "search_customers"
|
|
565
|
+
if (action.includes('.')) {
|
|
566
|
+
const [resource, verb] = action.split('.');
|
|
567
|
+
const reversed = `${verb}_${resource}`;
|
|
568
|
+
if (endpoints[reversed]) return reversed;
|
|
569
|
+
|
|
570
|
+
// Try singular: "customers.create" → "create_customer"
|
|
571
|
+
const singular = resource.endsWith('s') ? resource.slice(0, -1) : resource;
|
|
572
|
+
const reversedSingular = `${verb}_${singular}`;
|
|
573
|
+
if (endpoints[reversedSingular]) return reversedSingular;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// 3. Underscore join: "create_customer" from "create" + "customer"
|
|
577
|
+
// Try action as-is with underscores
|
|
578
|
+
const underscored = action.replace(/\./g, '_');
|
|
579
|
+
if (endpoints[underscored]) return underscored;
|
|
580
|
+
|
|
581
|
+
// 4. Substring match: find any endpoint containing the action
|
|
582
|
+
for (const key of Object.keys(endpoints)) {
|
|
583
|
+
if (key.includes(action) || action.includes(key)) return key;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// 5. No match
|
|
587
|
+
return action;
|
|
588
|
+
}
|
|
589
|
+
}
|