aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
794 lines (691 loc) • 22.4 kB
JavaScript
/**
* Subtask Verifier - Story 4.5
*
* Verifies subtask completion by running type-specific verification commands.
* Supports: command, api, browser, e2e verification types.
*
* @module subtask-verifier
*/
const fs = require('fs').promises;
const path = require('path');
// Optional dependencies with graceful fallback
let yaml;
try {
yaml = require('js-yaml');
} catch {
yaml = null;
}
let execa;
try {
execa = require('execa').execa;
} catch {
execa = null;
}
let chalk;
try {
chalk = require('chalk');
} catch {
chalk = {
blue: (s) => s,
green: (s) => s,
red: (s) => s,
yellow: (s) => s,
cyan: (s) => s,
gray: (s) => s,
bold: (s) => s,
};
}
/**
* Verification result interface
* @typedef {Object} VerificationResult
* @property {string} subtaskId - The subtask identifier
* @property {boolean} passed - Whether verification passed
* @property {string} verificationType - Type of verification performed
* @property {number} duration - Execution time in ms
* @property {string[]} logs - Execution logs
* @property {number} attempts - Number of attempts made
* @property {Error|null} error - Error if failed
*/
/**
* Verification config from implementation.yaml
* @typedef {Object} VerificationConfig
* @property {string} type - Verification type (command, api, browser, e2e)
* @property {string} [command] - Shell command to run
* @property {string} [url] - API endpoint URL
* @property {number} [expectedStatus] - Expected HTTP status
* @property {Object} [options] - Additional fetch options
* @property {string} [selector] - Browser selector to check
* @property {string} [testCommand] - E2E test command
* @property {number} [timeout] - Verification timeout in seconds
*/
/**
* SubtaskVerifier - Verifies subtask completion
*/
class SubtaskVerifier {
/**
* @param {Object} options - Configuration options
* @param {string} options.implementationPath - Path to implementation.yaml
* @param {number} [options.timeout=60000] - Default timeout in ms
* @param {number} [options.maxRetries=3] - Maximum retry attempts
* @param {boolean} [options.verbose=false] - Enable verbose logging
* @param {string} [options.cwd] - Working directory for commands
*/
constructor(options = {}) {
this.implementationPath = options.implementationPath;
this.timeout = options.timeout || 60000;
this.maxRetries = options.maxRetries || 3;
this.verbose = options.verbose || false;
this.cwd = options.cwd || process.cwd();
this.implementation = null;
this.logs = [];
// Verify required dependencies
if (!yaml) {
throw new Error('js-yaml module not available - install with: npm install js-yaml');
}
}
/**
* Log message with timestamp
* @private
*/
_log(message, level = 'info') {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
this.logs.push(logEntry);
if (this.verbose) {
const colorFn =
{
info: chalk.blue,
success: chalk.green,
error: chalk.red,
warn: chalk.yellow,
}[level] || chalk.gray;
console.log(colorFn(logEntry));
}
}
/**
* Load implementation.yaml
* @returns {Promise<Object>}
*/
async loadImplementation() {
try {
const content = await fs.readFile(this.implementationPath, 'utf8');
this.implementation = yaml.load(content);
this._log(`Loaded implementation from ${this.implementationPath}`, 'info');
return this.implementation;
} catch (error) {
throw new Error(`Failed to load implementation.yaml: ${error.message}`);
}
}
/**
* Find subtask by ID in implementation
* @param {string} subtaskId - Subtask identifier (e.g., '1.1', '2.3')
* @returns {Object|null}
*/
findSubtask(subtaskId) {
if (!this.implementation || !this.implementation.phases) {
return null;
}
for (const phase of this.implementation.phases) {
if (phase.subtasks) {
const subtask = phase.subtasks.find((st) => st.id === subtaskId);
if (subtask) {
return { ...subtask, phase: phase.id, phaseName: phase.name };
}
}
}
return null;
}
/**
* Verify a subtask by ID
* @param {string} subtaskId - Subtask identifier
* @returns {Promise<VerificationResult>}
*/
async verify(subtaskId) {
const startTime = Date.now();
this.logs = [];
// Load implementation if not already loaded
if (!this.implementation) {
await this.loadImplementation();
}
// Find subtask
const subtask = this.findSubtask(subtaskId);
if (!subtask) {
return this._createResult(subtaskId, false, 'unknown', startTime, {
error: new Error(`Subtask ${subtaskId} not found in implementation`),
});
}
this._log(`Verifying subtask ${subtaskId}: ${subtask.description}`, 'info');
// Get verification config
const verification = subtask.verification;
if (!verification) {
this._log(`No verification config for subtask ${subtaskId}, marking as manual`, 'warn');
return this._createResult(subtaskId, true, 'manual', startTime, {
logs: ['No automated verification configured - manual check required'],
});
}
// Run verification with retries
return this._verifyWithRetries(subtaskId, verification, startTime);
}
/**
* Run verification with retry logic
* @private
*/
async _verifyWithRetries(subtaskId, verification, startTime) {
const verificationType = verification.type || 'command';
let lastError = null;
let attempts = 0;
for (let i = 0; i < this.maxRetries; i++) {
attempts = i + 1;
try {
this._log(`Attempt ${attempts}/${this.maxRetries} for subtask ${subtaskId}`, 'info');
const result = await this._runVerification(verification);
if (result.passed) {
this._log(`Verification passed on attempt ${attempts}`, 'success');
return this._createResult(subtaskId, true, verificationType, startTime, {
attempts,
output: result.output,
});
}
lastError = new Error(result.error || 'Verification returned false');
this._log(`Attempt ${attempts} failed: ${lastError.message}`, 'warn');
// Wait before retry (exponential backoff)
if (i < this.maxRetries - 1) {
const delay = Math.min(1000 * Math.pow(2, i), 10000);
this._log(`Waiting ${delay}ms before retry...`, 'info');
await this._sleep(delay);
}
} catch (error) {
lastError = error;
this._log(`Attempt ${attempts} threw error: ${error.message}`, 'error');
// Check if this is a transient error worth retrying
if (!this._isTransientError(error) || i === this.maxRetries - 1) {
break;
}
const delay = Math.min(1000 * Math.pow(2, i), 10000);
await this._sleep(delay);
}
}
return this._createResult(subtaskId, false, verificationType, startTime, {
attempts,
error: lastError,
});
}
/**
* Run verification based on type
* @private
*/
async _runVerification(config) {
const type = config.type || 'command';
const timeout = (config.timeout || 60) * 1000;
switch (type) {
case 'command':
return this._verifyCommand(config, timeout);
case 'api':
return this._verifyApi(config, timeout);
case 'browser':
return this._verifyBrowser(config, timeout);
case 'e2e':
return this._verifyE2E(config, timeout);
default:
throw new Error(`Unknown verification type: ${type}`);
}
}
/**
* Command verification - run shell command, check exit code
* @private
*/
async _verifyCommand(config, timeout) {
if (!config.command) {
throw new Error('Command verification requires "command" field');
}
this._log(`Running command: ${config.command}`, 'info');
if (!execa) {
// Fallback to child_process
const { execSync } = require('child_process');
try {
const output = execSync(config.command, {
cwd: this.cwd,
timeout,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, CI: 'true' },
});
this._log(`Command output: ${output.substring(0, 200)}...`, 'info');
return { passed: true, output };
} catch (error) {
return {
passed: false,
error: `Exit code ${error.status}: ${error.stderr || error.message}`,
output: error.stdout,
};
}
}
try {
const parts = config.command.split(' ');
const program = parts[0];
const args = parts.slice(1);
const { stdout, stderr, exitCode } = await execa(program, args, {
cwd: this.cwd,
shell: true,
timeout,
encoding: 'utf8',
env: { ...process.env, CI: 'true' },
reject: false,
});
if (exitCode === 0) {
this._log(`Command succeeded with output: ${stdout.substring(0, 200)}...`, 'info');
return { passed: true, output: stdout };
}
return {
passed: false,
error: `Exit code ${exitCode}: ${stderr || stdout}`,
output: stdout,
};
} catch (error) {
if (error.timedOut) {
throw new Error(`Command timed out after ${timeout}ms`);
}
throw error;
}
}
/**
* API verification - make HTTP request, check response
* @private
*/
async _verifyApi(config, timeout) {
if (!config.url) {
throw new Error('API verification requires "url" field');
}
this._log(`Calling API: ${config.url}`, 'info');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const fetchOptions = {
method: config.method || 'GET',
signal: controller.signal,
...(config.options || {}),
};
if (config.body) {
fetchOptions.body = JSON.stringify(config.body);
fetchOptions.headers = {
'Content-Type': 'application/json',
...(fetchOptions.headers || {}),
};
}
const response = await fetch(config.url, fetchOptions);
clearTimeout(timeoutId);
const expectedStatus = config.expectedStatus || 200;
const passed = response.status === expectedStatus;
let output;
try {
output = await response.json();
} catch {
output = await response.text();
}
this._log(`API response: status=${response.status}, expected=${expectedStatus}`, 'info');
if (passed) {
// Additional validation if expectedOutput pattern provided
if (config.expectedOutput) {
const regex = new RegExp(config.expectedOutput);
const outputStr = typeof output === 'string' ? output : JSON.stringify(output);
if (!regex.test(outputStr)) {
return {
passed: false,
error: `Output did not match pattern: ${config.expectedOutput}`,
output,
};
}
}
return { passed: true, output };
}
return {
passed: false,
error: `Expected status ${expectedStatus}, got ${response.status}`,
output,
};
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`API request timed out after ${timeout}ms`);
}
throw error;
}
}
/**
* Browser verification - use Playwright to verify UI
* @private
*/
async _verifyBrowser(config, timeout) {
this._log('Running browser verification with Playwright...', 'info');
let playwright;
try {
playwright = require('playwright');
} catch {
throw new Error(
'Browser verification requires Playwright - install with: npm install playwright'
);
}
const browser = await playwright.chromium.launch({
headless: config.headless !== false,
});
try {
const context = await browser.newContext();
const page = await context.newPage();
page.setDefaultTimeout(timeout);
if (!config.url) {
throw new Error('Browser verification requires "url" field');
}
await page.goto(config.url, { waitUntil: 'networkidle' });
this._log(`Navigated to ${config.url}`, 'info');
// Check for selector if provided
if (config.selector) {
const element = await page.locator(config.selector);
const count = await element.count();
if (count === 0) {
return {
passed: false,
error: `Selector not found: ${config.selector}`,
};
}
// Check expected text if provided
if (config.expectedText) {
const text = await element.first().textContent();
if (!text || !text.includes(config.expectedText)) {
return {
passed: false,
error: `Expected text "${config.expectedText}" not found in element`,
};
}
}
}
// Take screenshot if path provided
let screenshotPath;
if (config.screenshot) {
screenshotPath = config.screenshot;
await page.screenshot({ path: screenshotPath, fullPage: true });
this._log(`Screenshot saved to ${screenshotPath}`, 'info');
}
return {
passed: true,
output: { url: config.url, screenshot: screenshotPath },
};
} finally {
await browser.close();
}
}
/**
* E2E verification - run e2e test suite
* @private
*/
async _verifyE2E(config, timeout) {
const testCommand = config.testCommand || config.command;
if (!testCommand) {
throw new Error('E2E verification requires "testCommand" or "command" field');
}
this._log(`Running E2E tests: ${testCommand}`, 'info');
// E2E tests reuse command verification
return this._verifyCommand({ command: testCommand }, timeout);
}
/**
* Check if error is transient and worth retrying
* @private
*/
_isTransientError(error) {
const transientPatterns = [
'ECONNRESET',
'ETIMEDOUT',
'ECONNREFUSED',
'socket hang up',
'network timeout',
'temporarily unavailable',
];
const message = error.message || '';
return transientPatterns.some((pattern) =>
message.toLowerCase().includes(pattern.toLowerCase())
);
}
/**
* Sleep for specified duration
* @private
*/
_sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Create verification result object
* @private
*/
_createResult(subtaskId, passed, verificationType, startTime, options = {}) {
return {
subtaskId,
passed,
verificationType,
duration: Date.now() - startTime,
logs: [...this.logs],
attempts: options.attempts || 1,
error: options.error || null,
output: options.output || null,
};
}
/**
* Verify all subtasks in implementation
* @returns {Promise<Object>}
*/
async verifyAll() {
if (!this.implementation) {
await this.loadImplementation();
}
const results = {
storyId: this.implementation.storyId,
totalSubtasks: 0,
passed: 0,
failed: 0,
skipped: 0,
subtasks: [],
};
for (const phase of this.implementation.phases || []) {
for (const subtask of phase.subtasks || []) {
results.totalSubtasks++;
// Skip already completed subtasks
if (subtask.status === 'completed') {
results.skipped++;
results.subtasks.push({
subtaskId: subtask.id,
passed: true,
verificationType: 'skipped',
duration: 0,
logs: ['Subtask already completed - skipped verification'],
attempts: 0,
});
continue;
}
const result = await this.verify(subtask.id);
results.subtasks.push(result);
if (result.passed) {
results.passed++;
} else {
results.failed++;
}
}
}
return results;
}
/**
* Generate verification report
* @param {Object} results - Results from verify() or verifyAll()
* @returns {string}
*/
generateReport(results) {
const lines = [];
const isMultiple = Array.isArray(results.subtasks);
lines.push('');
lines.push(chalk.bold('='.repeat(60)));
lines.push(chalk.bold(' Subtask Verification Report'));
lines.push(chalk.bold('='.repeat(60)));
lines.push('');
if (isMultiple) {
lines.push(`Story: ${results.storyId || 'Unknown'}`);
lines.push(`Total Subtasks: ${results.totalSubtasks}`);
lines.push(` ${chalk.green('Passed')}: ${results.passed}`);
lines.push(` ${chalk.red('Failed')}: ${results.failed}`);
lines.push(` ${chalk.yellow('Skipped')}: ${results.skipped}`);
lines.push('');
lines.push('-'.repeat(60));
for (const result of results.subtasks) {
const status = result.passed ? chalk.green('PASS') : chalk.red('FAIL');
lines.push(
`${status} [${result.subtaskId}] ${result.verificationType} (${result.duration}ms)`
);
if (!result.passed && result.error) {
lines.push(` Error: ${result.error.message || result.error}`);
}
}
} else {
const status = results.passed ? chalk.green('PASS') : chalk.red('FAIL');
lines.push(`Subtask: ${results.subtaskId}`);
lines.push(`Status: ${status}`);
lines.push(`Type: ${results.verificationType}`);
lines.push(`Duration: ${results.duration}ms`);
lines.push(`Attempts: ${results.attempts}`);
if (!results.passed && results.error) {
lines.push('');
lines.push(chalk.red('Error:'));
lines.push(` ${results.error.message || results.error}`);
}
if (results.logs && results.logs.length > 0) {
lines.push('');
lines.push('Logs:');
for (const log of results.logs.slice(-10)) {
lines.push(` ${chalk.gray(log)}`);
}
}
}
lines.push('');
lines.push('='.repeat(60));
lines.push('');
return lines.join('\n');
}
/**
* Update subtask status in implementation.yaml
* @param {string} subtaskId - Subtask identifier
* @param {string} status - New status (pending, in_progress, completed, failed)
* @returns {Promise<void>}
*/
async updateSubtaskStatus(subtaskId, status) {
if (!this.implementation) {
await this.loadImplementation();
}
let found = false;
for (const phase of this.implementation.phases || []) {
for (const subtask of phase.subtasks || []) {
if (subtask.id === subtaskId) {
subtask.status = status;
subtask.verifiedAt = new Date().toISOString();
found = true;
break;
}
}
if (found) break;
}
if (!found) {
throw new Error(`Subtask ${subtaskId} not found`);
}
// Write back to file
const yamlContent = yaml.dump(this.implementation, {
indent: 2,
lineWidth: 100,
noRefs: true,
});
await fs.writeFile(this.implementationPath, yamlContent, 'utf8');
this._log(`Updated status for ${subtaskId} to ${status}`, 'info');
}
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
console.log(`
Usage: subtask-verifier <subtask-id> [options]
Options:
--implementation, -i <path> Path to implementation.yaml (required)
--timeout, -t <ms> Verification timeout in ms (default: 60000)
--retries, -r <n> Max retry attempts (default: 3)
--verbose, -v Enable verbose logging
--all Verify all subtasks
--update Update implementation.yaml with result
--help, -h Show this help
Examples:
subtask-verifier 1.1 -i docs/stories/STORY-42/plan/implementation.yaml
subtask-verifier --all -i docs/stories/STORY-42/plan/implementation.yaml -v
`);
process.exit(0);
}
// Parse arguments
let subtaskId = null;
let implementationPath = null;
let timeout = 60000;
let maxRetries = 3;
let verbose = false;
let verifyAll = false;
let update = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--implementation' || arg === '-i') {
implementationPath = args[++i];
} else if (arg === '--timeout' || arg === '-t') {
timeout = parseInt(args[++i], 10);
} else if (arg === '--retries' || arg === '-r') {
maxRetries = parseInt(args[++i], 10);
} else if (arg === '--verbose' || arg === '-v') {
verbose = true;
} else if (arg === '--all') {
verifyAll = true;
} else if (arg === '--update') {
update = true;
} else if (!arg.startsWith('-')) {
subtaskId = arg;
}
}
if (!implementationPath) {
console.error(chalk.red('Error: --implementation path is required'));
process.exit(1);
}
if (!verifyAll && !subtaskId) {
console.error(chalk.red('Error: subtask-id or --all is required'));
process.exit(1);
}
try {
const verifier = new SubtaskVerifier({
implementationPath: path.resolve(implementationPath),
timeout,
maxRetries,
verbose,
});
let results;
if (verifyAll) {
results = await verifier.verifyAll();
} else {
results = await verifier.verify(subtaskId);
if (update) {
const status = results.passed ? 'completed' : 'failed';
await verifier.updateSubtaskStatus(subtaskId, status);
}
}
console.log(verifier.generateReport(results));
// Exit with appropriate code
if (verifyAll) {
process.exit(results.failed > 0 ? 1 : 0);
} else {
process.exit(results.passed ? 0 : 1);
}
} catch (error) {
console.error(chalk.red(`Fatal error: ${error.message}`));
process.exit(1);
}
}
// Export for programmatic use
module.exports = { SubtaskVerifier };
// Run CLI if executed directly
if (require.main === module) {
main();
}