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/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
+ }