aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
696 lines (603 loc) • 23 kB
JavaScript
/**
* Workflow Validator
*
* Validates workflow YAML files against AIOS workflow conventions:
* 1. YAML syntax
* 2. Required fields (workflow.id, workflow.name, sequence)
* 3. Phase sequence integrity
* 4. Agent reference existence
* 5. Artifact flow (requires/creates chain)
* 6. Circular dependency detection
* 7. Conditional logic validation
* 8. Handoff prompt completeness
* 9. Mermaid diagram syntax (basic)
*
* Follows SquadValidator pattern for consistent validation UX.
*
* @module workflow-validator
* @version 1.0.0
*/
const fs = require('fs').promises;
const path = require('path');
const yaml = require('js-yaml');
/**
* Error codes for workflow validation
* @enum {string}
*/
const WorkflowValidationErrorCodes = {
WF_FILE_NOT_FOUND: 'WF_FILE_NOT_FOUND',
WF_YAML_PARSE_ERROR: 'WF_YAML_PARSE_ERROR',
WF_MISSING_REQUIRED_FIELD: 'WF_MISSING_REQUIRED_FIELD',
WF_INVALID_SEQUENCE: 'WF_INVALID_SEQUENCE',
WF_AGENT_NOT_FOUND: 'WF_AGENT_NOT_FOUND',
WF_ARTIFACT_CHAIN_BROKEN: 'WF_ARTIFACT_CHAIN_BROKEN',
WF_CIRCULAR_DEPENDENCY: 'WF_CIRCULAR_DEPENDENCY',
WF_INVALID_CONDITIONAL: 'WF_INVALID_CONDITIONAL',
WF_MISSING_HANDOFF: 'WF_MISSING_HANDOFF',
WF_INVALID_MERMAID: 'WF_INVALID_MERMAID',
WF_AGENT_AMBIGUOUS: 'WF_AGENT_AMBIGUOUS',
};
/**
* Workflow Validator class
*/
class WorkflowValidator {
/**
* @param {Object} [options={}]
* @param {boolean} [options.verbose=false] - Enable verbose logging
* @param {boolean} [options.strict=false] - Treat warnings as errors
* @param {string} [options.agentsPath] - Path to core agents directory for reference checking
* @param {string|null} [options.squadAgentsPath=null] - Path to squad agents directory for hybrid resolution
*/
constructor(options = {}) {
this.verbose = options.verbose || false;
this.strict = options.strict || false;
this.agentsPath = options.agentsPath || path.join(process.cwd(), '.aios-core', 'development', 'agents');
this.squadAgentsPath = options.squadAgentsPath || null;
}
/**
* @private
*/
_log(message) {
if (this.verbose) {
console.log(`[WorkflowValidator] ${message}`);
}
}
/**
* Validate a workflow file
* @param {string} workflowPath - Absolute or relative path to workflow YAML
* @returns {Promise<Object>} { valid, errors, warnings, suggestions }
*/
async validate(workflowPath) {
this._log(`Validating workflow: ${workflowPath}`);
const result = {
valid: true,
errors: [],
warnings: [],
suggestions: [],
};
// 1. Check file exists
const fileResult = await this._checkFileExists(workflowPath);
if (!fileResult.exists) {
result.valid = false;
result.errors.push({
code: WorkflowValidationErrorCodes.WF_FILE_NOT_FOUND,
message: `Workflow file not found: ${workflowPath}`,
suggestion: 'Check the file path or create the workflow first',
});
return result;
}
// 2. Parse YAML
const parseResult = await this.validateYamlSyntax(workflowPath);
this._mergeResults(result, parseResult);
if (!parseResult.data) {
return result; // Can't continue without parsed data
}
const workflow = parseResult.data;
// 3. Required fields
const fieldsResult = this.validateRequiredFields(workflow, workflowPath);
this._mergeResults(result, fieldsResult);
// 4. Phase sequence
if (workflow.workflow && workflow.workflow.sequence) {
const sequenceResult = this.validatePhaseSequence(workflow.workflow.sequence);
this._mergeResults(result, sequenceResult);
// 5. Agent references
const agentResult = await this.validateAgentReferences(workflow.workflow.sequence);
this._mergeResults(result, agentResult);
// 6. Artifact flow
const artifactResult = this.validateArtifactFlow(workflow.workflow.sequence);
this._mergeResults(result, artifactResult);
// 7. Circular dependencies
const circularResult = this.detectCircularDeps(workflow.workflow.sequence);
this._mergeResults(result, circularResult);
// 8. Conditional logic
const conditionalResult = this.validateConditionalLogic(workflow.workflow.sequence);
this._mergeResults(result, conditionalResult);
}
// 9. Handoff prompts
const handoffResult = this.validateHandoffPrompts(workflow);
this._mergeResults(result, handoffResult);
// 10. Mermaid diagram
const mermaidResult = this.validateMermaidDiagram(workflow);
this._mergeResults(result, mermaidResult);
// Strict mode: warnings become errors
if (this.strict && result.warnings.length > 0) {
result.errors.push(...result.warnings);
result.warnings = [];
result.valid = false;
}
this._log(`Validation complete: ${result.valid ? 'VALID' : 'INVALID'}`);
return result;
}
/**
* Validate YAML syntax
* @param {string} workflowPath
* @returns {Promise<Object>} Result with parsed data
*/
async validateYamlSyntax(workflowPath) {
const result = { valid: true, errors: [], warnings: [], suggestions: [], data: null };
try {
const content = await fs.readFile(workflowPath, 'utf-8');
const data = yaml.load(content);
if (!data || typeof data !== 'object') {
result.valid = false;
result.errors.push({
code: WorkflowValidationErrorCodes.WF_YAML_PARSE_ERROR,
message: 'Workflow file is empty or not a valid YAML object',
suggestion: 'Ensure the file contains valid YAML with a workflow: root key',
});
} else {
result.data = data;
}
} catch (error) {
result.valid = false;
result.errors.push({
code: WorkflowValidationErrorCodes.WF_YAML_PARSE_ERROR,
message: `YAML parse error: ${error.message}`,
suggestion: 'Fix YAML syntax — check indentation, colons, and special characters',
});
}
return result;
}
/**
* Validate required fields
* @param {Object} workflow - Parsed workflow object
* @param {string} filePath - For error reporting
* @returns {Object} Validation result
*/
validateRequiredFields(workflow, filePath) {
const result = { valid: true, errors: [], warnings: [], suggestions: [] };
const filename = path.basename(filePath);
if (!workflow.workflow) {
result.valid = false;
result.errors.push({
code: WorkflowValidationErrorCodes.WF_MISSING_REQUIRED_FIELD,
message: 'Missing top-level "workflow:" key',
file: filename,
suggestion: 'Add workflow: as the root key containing id, name, and sequence',
});
return result;
}
const wf = workflow.workflow;
const requiredFields = ['id', 'name'];
for (const field of requiredFields) {
if (!wf[field]) {
result.valid = false;
result.errors.push({
code: WorkflowValidationErrorCodes.WF_MISSING_REQUIRED_FIELD,
message: `Missing required field: workflow.${field}`,
file: filename,
suggestion: `Add "${field}:" under the workflow: key`,
});
}
}
if (!wf.sequence || !Array.isArray(wf.sequence) || wf.sequence.length === 0) {
result.valid = false;
result.errors.push({
code: WorkflowValidationErrorCodes.WF_MISSING_REQUIRED_FIELD,
message: 'Missing or empty "sequence:" array',
file: filename,
suggestion: 'Add sequence: with at least one step entry containing agent and creates/action',
});
}
// Warnings for recommended fields
const recommendedFields = ['description', 'type'];
for (const field of recommendedFields) {
if (!wf[field]) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_MISSING_REQUIRED_FIELD,
message: `Missing recommended field: workflow.${field}`,
file: filename,
suggestion: `Consider adding "${field}:" for better documentation`,
});
}
}
return result;
}
/**
* Validate phase sequence integrity
* @param {Array} sequence - Workflow sequence array
* @returns {Object} Validation result
*/
validatePhaseSequence(sequence) {
const result = { valid: true, errors: [], warnings: [], suggestions: [] };
if (!Array.isArray(sequence)) return result;
// Check that each step has an agent or is a control flow entry
for (let i = 0; i < sequence.length; i++) {
const step = sequence[i];
// Control flow entries (repeat_development_cycle, workflow_end) are valid
if (step.repeat_development_cycle || step.workflow_end) {
continue;
}
if (!step.agent) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_INVALID_SEQUENCE,
message: `Step ${i + 1} has no "agent:" field`,
suggestion: 'Each step should specify an agent responsible for that phase',
});
}
// Check that step has at least one action indicator
const hasAction = step.creates || step.updates || step.validates || step.action;
if (!hasAction) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_INVALID_SEQUENCE,
message: `Step ${i + 1} (agent: ${step.agent || 'unknown'}) has no action (creates/updates/validates/action)`,
suggestion: 'Add creates:, updates:, validates:, or action: to define what this step does',
});
}
}
return result;
}
/**
* Validate agent references exist as .md files.
* Supports hybrid resolution when squadAgentsPath is set:
* - Checks squad agents first, then core agents (squad-first, core-fallback)
* - Warns when agent exists in both contexts (WF_AGENT_AMBIGUOUS)
* - Supports explicit prefix: "core:architect" or "squad:validator"
* When squadAgentsPath is null, behavior is identical to pre-hybrid (backward compat).
*
* @param {Array} sequence
* @returns {Promise<Object>} Validation result
*/
async validateAgentReferences(sequence) {
const result = { valid: true, errors: [], warnings: [], suggestions: [] };
if (!Array.isArray(sequence)) return result;
const agents = new Set();
for (const step of sequence) {
if (step.agent) {
// Handle compound agents like "analyst/pm"
const agentNames = step.agent.split('/').map(a => a.trim());
for (const a of agentNames) {
agents.add(a);
}
}
}
// Skip meta-agent names
const metaAgents = new Set(['various']);
for (const agent of agents) {
if (metaAgents.has(agent)) continue;
// Parse explicit prefix (e.g., "core:architect" or "squad:validator")
let explicitContext = null;
let agentName = agent;
if (agent.includes(':')) {
const parts = agent.split(':');
if (parts[0] === 'core' || parts[0] === 'squad') {
explicitContext = parts[0];
agentName = parts.slice(1).join(':');
}
}
// Sanitize agent name to prevent path traversal
if (!agentName || agentName.includes('..') || agentName.includes('/') || agentName.includes('\\')) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_AGENT_NOT_FOUND,
message: `Agent "${agent}" has an invalid name`,
suggestion: 'Use simple agent identifiers without path segments',
});
continue;
}
if (this.squadAgentsPath) {
// Hybrid mode: check both contexts
const coreFile = path.join(this.agentsPath, `${agentName}.md`);
const squadFile = path.join(this.squadAgentsPath, `${agentName}.md`);
if (explicitContext === 'core') {
const coreExists = await this._checkFileExists(coreFile);
if (!coreExists.exists) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_AGENT_NOT_FOUND,
message: `Agent "${agent}" (explicit core) not found at ${coreFile}`,
suggestion: `Create ${agentName}.md in core agents/ or check the agent name`,
});
}
} else if (explicitContext === 'squad') {
const squadExists = await this._checkFileExists(squadFile);
if (!squadExists.exists) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_AGENT_NOT_FOUND,
message: `Agent "${agent}" (explicit squad) not found at ${squadFile}`,
suggestion: `Create ${agentName}.md in squad agents/ or check the agent name`,
});
}
} else {
// No prefix: squad-first, core-fallback
const squadExists = await this._checkFileExists(squadFile);
const coreExists = await this._checkFileExists(coreFile);
if (squadExists.exists && coreExists.exists) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_AGENT_AMBIGUOUS,
message: `Agent "${agentName}" exists in both core and squad contexts`,
suggestion: `Use explicit prefix "core:${agentName}" or "squad:${agentName}" to disambiguate`,
});
} else if (!squadExists.exists && !coreExists.exists) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_AGENT_NOT_FOUND,
message: `Agent "${agentName}" not found in core (${coreFile}) or squad (${squadFile})`,
suggestion: `Create ${agentName}.md in either agents directory or check the agent name`,
});
}
}
} else {
// Core-only mode (backward compatible): check only core agents
const agentFile = path.join(this.agentsPath, `${agentName}.md`);
const exists = await this._checkFileExists(agentFile);
if (!exists.exists) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_AGENT_NOT_FOUND,
message: `Agent "${agentName}" not found at ${agentFile}`,
suggestion: `Create ${agentName}.md in agents/ or check the agent name`,
});
}
}
}
return result;
}
/**
* Validate artifact flow — every requires: should have a preceding creates:
* @param {Array} sequence
* @returns {Object} Validation result
*/
validateArtifactFlow(sequence) {
const result = { valid: true, errors: [], warnings: [], suggestions: [] };
if (!Array.isArray(sequence)) return result;
const createdArtifacts = new Set();
for (let i = 0; i < sequence.length; i++) {
const step = sequence[i];
// Track created artifacts
if (step.creates) {
createdArtifacts.add(step.creates);
}
// Check required artifacts
if (step.requires) {
const required = step.requires;
// Handle both string and special values
if (typeof required === 'string' && !required.startsWith('all_') && !required.startsWith('sharded_')) {
if (!createdArtifacts.has(required)) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_ARTIFACT_CHAIN_BROKEN,
message: `Step ${i + 1} (agent: ${step.agent || 'unknown'}) requires "${required}" but no prior step creates it`,
suggestion: 'Add a creates: entry in a preceding step or fix the artifact name',
});
}
}
}
}
return result;
}
/**
* Detect circular dependencies using topological sort on requires/creates graph
* @param {Array} sequence
* @returns {Object} Validation result
*/
detectCircularDeps(sequence) {
const result = { valid: true, errors: [], warnings: [], suggestions: [] };
if (!Array.isArray(sequence)) return result;
const artifactToStep = new Map();
for (let i = 0; i < sequence.length; i++) {
const step = sequence[i];
if (step.creates) {
artifactToStep.set(step.creates, i);
}
}
// Build edges: step that requires → step that creates
const edges = new Map();
for (let i = 0; i < sequence.length; i++) {
edges.set(i, []);
}
for (let i = 0; i < sequence.length; i++) {
const step = sequence[i];
if (step.requires && typeof step.requires === 'string') {
const creatorStep = artifactToStep.get(step.requires);
if (creatorStep !== undefined && creatorStep !== i) {
// Step i depends on creatorStep
edges.get(i).push(creatorStep);
}
}
}
// Detect cycles with DFS
const visited = new Set();
const inStack = new Set();
const hasCycle = (node) => {
visited.add(node);
inStack.add(node);
for (const neighbor of (edges.get(node) || [])) {
if (!visited.has(neighbor)) {
if (hasCycle(neighbor)) return true;
} else if (inStack.has(neighbor)) {
return true;
}
}
inStack.delete(node);
return false;
};
for (let i = 0; i < sequence.length; i++) {
if (!visited.has(i)) {
if (hasCycle(i)) {
result.valid = false;
result.errors.push({
code: WorkflowValidationErrorCodes.WF_CIRCULAR_DEPENDENCY,
message: 'Circular dependency detected in artifact requires/creates chain',
suggestion: 'Review the requires/creates relationships and break the cycle',
});
break;
}
}
}
return result;
}
/**
* Validate conditional logic references
* @param {Array} sequence
* @returns {Object} Validation result
*/
validateConditionalLogic(sequence) {
const result = { valid: true, errors: [], warnings: [], suggestions: [] };
if (!Array.isArray(sequence)) return result;
for (let i = 0; i < sequence.length; i++) {
const step = sequence[i];
if (step.condition) {
// Conditions should be descriptive identifiers
if (typeof step.condition !== 'string' || step.condition.trim().length === 0) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_INVALID_CONDITIONAL,
message: `Step ${i + 1} has empty or invalid condition`,
suggestion: 'Conditions should be descriptive identifiers (e.g., "architecture_suggests_prd_changes")',
});
}
}
}
return result;
}
/**
* Validate handoff prompts exist for transitions
* @param {Object} workflow - Full parsed workflow
* @returns {Object} Validation result
*/
validateHandoffPrompts(workflow) {
const result = { valid: true, errors: [], warnings: [], suggestions: [] };
const wf = workflow.workflow;
if (!wf || !wf.sequence || !Array.isArray(wf.sequence)) return result;
// Count agent transitions (where one agent hands off to another)
let transitionCount = 0;
let prevAgent = null;
for (const step of wf.sequence) {
if (step.agent && step.agent !== prevAgent) {
if (prevAgent) transitionCount++;
prevAgent = step.agent;
}
}
// Check for handoff_prompts section
if (transitionCount > 0 && !wf.handoff_prompts) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_MISSING_HANDOFF,
message: `Workflow has ${transitionCount} agent transitions but no handoff_prompts section`,
suggestion: 'Add handoff_prompts: with guidance for each agent transition',
});
}
return result;
}
/**
* Validate Mermaid diagram syntax (basic check)
* @param {Object} workflow - Full parsed workflow
* @returns {Object} Validation result
*/
validateMermaidDiagram(workflow) {
const result = { valid: true, errors: [], warnings: [], suggestions: [] };
const wf = workflow.workflow;
if (!wf || !wf.flow_diagram) return result;
const diagram = wf.flow_diagram;
// Basic checks
if (!diagram.includes('graph') && !diagram.includes('flowchart')) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_INVALID_MERMAID,
message: 'Mermaid diagram missing graph/flowchart declaration',
suggestion: 'Start diagram with "graph TD" or "flowchart TD"',
});
}
// Check for unbalanced brackets
const openBrackets = (diagram.match(/\[/g) || []).length;
const closeBrackets = (diagram.match(/\]/g) || []).length;
if (openBrackets !== closeBrackets) {
result.warnings.push({
code: WorkflowValidationErrorCodes.WF_INVALID_MERMAID,
message: `Mermaid diagram has unbalanced brackets (${openBrackets} open, ${closeBrackets} close)`,
suggestion: 'Check for missing [ or ] in node definitions',
});
}
return result;
}
/**
* Format validation result for display (same pattern as SquadValidator)
* @param {Object} result
* @param {string} workflowPath
* @returns {string}
*/
formatResult(result, workflowPath) {
const lines = [];
lines.push(`Validating workflow: ${workflowPath}`);
lines.push('');
if (result.errors.length > 0) {
lines.push(`Errors: ${result.errors.length}`);
for (const err of result.errors) {
const filePart = err.file ? ` (${err.file})` : '';
lines.push(` - [${err.code}]${filePart}: ${err.message}`);
if (err.suggestion) {
lines.push(` Suggestion: ${err.suggestion}`);
}
}
lines.push('');
}
if (result.warnings.length > 0) {
lines.push(`Warnings: ${result.warnings.length}`);
for (const warn of result.warnings) {
const filePart = warn.file ? ` (${warn.file})` : '';
lines.push(` - [${warn.code}]${filePart}: ${warn.message}`);
if (warn.suggestion) {
lines.push(` Suggestion: ${warn.suggestion}`);
}
}
lines.push('');
}
if (result.suggestions.length > 0) {
lines.push(`Suggestions: ${result.suggestions.length}`);
for (const sug of result.suggestions) {
lines.push(` - ${sug}`);
}
lines.push('');
}
if (result.valid) {
if (result.warnings.length > 0) {
lines.push('Result: VALID (with warnings)');
} else {
lines.push('Result: VALID');
}
} else {
lines.push('Result: INVALID');
}
return lines.join('\n');
}
// ============ Private Helper Methods ============
/**
* @private
*/
async _checkFileExists(filePath) {
try {
await fs.access(filePath);
return { exists: true };
} catch {
return { exists: false };
}
}
/**
* @private
*/
_mergeResults(target, source) {
target.errors.push(...(source.errors || []));
target.warnings.push(...(source.warnings || []));
target.suggestions.push(...(source.suggestions || []));
if (source.errors && source.errors.length > 0) {
target.valid = false;
}
}
}
module.exports = {
WorkflowValidator,
WorkflowValidationErrorCodes,
};