Fix broken workflow engine + add 14 working pipeline presets + persistence

This commit is contained in:
drjones
2026-08-14 01:00:37 +00:00
parent 2b4b85cb0e
commit 5e924bb6e9
2 changed files with 241 additions and 37 deletions

View File

@@ -27,11 +27,29 @@ export const WorkflowsTab: React.FC = () => {
const [workflows, setWorkflows] = useState<MCPWorkflow[]>([]); const [workflows, setWorkflows] = useState<MCPWorkflow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [executingId, setExecutingId] = useState<string | null>(null); const [executingId, setExecutingId] = useState<string | null>(null);
const [inputsJson, setInputsJson] = useState<Record<string, string>>({ const [inputsJson, setInputsJson] = useState<Record<string, string>>({});
'wf-preset-summarize-slack': '{"url": "https://en.wikipedia.org/wiki/Model_Context_Protocol"}',
});
const [executionResult, setExecutionResult] = useState<any | null>(null); const [executionResult, setExecutionResult] = useState<any | null>(null);
// Derive example input keys from a workflow's {{input.X}} references.
const extractInputKeys = (wf: MCPWorkflow): string[] => {
const keys = new Set<string>();
for (const step of wf.steps) {
const raw = JSON.stringify(step.argsTemplate);
const re = /\{\{input\.([a-zA-Z0-9_]+)\}\}/g;
let m;
while ((m = re.exec(raw)) !== null) keys.add(m[1]);
}
return Array.from(keys);
};
const defaultInputFor = (wf: MCPWorkflow): string => {
const keys = extractInputKeys(wf);
if (keys.length === 0) return '{}';
const obj: Record<string, string> = {};
for (const k of keys) obj[k] = '';
return JSON.stringify(obj, null, 2);
};
const fetchWorkflows = async () => { const fetchWorkflows = async () => {
setLoading(true); setLoading(true);
try { try {
@@ -177,7 +195,7 @@ export const WorkflowsTab: React.FC = () => {
<label className="block text-xs font-semibold text-slate-700">Pipeline Input JSON Arguments:</label> <label className="block text-xs font-semibold text-slate-700">Pipeline Input JSON Arguments:</label>
<input <input
type="text" type="text"
value={inputsJson[wf.id] || '{"url": "https://en.wikipedia.org/wiki/Model_Context_Protocol"}'} value={inputsJson[wf.id] ?? defaultInputFor(wf)}
onChange={(e) => setInputsJson({ ...inputsJson, [wf.id]: e.target.value })} onChange={(e) => setInputsJson({ ...inputsJson, [wf.id]: e.target.value })}
className="w-full rounded-lg border border-slate-300 p-2.5 font-mono text-xs bg-white text-slate-900 focus:outline-none focus:ring-2 focus:ring-indigo-500" className="w-full rounded-lg border border-slate-300 p-2.5 font-mono text-xs bg-white text-slate-900 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/> />

View File

@@ -1,3 +1,5 @@
import fs from 'fs';
import path from 'path';
import { mcpRegistry } from './mcpRegistry.js'; import { mcpRegistry } from './mcpRegistry.js';
import { ToolExecutionResult } from '../types.js'; import { ToolExecutionResult } from '../types.js';
@@ -5,7 +7,7 @@ export interface WorkflowStep {
stepId: string; stepId: string;
name: string; name: string;
toolName: string; toolName: string;
// Arguments mapping template: e.g. { "url": "{{input.url}}", "text": "{{step1.result.markdown}}" } // Arguments mapping template: e.g. { "url": "{{input.url}}", "text": "{{step1.result}}" }
argsTemplate: Record<string, any>; argsTemplate: Record<string, any>;
} }
@@ -15,6 +17,7 @@ export interface MCPWorkflow {
description: string; description: string;
enabled: boolean; enabled: boolean;
steps: WorkflowStep[]; steps: WorkflowStep[];
isPreset?: boolean;
createdAt: string; createdAt: string;
updatedAt?: string; updatedAt?: string;
lastExecution?: { lastExecution?: {
@@ -27,38 +30,218 @@ export interface MCPWorkflow {
class MCPWorkflowManager { class MCPWorkflowManager {
private workflows: Map<string, MCPWorkflow> = new Map(); private workflows: Map<string, MCPWorkflow> = new Map();
private persistenceFilePath: string;
constructor() { constructor() {
this.persistenceFilePath = path.join(process.cwd(), 'data', 'workflows_persistence.json');
this.initializePresets(); this.initializePresets();
this.loadPersistedWorkflows();
}
private ensureDataDir() {
const dir = path.dirname(this.persistenceFilePath);
if (!fs.existsSync(dir)) {
try {
fs.mkdirSync(dir, { recursive: true });
} catch (err) {
console.error('[Workflows] Failed to create data dir:', err);
}
}
}
private loadPersistedWorkflows() {
try {
if (fs.existsSync(this.persistenceFilePath)) {
const raw = fs.readFileSync(this.persistenceFilePath, 'utf-8');
const data = JSON.parse(raw);
if (Array.isArray(data)) {
for (const wf of data) {
if (wf && wf.id) {
// Presets: keep code definition (source of truth) but restore enabled + lastExecution
const existing = this.workflows.get(wf.id);
if (existing && wf.isPreset) {
existing.enabled = wf.enabled ?? existing.enabled;
existing.lastExecution = wf.lastExecution;
} else {
this.workflows.set(wf.id, wf);
}
}
}
console.log(`[Workflows] Restored ${data.length} persisted workflow(s).`);
}
}
} catch (err) {
console.warn('[Workflows] Could not load persisted workflows, using presets:', err);
}
}
private savePersistedWorkflows() {
try {
this.ensureDataDir();
const all = Array.from(this.workflows.values());
fs.writeFileSync(this.persistenceFilePath, JSON.stringify(all, null, 2), 'utf-8');
} catch (err) {
console.error('[Workflows] Error saving workflows:', err);
}
} }
private initializePresets() { private initializePresets() {
const sampleWorkflow: MCPWorkflow = { const now = () => new Date().toISOString();
id: 'wf-preset-summarize-slack', const presets: Omit<MCPWorkflow, 'id' | 'createdAt'>[] = [
name: 'Web Scrape & Sentiment Pipeline', {
description: 'Scrapes HTML from target URL, converts to markdown, then extracts top keywords and text sentiment.', name: 'Web Scrape → AI Summary',
enabled: true, description: 'Fetches a page, extracts clean markdown, then summarizes it with the local LLM.',
steps: [ enabled: true,
{ isPreset: true,
stepId: 'step1', steps: [
name: 'Fetch Web Content', { stepId: 'step1', name: 'Fetch Web Content', toolName: 'web_scrape_markdown', argsTemplate: { url: '{{input.url}}' } },
toolName: 'web_scrape_markdown', { stepId: 'step2', name: 'Summarize', toolName: 'text_summarize_classify', argsTemplate: { text: '{{step1.result}}', task: 'summarize' } },
argsTemplate: { ],
url: '{{input.url}}', },
}, {
}, name: 'Web Scrape → Sentiment & Keywords',
{ description: 'Fetches a page then extracts key takeaways, sentiment, and entities.',
stepId: 'step2', enabled: true,
name: 'Analyze Sentiment & Keywords', isPreset: true,
toolName: 'extract_text_keywords_mcp', steps: [
argsTemplate: { { stepId: 'step1', name: 'Fetch Web Content', toolName: 'web_scrape_markdown', argsTemplate: { url: '{{input.url}}' } },
text: '{{step1.result.markdown}}', { stepId: 'step2', name: 'Analyze', toolName: 'text_summarize_classify', argsTemplate: { text: '{{step1.result}}', task: 'all' } },
}, ],
}, },
], {
createdAt: new Date().toISOString(), name: 'Search → Summarize → Remember',
}; description: 'Runs a web search, summarizes the synthesis, and stores it in the memory vault.',
this.workflows.set(sampleWorkflow.id, sampleWorkflow); enabled: true,
isPreset: true,
steps: [
{ stepId: 'step1', name: 'Search', toolName: 'web_search_gemini', argsTemplate: { query: '{{input.query}}' } },
{ stepId: 'step2', name: 'Summarize', toolName: 'text_summarize_classify', argsTemplate: { text: '{{step1.result}}', task: 'all' } },
{ stepId: 'step3', name: 'Store Memory', toolName: 'memory_store_set', argsTemplate: { key: '{{input.memoryKey}}', value: '{{step2.result}}', tags: ['research', 'workflow'] } },
],
},
{
name: 'API Fetch → Convert → Archive',
description: 'GETs a JSON API, converts it to another format, and archives it to memory.',
enabled: true,
isPreset: true,
steps: [
{ stepId: 'step1', name: 'Fetch API', toolName: 'http_client', argsTemplate: { url: '{{input.url}}', method: 'GET' } },
{ stepId: 'step2', name: 'Convert Format', toolName: 'data_converter', argsTemplate: { data: '{{step1.result}}', fromFormat: 'json', toFormat: '{{input.toFormat}}' } },
{ stepId: 'step3', name: 'Archive', toolName: 'memory_store_set', argsTemplate: { key: '{{input.memoryKey}}', value: '{{step2.result}}' } },
],
},
{
name: 'Encode → Hash Fingerprint',
description: 'Base64-encodes text then produces a SHA-256 hash of the encoded value.',
enabled: true,
isPreset: true,
steps: [
{ stepId: 'step1', name: 'Base64 Encode', toolName: 'encoder_decoder', argsTemplate: { input: '{{input.text}}', action: 'base64_encode' } },
{ stepId: 'step2', name: 'SHA-256 Hash', toolName: 'crypto_hash_generator', argsTemplate: { input: '{{step1.result}}', algorithm: 'sha256' } },
],
},
{
name: 'JSON → YAML → Stored Config',
description: 'Converts a JSON payload to YAML and stores it as a config in memory.',
enabled: true,
isPreset: true,
steps: [
{ stepId: 'step1', name: 'Convert to YAML', toolName: 'data_converter', argsTemplate: { data: '{{input.json}}', fromFormat: 'json', toFormat: 'yaml' } },
{ stepId: 'step2', name: 'Store Config', toolName: 'memory_store_set', argsTemplate: { key: '{{input.key}}', value: '{{step1.result}}', tags: ['config'] } },
],
},
{
name: 'Regex Extract → Convert → Store',
description: 'Extracts matches with a regex, converts the result set, and saves it.',
enabled: true,
isPreset: true,
steps: [
{ stepId: 'step1', name: 'Extract Matches', toolName: 'regex_tester', argsTemplate: { pattern: '{{input.pattern}}', text: '{{input.text}}', flags: 'g' } },
{ stepId: 'step2', name: 'Convert to JSON', toolName: 'data_converter', argsTemplate: { data: '{{step1.result}}', fromFormat: 'json', toFormat: 'json' } },
{ stepId: 'step3', name: 'Store', toolName: 'memory_store_set', argsTemplate: { key: '{{input.key}}', value: '{{step2.result}}' } },
],
},
{
name: 'Math Evaluate → Store Result',
description: 'Evaluates a math expression or stats set and saves the answer to memory.',
enabled: true,
isPreset: true,
steps: [
{ stepId: 'step1', name: 'Evaluate', toolName: 'math_evaluator', argsTemplate: { operation: '{{input.operation}}', expression: '{{input.expression}}' } },
{ stepId: 'step2', name: 'Store', toolName: 'memory_store_set', argsTemplate: { key: '{{input.key}}', value: '{{step1.result}}' } },
],
},
{
name: 'URL Parse → QR Code',
description: 'Parses and validates a URL, then renders it as a QR code.',
enabled: true,
isPreset: true,
steps: [
{ stepId: 'step1', name: 'Parse URL', toolName: 'network_utilities', argsTemplate: { action: 'parse_url', target: '{{input.url}}' } },
{ stepId: 'step2', name: 'Generate QR', toolName: 'qr_barcode_generator', argsTemplate: { text: '{{input.url}}', type: 'qr' } },
],
},
{
name: 'Cron → Explain → Remember',
description: 'Validates a cron expression, explains it in plain English, and saves the schedule.',
enabled: true,
isPreset: true,
steps: [
{ stepId: 'step1', name: 'Explain Cron', toolName: 'cron_calculator', argsTemplate: { expression: '{{input.expression}}' } },
{ stepId: 'step2', name: 'Store Schedule', toolName: 'memory_store_set', argsTemplate: { key: '{{input.key}}', value: '{{step1.result}}' } },
],
},
{
name: 'Hash → URL Encode',
description: 'Generates a SHA-256 hash then URL-encodes it for safe transport.',
enabled: true,
isPreset: true,
steps: [
{ stepId: 'step1', name: 'Hash', toolName: 'crypto_hash_generator', argsTemplate: { input: '{{input.text}}', algorithm: 'sha256' } },
{ stepId: 'step2', name: 'URL Encode', toolName: 'encoder_decoder', argsTemplate: { input: '{{step1.result}}', action: 'url_encode' } },
],
},
{
name: 'Search → Store Memory',
description: 'Runs a web search and stores the raw synthesis directly into the memory vault.',
enabled: true,
isPreset: true,
steps: [
{ stepId: 'step1', name: 'Search', toolName: 'web_search_gemini', argsTemplate: { query: '{{input.query}}' } },
{ stepId: 'step2', name: 'Store', toolName: 'memory_store_set', argsTemplate: { key: '{{input.key}}', value: '{{step1.result}}' } },
],
},
{
name: 'Convert JSON → QR Code',
description: 'Converts a JSON payload to a query string and encodes it as a QR code.',
enabled: true,
isPreset: true,
steps: [
{ stepId: 'step1', name: 'Convert to Query String', toolName: 'data_converter', argsTemplate: { data: '{{input.json}}', fromFormat: 'json', toFormat: 'query_string' } },
{ stepId: 'step2', name: 'Generate QR', toolName: 'qr_barcode_generator', argsTemplate: { text: '{{step1.result}}', type: 'qr' } },
],
},
{
name: 'Encode → Hash → Remember',
description: 'URL-encodes input, hashes it, and persists the final fingerprint to memory.',
enabled: true,
isPreset: true,
steps: [
{ stepId: 'step1', name: 'URL Encode', toolName: 'encoder_decoder', argsTemplate: { input: '{{input.text}}', action: 'url_encode' } },
{ stepId: 'step2', name: 'Hash', toolName: 'crypto_hash_generator', argsTemplate: { input: '{{step1.result}}', algorithm: 'sha256' } },
{ stepId: 'step3', name: 'Store', toolName: 'memory_store_set', argsTemplate: { key: '{{input.key}}', value: '{{step2.result}}' } },
],
},
];
for (const p of presets) {
const id = `wf-preset-${p.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 48)}`;
this.workflows.set(id, {
...p,
id,
createdAt: now(),
});
}
} }
public getWorkflows(): MCPWorkflow[] { public getWorkflows(): MCPWorkflow[] {
@@ -78,6 +261,7 @@ class MCPWorkflowManager {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
this.workflows.set(id, newWf); this.workflows.set(id, newWf);
this.savePersistedWorkflows();
return newWf; return newWf;
} }
@@ -91,11 +275,14 @@ class MCPWorkflowManager {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
this.workflows.set(id, updated); this.workflows.set(id, updated);
this.savePersistedWorkflows();
return updated; return updated;
} }
public deleteWorkflow(id: string): boolean { public deleteWorkflow(id: string): boolean {
return this.workflows.delete(id); const deleted = this.workflows.delete(id);
if (deleted) this.savePersistedWorkflows();
return deleted;
} }
public async executeWorkflow(id: string, initialInputs: Record<string, any>): Promise<{ public async executeWorkflow(id: string, initialInputs: Record<string, any>): Promise<{
@@ -121,10 +308,7 @@ class MCPWorkflowManager {
const contextData: Record<string, any> = { input: initialInputs }; const contextData: Record<string, any> = { input: initialInputs };
for (const step of wf.steps) { for (const step of wf.steps) {
// Interpolate arguments template from contextData
const resolvedArgs = this.resolveArgsTemplate(step.argsTemplate, contextData); const resolvedArgs = this.resolveArgsTemplate(step.argsTemplate, contextData);
// Execute tool
const execRes = await mcpRegistry.executeServerTool(step.toolName, resolvedArgs); const execRes = await mcpRegistry.executeServerTool(step.toolName, resolvedArgs);
stepResults[step.stepId] = execRes; stepResults[step.stepId] = execRes;
contextData[step.stepId] = execRes; contextData[step.stepId] = execRes;
@@ -137,6 +321,7 @@ class MCPWorkflowManager {
success: false, success: false,
stepResults, stepResults,
}; };
this.savePersistedWorkflows();
return { return {
success: false, success: false,
durationMs, durationMs,
@@ -157,6 +342,7 @@ class MCPWorkflowManager {
success: true, success: true,
stepResults, stepResults,
}; };
this.savePersistedWorkflows();
return { return {
success: true, success: true,
@@ -183,14 +369,14 @@ class MCPWorkflowManager {
} }
private interpolateString(str: string, context: Record<string, any>): any { private interpolateString(str: string, context: Record<string, any>): any {
// Exact match e.g. "{{step1.result.markdown}}" // Exact match e.g. "{{step1.result}}"
const exactMatch = str.match(/^\{\{([a-zA-Z0-9_.]+)\}\}$/); const exactMatch = str.match(/^\{\{([a-zA-Z0-9_.]+)\}\}$/);
if (exactMatch) { if (exactMatch) {
const path = exactMatch[1]; const path = exactMatch[1];
return this.getValueByPath(context, path); return this.getValueByPath(context, path);
} }
// Partial string match e.g. "Result is {{step1.result.title}}" // Partial string match e.g. "Result is {{step1.result}}"
return str.replace(/\{\{([a-zA-Z0-9_.]+)\}\}/g, (_, path) => { return str.replace(/\{\{([a-zA-Z0-9_.]+)\}\}/g, (_, path) => {
const v = this.getValueByPath(context, path); const v = this.getValueByPath(context, path);
if (v === undefined || v === null) return ''; if (v === undefined || v === null) return '';