@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
751 lines (650 loc) • 23.4 kB
text/typescript
import {
ProcessDefinition,
Activity,
ProcessTrigger,
PersonaType
} from './types.js';
import { ProcessStore } from './process-store.js';
import { TriggerSuggestionEngine } from './trigger-suggestion-engine.js';
interface BuilderSession {
id: string;
currentStep: number;
processData: Partial<ProcessDefinition>;
responses: Record<string, any>;
suggestedTriggers?: ProcessTrigger[];
}
interface BuilderStep {
id: string;
type: 'question' | 'multiSelect' | 'confirm' | 'trigger-suggestion';
prompt: string;
helpText?: string;
options?: string[];
validation?: (value: any) => boolean | string;
}
export class ProcessBuilder {
private sessions: Map<string, BuilderSession> = new Map();
private store: ProcessStore;
private triggerEngine: TriggerSuggestionEngine;
constructor(store: ProcessStore) {
this.store = store;
this.triggerEngine = new TriggerSuggestionEngine(store);
}
// Simple API for tests
async createProcess(options: {
name: string;
description?: string;
persona?: PersonaType;
activities?: Activity[];
triggers?: ProcessTrigger[];
variables?: Record<string, any>;
}): Promise<ProcessDefinition> {
if (!options.name || options.name.trim().length === 0) {
throw new Error('Process name is required');
}
const process: ProcessDefinition = {
id: `process-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
name: options.name,
description: options.description || '',
version: '1.0.0',
persona: options.persona,
triggers: options.triggers || [],
activities: options.activities || [],
variables: options.variables || {},
metadata: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
executionCount: 0
}
};
await this.store.saveProcess(process);
return process;
}
async addActivity(processId: string, activity: Activity, position?: number): Promise<ProcessDefinition> {
const process = await this.store.getProcess(processId);
if (!process) {
throw new Error(`Process ${processId} not found`);
}
if (position !== undefined && position >= 0 && position <= process.activities.length) {
process.activities.splice(position, 0, activity);
} else {
process.activities.push(activity);
}
process.metadata.updatedAt = new Date().toISOString();
await this.store.saveProcess(process);
return process;
}
async updateActivity(processId: string, activityId: string, updates: Partial<Activity>): Promise<ProcessDefinition> {
const process = await this.store.getProcess(processId);
if (!process) {
throw new Error(`Process ${processId} not found`);
}
const activityIndex = process.activities.findIndex(a => a.id === activityId);
if (activityIndex === -1) {
throw new Error(`Activity ${activityId} not found`);
}
process.activities[activityIndex] = { ...process.activities[activityIndex], ...updates };
process.metadata.updatedAt = new Date().toISOString();
await this.store.saveProcess(process);
return process;
}
async removeActivity(processId: string, activityId: string): Promise<ProcessDefinition> {
const process = await this.store.getProcess(processId);
if (!process) {
throw new Error(`Process ${processId} not found`);
}
const originalLength = process.activities.length;
process.activities = process.activities.filter(a => a.id !== activityId);
if (process.activities.length === originalLength) {
throw new Error('Activity not found');
}
process.metadata.updatedAt = new Date().toISOString();
await this.store.saveProcess(process);
return process;
}
async addTrigger(processId: string, trigger: ProcessTrigger): Promise<ProcessDefinition> {
const process = await this.store.getProcess(processId);
if (!process) {
throw new Error(`Process ${processId} not found`);
}
// Check for duplicate trigger ID
if (process.triggers.some(t => t.id === trigger.id)) {
throw new Error(`Trigger with ID ${trigger.id} already exists`);
}
process.triggers.push(trigger);
process.metadata.updatedAt = new Date().toISOString();
await this.store.saveProcess(process);
return process;
}
async updateTrigger(processId: string, triggerId: string, updates: Partial<ProcessTrigger>): Promise<ProcessDefinition> {
const process = await this.store.getProcess(processId);
if (!process) {
throw new Error(`Process ${processId} not found`);
}
const triggerIndex = process.triggers.findIndex(t => t.id === triggerId);
if (triggerIndex === -1) {
throw new Error(`Trigger ${triggerId} not found`);
}
process.triggers[triggerIndex] = { ...process.triggers[triggerIndex], ...updates };
process.metadata.updatedAt = new Date().toISOString();
await this.store.saveProcess(process);
return process;
}
async removeTrigger(processId: string, triggerId: string): Promise<ProcessDefinition> {
const process = await this.store.getProcess(processId);
if (!process) {
throw new Error(`Process ${processId} not found`);
}
process.triggers = process.triggers.filter(t => t.id !== triggerId);
process.metadata.updatedAt = new Date().toISOString();
await this.store.saveProcess(process);
return process;
}
async cloneProcess(processId: string, newName: string, options?: { persona?: string }): Promise<ProcessDefinition> {
const process = await this.store.getProcess(processId);
if (!process) {
throw new Error(`Process ${processId} not found`);
}
const cloned: ProcessDefinition = {
...process,
id: `process-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
name: newName,
persona: (options?.persona || process.persona) as PersonaType,
// Deep clone activities with new IDs
activities: process.activities.map(activity => ({
...activity,
id: `${activity.id}-clone-${Date.now()}`
})),
// Deep clone triggers with new IDs
triggers: process.triggers.map(trigger => ({
...trigger,
id: `${trigger.id}-clone-${Date.now()}`
})),
// Deep clone variables
variables: JSON.parse(JSON.stringify(process.variables)),
metadata: {
...process.metadata,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
executionCount: 0,
}
};
await this.store.saveProcess(cloned);
return cloned;
}
private steps: BuilderStep[] = [
{
id: 'name',
type: 'question',
prompt: '📝 What would you like to name this process?',
helpText: 'Choose a descriptive name that explains what the process does',
validation: (value: string) => {
if (!value || value.trim().length < 3) {
return 'Process name must be at least 3 characters';
}
return true;
}
},
{
id: 'description',
type: 'question',
prompt: '📄 Describe what this process does',
helpText: 'This helps others understand the purpose of the process'
},
{
id: 'persona',
type: 'question',
prompt: '👤 Who is this process for?',
helpText: 'Choose a persona or type "custom" for general use',
options: [
'software-engineer',
'architect',
'cto',
'ceo',
'cfo',
'marketing',
'sales',
'product-manager',
'designer',
'custom'
]
},
{
id: 'activities',
type: 'multiSelect',
prompt: '📊 What activities should this process include?',
helpText: 'Select all that apply (we\'ll configure details later)',
options: [
'Run automated tools',
'Wait for human approval',
'Delegate to AI agents',
'Make conditional decisions',
'Loop through items',
'Call external APIs',
'Generate reports',
'Send notifications'
]
},
{
id: 'trigger-review',
type: 'trigger-suggestion',
prompt: '🎯 Based on your process, here are my trigger recommendations',
helpText: 'You can accept, modify, or choose alternatives'
},
{
id: 'confirm',
type: 'confirm',
prompt: '✅ Ready to create your process?',
helpText: 'You can always modify the process later'
}
];
async startProcessBuilder(): Promise<BuilderSession> {
const session: BuilderSession = {
id: this.generateSessionId(),
currentStep: 0,
processData: {},
responses: {}
};
this.sessions.set(session.id, session);
return session;
}
async continueBuilder(sessionId: string, response: any): Promise<BuilderResult> {
const session = this.sessions.get(sessionId);
if (!session) {
return { valid: false, message: 'Session not found' };
}
const currentStep = this.steps[session.currentStep];
// Validate response
if (currentStep.validation) {
const validationResult = currentStep.validation(response);
if (validationResult !== true) {
return {
valid: false,
message: typeof validationResult === 'string' ? validationResult : 'Invalid response'
};
}
}
// Store response
session.responses[currentStep.id] = response;
// Process based on step type
if (currentStep.type === 'trigger-suggestion') {
// User selected a trigger configuration
if (response.selectedTrigger) {
session.processData.triggers = [response.selectedTrigger];
}
}
// Move to next step
session.currentStep++;
// Check if we need to prepare trigger suggestions
if (session.currentStep < this.steps.length) {
const nextStep = this.steps[session.currentStep];
if (nextStep.type === 'trigger-suggestion') {
// Generate trigger suggestions based on collected data
const tempProcess = this.buildProcessFromSession(session);
const suggestions = await this.triggerEngine.suggestTriggers(tempProcess);
session.suggestedTriggers = suggestions.map(s => s.trigger);
}
}
// Check if complete
if (session.currentStep >= this.steps.length) {
const process = this.buildProcessFromSession(session);
this.sessions.delete(sessionId);
return {
valid: true,
completed: true,
process
};
}
return { valid: true };
}
formatCurrentStep(sessionId: string): string {
const session = this.sessions.get(sessionId);
if (!session) {
return 'Session not found';
}
const step = this.steps[session.currentStep];
const progress = `Step ${session.currentStep + 1} of ${this.steps.length}`;
let output = `${progress}\n\n${step.prompt}\n`;
if (step.helpText) {
output += `💡 ${step.helpText}\n`;
}
if (step.type === 'trigger-suggestion' && session.suggestedTriggers) {
output += '\n';
session.suggestedTriggers.forEach((trigger, index) => {
output += `\n${index + 1}. **${trigger.name}**\n`;
output += ` Type: ${trigger.type}\n`;
if (trigger.type === 'schedule' && trigger.config.cron) {
output += ` Schedule: ${trigger.config.cron}\n`;
}
if (trigger.reasoning) {
output += ` 📝 ${trigger.reasoning}\n`;
}
});
output += '\nWhich trigger would you like to use? (number or "custom" for manual setup)';
} else if (step.options) {
output += '\nOptions:\n';
step.options.forEach((option, index) => {
output += `${index + 1}. ${option}\n`;
});
}
return output;
}
cancelSession(sessionId: string): void {
this.sessions.delete(sessionId);
}
private buildProcessFromSession(session: BuilderSession): ProcessDefinition {
const responses = session.responses;
// Build activities based on selections
const activities: Activity[] = [];
const activitySelections = responses.activities || [];
// Map selections to basic activity templates
if (activitySelections.includes('Run automated tools')) {
activities.push({
id: 'tool-1',
type: 'tool',
name: 'Execute Tool',
config: {
toolName: 'check_project_status',
toolArgs: {}
}
});
}
if (activitySelections.includes('Wait for human approval')) {
activities.push({
id: 'human-1',
type: 'human',
name: 'Human Approval',
config: {
prompt: 'Please review and approve',
approvalType: 'any'
}
});
}
if (activitySelections.includes('Generate reports')) {
activities.push({
id: 'tool-2',
type: 'tool',
name: 'Generate Report',
config: {
toolName: 'generate_report',
toolArgs: {}
}
});
}
const process: ProcessDefinition = {
id: this.generateId('process'),
name: responses.name || 'Unnamed Process',
description: responses.description,
version: '1.0.0',
persona: responses.persona as PersonaType,
triggers: session.processData.triggers || [],
activities,
variables: {},
metadata: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
executionCount: 0
}
};
return process;
}
private generateSessionId(): string {
return `session-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
private generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
// Additional methods for test compatibility
async setVariables(processId: string, variables: Record<string, any>, merge: boolean = false): Promise<ProcessDefinition> {
const process = await this.store.getProcess(processId);
if (!process) {
throw new Error(`Process ${processId} not found`);
}
if (merge) {
// Merge variables with existing ones
process.variables = {
...process.variables,
...variables
};
} else {
// Replace all variables
process.variables = variables;
}
process.metadata.updatedAt = new Date().toISOString();
await this.store.saveProcess(process);
return process;
}
async validateProcess(processId: string): Promise<{
isValid: boolean;
errors: Array<{ type: 'error'; field?: string; message: string }>;
warnings: Array<{ type: 'warning'; field?: string; message: string }>;
}> {
const process = await this.store.getProcess(processId);
if (!process) {
return {
isValid: false,
errors: [{ type: 'error', message: `Process ${processId} not found` }],
warnings: []
};
}
const errors: Array<{ type: 'error'; field?: string; message: string }> = [];
const warnings: Array<{ type: 'warning'; field?: string; message: string }> = [];
// Validate required fields
if (!process.name || process.name.trim().length === 0) {
errors.push({ type: 'error', field: 'name', message: 'Process name is required' });
}
if (!process.id) {
errors.push({ type: 'error', field: 'id', message: 'Process ID is required' });
}
// Validate activities
if (!process.activities || process.activities.length === 0) {
warnings.push({ type: 'warning', message: 'Process has no activities defined' });
} else {
// Validate each activity
process.activities.forEach((activity, index) => {
if (!activity.id) {
errors.push({
type: 'error',
field: `activities[${index}].id`,
message: `Activity at index ${index} is missing ID`
});
}
if (!activity.type) {
errors.push({
type: 'error',
field: `activities[${index}].type`,
message: `Activity ${activity.id || index} is missing type`
});
}
if (!activity.name) {
errors.push({
type: 'error',
field: `activities[${index}].name`,
message: `Activity ${activity.id || index} is missing name`
});
}
// Validate activity-specific config
if (activity.type === 'tool' && (!activity.config || !activity.config.toolName)) {
errors.push({
type: 'error',
field: `activities[${index}].config`,
message: `Tool activity ${activity.id || index} is missing toolName in config`
});
}
// Validate condition syntax
if (activity.condition && !this.isValidCondition(activity.condition)) {
errors.push({
type: 'error',
field: `activities[${index}].condition`,
message: `Invalid condition syntax for activity ${activity.id || index}`
});
}
});
}
// Validate triggers
if (!process.triggers || process.triggers.length === 0) {
warnings.push({ type: 'warning', message: 'Process has no triggers defined' });
} else {
process.triggers.forEach((trigger, index) => {
if (!trigger.id) {
errors.push({
type: 'error',
field: `triggers[${index}].id`,
message: `Trigger at index ${index} is missing ID`
});
}
if (!trigger.type) {
errors.push({
type: 'error',
field: `triggers[${index}].type`,
message: `Trigger ${trigger.id || index} is missing type`
});
}
if (trigger.type === 'schedule') {
if (!trigger.config?.cron) {
errors.push({
type: 'error',
field: `triggers[${index}].config.cron`,
message: `Schedule trigger ${trigger.id || index} is missing cron expression`
});
} else if (!this.isValidCronExpression(trigger.config.cron)) {
errors.push({
type: 'error',
field: `triggers[${index}].config.cron`,
message: `Invalid cron expression for trigger ${trigger.id || index}`
});
}
}
});
}
return {
isValid: errors.length === 0,
errors,
warnings
};
}
private isValidCondition(condition: string): boolean {
// Simple validation for condition syntax
// Should contain comparison operators and valid syntax
const validOperators = ['===', '!==', '==', '!=', '>', '<', '>=', '<=', '&&', '||', '!'];
const hasValidOperator = validOperators.some(op => condition.includes(op));
// Check for invalid characters
const invalidChars = ['@', '#', '$'];
const hasInvalidChars = invalidChars.some(char => condition.includes(char));
// Check for balanced parentheses
let parenCount = 0;
for (const char of condition) {
if (char === '(') parenCount++;
if (char === ')') parenCount--;
if (parenCount < 0) return false;
}
return hasValidOperator && !hasInvalidChars && parenCount === 0;
}
private isValidCronExpression(cron: string): boolean {
// Basic cron expression validation
// Format: minute hour day month weekday
const parts = cron.trim().split(/\s+/);
// Must have exactly 5 parts for standard cron
if (parts.length !== 5) {
return false;
}
// Validate each part
const patterns = [
/^(\*|[0-5]?\d)(,(\*|[0-5]?\d))*$/, // minute (0-59)
/^(\*|[01]?\d|2[0-3])(,(\*|[01]?\d|2[0-3]))*$/, // hour (0-23)
/^(\*|[1-9]|[12]\d|3[01])(,(\*|[1-9]|[12]\d|3[01]))*$/, // day (1-31)
/^(\*|[1-9]|1[0-2])(,(\*|[1-9]|1[0-2]))*$/, // month (1-12)
/^(\*|[0-6])(,(\*|[0-6]))*$/ // weekday (0-6)
];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
// Handle ranges (e.g., 1-5)
if (part.includes('-')) {
const rangeParts = part.split('-');
if (rangeParts.length !== 2) return false;
// For simplicity, just check it's not completely invalid
if (!/^\d+$/.test(rangeParts[0]) || !/^\d+$/.test(rangeParts[1])) {
return false;
}
continue;
}
// Handle step values (e.g., */5)
if (part.includes('/')) {
const stepParts = part.split('/');
if (stepParts.length !== 2) return false;
if (stepParts[0] !== '*' && !/^\d+$/.test(stepParts[0])) return false;
if (!/^\d+$/.test(stepParts[1])) return false;
continue;
}
// Validate against pattern
if (!patterns[i].test(part)) {
return false;
}
}
return true;
}
async exportProcess(processId: string): Promise<any> {
const process = await this.store.getProcess(processId);
if (!process) {
throw new Error(`Process ${processId} not found`);
}
// Create export format without runtime data
const exportData = {
name: process.name,
description: process.description,
version: process.version,
persona: process.persona,
triggers: process.triggers,
activities: process.activities,
variables: process.variables,
exportVersion: '1.0',
exportedAt: new Date().toISOString()
};
return exportData;
}
async importProcess(data: any, options?: { namePrefix?: string }): Promise<ProcessDefinition> {
let importData: any;
// Handle both JSON string and object input
if (typeof data === 'string') {
try {
importData = JSON.parse(data);
} catch (error) {
throw new Error('Invalid JSON format');
}
} else {
importData = data;
}
// Build process from import data
const process: ProcessDefinition = {
id: this.generateId('process'),
name: (options?.namePrefix || '') + (importData.name || 'Imported Process'),
description: importData.description,
version: importData.version || '1.0.0',
persona: importData.persona || 'custom',
triggers: importData.triggers || [],
activities: importData.activities || [],
variables: importData.variables || {},
metadata: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
executionCount: 0,
}
};
// Save the imported process first
await this.store.saveProcess(process);
// Then validate it
const validation = await this.validateProcess(process.id);
if (!validation.isValid) {
// Remove invalid process
await this.store.deleteProcess(process.id);
const errorMessages = validation.errors.map(e => e.message).join(', ');
throw new Error(`Invalid process: ${errorMessages}`);
}
return process;
}
}
interface BuilderResult {
valid: boolean;
message?: string;
completed?: boolean;
process?: ProcessDefinition;
}