Workflow & Command system
This commit is contained in:
430
server.js
430
server.js
@ -326,3 +326,433 @@ initDatabase().then(() => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
// ============================================
|
||||
// WORKFLOW EXECUTION ENGINE
|
||||
// ============================================
|
||||
|
||||
// Store active workflow executions in memory
|
||||
const activeExecutions = new Map();
|
||||
|
||||
// Execute workflow step by step
|
||||
async function executeWorkflow(workflowId, executionId, userId, targetWorkers = 'all') {
|
||||
try {
|
||||
// Get workflow definition
|
||||
const [workflows] = await pool.query('SELECT * FROM workflows WHERE id = ?', [workflowId]);
|
||||
if (workflows.length === 0) {
|
||||
throw new Error('Workflow not found');
|
||||
}
|
||||
|
||||
const workflow = workflows[0];
|
||||
const definition = JSON.parse(workflow.definition);
|
||||
|
||||
// Initialize execution state
|
||||
const executionState = {
|
||||
id: executionId,
|
||||
workflowId: workflowId,
|
||||
currentStep: 0,
|
||||
steps: definition.steps || [],
|
||||
results: [],
|
||||
status: 'running',
|
||||
waitingForInput: false,
|
||||
targetWorkers: targetWorkers,
|
||||
userId: userId
|
||||
};
|
||||
|
||||
activeExecutions.set(executionId, executionState);
|
||||
|
||||
// Start executing steps
|
||||
await executeNextStep(executionId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Workflow execution error:', error);
|
||||
await updateExecutionStatus(executionId, 'failed', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the next step in a workflow
|
||||
async function executeNextStep(executionId) {
|
||||
const state = activeExecutions.get(executionId);
|
||||
if (!state) return;
|
||||
|
||||
// Check if we've completed all steps
|
||||
if (state.currentStep >= state.steps.length) {
|
||||
await updateExecutionStatus(executionId, 'completed');
|
||||
activeExecutions.delete(executionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const step = state.steps[state.currentStep];
|
||||
|
||||
// Check if step has a condition
|
||||
if (step.condition && !evaluateCondition(step.condition, state)) {
|
||||
console.log(`[Workflow] Skipping step ${state.currentStep}: condition not met`);
|
||||
state.currentStep++;
|
||||
return executeNextStep(executionId);
|
||||
}
|
||||
|
||||
console.log(`[Workflow] Executing step ${state.currentStep}: ${step.name}`);
|
||||
|
||||
try {
|
||||
switch (step.type) {
|
||||
case 'execute':
|
||||
await executeCommandStep(executionId, step);
|
||||
break;
|
||||
case 'prompt':
|
||||
await executePromptStep(executionId, step);
|
||||
break;
|
||||
case 'wait':
|
||||
await executeWaitStep(executionId, step);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown step type: ${step.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
await addExecutionLog(executionId, {
|
||||
step: state.currentStep,
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
await updateExecutionStatus(executionId, 'failed', error.message);
|
||||
activeExecutions.delete(executionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute a command on workers
|
||||
async function executeCommandStep(executionId, step) {
|
||||
const state = activeExecutions.get(executionId);
|
||||
|
||||
// Get target workers
|
||||
const [workers] = await pool.query(
|
||||
'SELECT * FROM workers WHERE status = "online"'
|
||||
);
|
||||
|
||||
if (workers.length === 0) {
|
||||
throw new Error('No online workers available');
|
||||
}
|
||||
|
||||
// Filter workers based on target
|
||||
let targetWorkers = workers;
|
||||
if (step.targets && step.targets[0] !== 'all') {
|
||||
targetWorkers = workers.filter(w => step.targets.includes(w.name));
|
||||
}
|
||||
|
||||
// Send command to workers via WebSocket
|
||||
const commandMessage = {
|
||||
type: 'execute_command',
|
||||
execution_id: executionId,
|
||||
step_index: state.currentStep,
|
||||
command: step.command,
|
||||
timeout: step.timeout || 300000
|
||||
};
|
||||
|
||||
// Broadcast to target workers
|
||||
clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(commandMessage));
|
||||
}
|
||||
});
|
||||
|
||||
await addExecutionLog(executionId, {
|
||||
step: state.currentStep,
|
||||
action: 'command_sent',
|
||||
command: step.command,
|
||||
workers: targetWorkers.map(w => w.name),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// For now, move to next step immediately
|
||||
// In production, we'd wait for worker responses
|
||||
state.currentStep++;
|
||||
|
||||
// Small delay to allow command to execute
|
||||
setTimeout(() => executeNextStep(executionId), 1000);
|
||||
}
|
||||
|
||||
// Execute a user prompt step
|
||||
async function executePromptStep(executionId, step) {
|
||||
const state = activeExecutions.get(executionId);
|
||||
|
||||
state.waitingForInput = true;
|
||||
state.promptData = {
|
||||
message: step.message,
|
||||
options: step.options,
|
||||
step: state.currentStep
|
||||
};
|
||||
|
||||
await addExecutionLog(executionId, {
|
||||
step: state.currentStep,
|
||||
action: 'waiting_for_input',
|
||||
prompt: step.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Notify frontend that input is needed
|
||||
broadcast({
|
||||
type: 'execution_prompt',
|
||||
execution_id: executionId,
|
||||
prompt: state.promptData
|
||||
});
|
||||
}
|
||||
|
||||
// Execute a wait/delay step
|
||||
async function executeWaitStep(executionId, step) {
|
||||
const state = activeExecutions.get(executionId);
|
||||
const delay = step.duration || 1000;
|
||||
|
||||
await addExecutionLog(executionId, {
|
||||
step: state.currentStep,
|
||||
action: 'waiting',
|
||||
duration: delay,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
state.currentStep++;
|
||||
setTimeout(() => executeNextStep(executionId), delay);
|
||||
}
|
||||
|
||||
// Handle user input for prompts
|
||||
function handleUserInput(executionId, response) {
|
||||
const state = activeExecutions.get(executionId);
|
||||
if (!state || !state.waitingForInput) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.promptResponse = response;
|
||||
state.waitingForInput = false;
|
||||
state.currentStep++;
|
||||
|
||||
addExecutionLog(executionId, {
|
||||
step: state.currentStep - 1,
|
||||
action: 'user_response',
|
||||
response: response,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
executeNextStep(executionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Evaluate conditions
|
||||
function evaluateCondition(condition, state) {
|
||||
try {
|
||||
// Simple condition evaluation
|
||||
// In production, use a proper expression evaluator
|
||||
const promptResponse = state.promptResponse;
|
||||
return eval(condition);
|
||||
} catch (error) {
|
||||
console.error('Condition evaluation error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
async function updateExecutionStatus(executionId, status, error = null) {
|
||||
const updates = { status };
|
||||
if (status === 'completed' || status === 'failed') {
|
||||
updates.completed_at = new Date();
|
||||
}
|
||||
if (error) {
|
||||
// Add error to logs
|
||||
await addExecutionLog(executionId, { error, timestamp: new Date().toISOString() });
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'UPDATE executions SET status = ?, completed_at = ? WHERE id = ?',
|
||||
[status, updates.completed_at || null, executionId]
|
||||
);
|
||||
|
||||
broadcast({
|
||||
type: 'execution_status',
|
||||
execution_id: executionId,
|
||||
status: status
|
||||
});
|
||||
}
|
||||
|
||||
async function addExecutionLog(executionId, logEntry) {
|
||||
const [rows] = await pool.query('SELECT logs FROM executions WHERE id = ?', [executionId]);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const logs = JSON.parse(rows[0].logs || '[]');
|
||||
logs.push(logEntry);
|
||||
|
||||
await pool.query('UPDATE executions SET logs = ? WHERE id = ?', [
|
||||
JSON.stringify(logs),
|
||||
executionId
|
||||
]);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API ROUTES - Add these to your server.js
|
||||
// ============================================
|
||||
|
||||
// Start workflow execution
|
||||
app.post('/api/executions', authenticateSSO, async (req, res) => {
|
||||
try {
|
||||
const { workflow_id, target_workers } = req.body;
|
||||
const id = generateUUID();
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO executions (id, workflow_id, status, started_by, started_at, logs) VALUES (?, ?, ?, ?, NOW(), ?)',
|
||||
[id, workflow_id, 'running', req.user.username, JSON.stringify([])]
|
||||
);
|
||||
|
||||
// Start execution
|
||||
executeWorkflow(workflow_id, id, req.user.username, target_workers || 'all');
|
||||
|
||||
broadcast({ type: 'execution_started', execution_id: id, workflow_id });
|
||||
res.json({ id, workflow_id, status: 'running' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Respond to workflow prompt
|
||||
app.post('/api/executions/:id/respond', authenticateSSO, async (req, res) => {
|
||||
try {
|
||||
const { response } = req.body;
|
||||
const success = handleUserInput(req.params.id, response);
|
||||
|
||||
if (success) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(400).json({ error: 'Execution not waiting for input' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get execution details with logs
|
||||
app.get('/api/executions/:id', authenticateSSO, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM executions WHERE id = ?', [req.params.id]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
|
||||
const execution = rows[0];
|
||||
const state = activeExecutions.get(req.params.id);
|
||||
|
||||
res.json({
|
||||
...execution,
|
||||
logs: JSON.parse(execution.logs || '[]'),
|
||||
waiting_for_input: state?.waitingForInput || false,
|
||||
prompt: state?.promptData || null
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete worker (admin only)
|
||||
app.delete('/api/workers/:id', authenticateSSO, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.isAdmin) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM workers WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
broadcast({ type: 'worker_deleted', worker_id: req.params.id });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Send direct command to specific worker (for testing)
|
||||
app.post('/api/workers/:id/command', authenticateSSO, async (req, res) => {
|
||||
try {
|
||||
const { command } = req.body;
|
||||
const executionId = generateUUID();
|
||||
|
||||
// Send command via WebSocket
|
||||
const commandMessage = {
|
||||
type: 'execute_command',
|
||||
execution_id: executionId,
|
||||
command: command,
|
||||
worker_id: req.params.id,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(commandMessage));
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ success: true, execution_id: executionId });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EXAMPLE WORKFLOW DEFINITIONS
|
||||
// ============================================
|
||||
|
||||
// Example 1: Simple command execution
|
||||
const simpleWorkflow = {
|
||||
name: "Update System Packages",
|
||||
description: "Update all packages on target servers",
|
||||
steps: [
|
||||
{
|
||||
name: "Update package list",
|
||||
type: "execute",
|
||||
targets: ["all"],
|
||||
command: "apt update"
|
||||
},
|
||||
{
|
||||
name: "User Approval",
|
||||
type: "prompt",
|
||||
message: "Packages updated. Proceed with upgrade?",
|
||||
options: ["Yes", "No"]
|
||||
},
|
||||
{
|
||||
name: "Upgrade packages",
|
||||
type: "execute",
|
||||
targets: ["all"],
|
||||
command: "apt upgrade -y",
|
||||
condition: "promptResponse === 'Yes'"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Example 2: Complex workflow with conditions
|
||||
const backupWorkflow = {
|
||||
name: "Backup and Verify",
|
||||
description: "Create backup and verify integrity",
|
||||
steps: [
|
||||
{
|
||||
name: "Create backup",
|
||||
type: "execute",
|
||||
targets: ["all"],
|
||||
command: "tar -czf /tmp/backup-$(date +%Y%m%d).tar.gz /opt/pulse-worker"
|
||||
},
|
||||
{
|
||||
name: "Wait for backup",
|
||||
type: "wait",
|
||||
duration: 5000
|
||||
},
|
||||
{
|
||||
name: "Verify backup",
|
||||
type: "execute",
|
||||
targets: ["all"],
|
||||
command: "tar -tzf /tmp/backup-*.tar.gz > /dev/null && echo 'Backup OK' || echo 'Backup FAILED'"
|
||||
},
|
||||
{
|
||||
name: "Cleanup decision",
|
||||
type: "prompt",
|
||||
message: "Backup complete. Delete old backups?",
|
||||
options: ["Yes", "No", "Cancel"]
|
||||
},
|
||||
{
|
||||
name: "Cleanup old backups",
|
||||
type: "execute",
|
||||
targets: ["all"],
|
||||
command: "find /tmp -name 'backup-*.tar.gz' -mtime +7 -delete",
|
||||
condition: "promptResponse === 'Yes'"
|
||||
}
|
||||
]
|
||||
};
|
||||
Reference in New Issue
Block a user