UNPKG

aiwg

Version:

Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.

359 lines (304 loc) 9.57 kB
/** * Multi-Loop Dashboard for External Ralph * * ASCII dashboard for terminal display of multiple concurrent Ralph loops. * Works without TTY (static output). * * @implements @.aiwg/requirements/use-cases/UC-273-multi-loop-supervisor.md */ import { existsSync, readFileSync, readdirSync } from 'fs'; import { join } from 'path'; /** * @typedef {Object} LoopSummary * @property {string} loopId - Loop identifier * @property {string} status - Loop status * @property {number} iteration - Current iteration * @property {number} maxIterations - Maximum iterations * @property {number} progress - Progress percentage * @property {number} elapsed - Elapsed time in milliseconds * @property {string} objective - Loop objective */ /** * @typedef {Object} AggregateMetrics * @property {number} totalIterations - Total iterations across all loops * @property {number} successRate - Success rate percentage * @property {number} crashesToday - Crashes in last 24 hours * @property {number} avgProgress - Average progress percentage * @property {number} totalCost - Total cost in USD */ export class Dashboard { /** * @param {Object} config * @param {string} config.projectRoot - Project root directory */ constructor(config) { this.projectRoot = config.projectRoot; this.loopsDir = join(this.projectRoot, '.aiwg', 'ralph', 'loops'); } /** * Render dashboard as ASCII string * @returns {string} - Dashboard output */ render() { const loops = this.getLoopsSummary(); const metrics = this.getAggregateMetrics(); const activeCount = loops.filter(l => l.status === 'running').length; const totalCount = loops.length; const width = 70; const border = '─'.repeat(width - 2); const lines = [ `╭${border}╮`, this.centerText('External Ralph Multi-Loop Dashboard', width), `├${border}┤`, this.centerText(`Active Loops: ${activeCount}/${totalCount}`, width), `├${border}┤`, this.tableHeader(), `├${border}┤`, ]; // Add loop rows if (loops.length === 0) { lines.push(this.centerText('No active loops', width)); } else { for (const loop of loops.slice(0, 10)) { // Show max 10 lines.push(this.formatLoopRow(loop)); } if (loops.length > 10) { lines.push(this.centerText(`... and ${loops.length - 10} more`, width)); } } // Add aggregate metrics lines.push(`├${border}┤`); lines.push(this.formatMetrics(metrics)); lines.push(`╰${border}╯`); return lines.join('\n'); } /** * Center text in a line * @param {string} text - Text to center * @param {number} width - Line width * @returns {string} */ centerText(text, width) { const padding = Math.floor((width - 2 - text.length) / 2); const leftPad = ' '.repeat(padding); const rightPad = ' '.repeat(width - 2 - text.length - padding); return `│${leftPad}${text}${rightPad}│`; } /** * Format table header * @returns {string} */ tableHeader() { return '│ ID │ Status │ Progress │ Elapsed │'; } /** * Format loop row * @param {LoopSummary} loop - Loop summary * @returns {string} */ formatLoopRow(loop) { const id = this.truncate(loop.loopId, 24); const status = this.padRight(loop.status, 7); const progress = this.padLeft(`${loop.iteration}/${loop.maxIterations}`, 8); const elapsed = this.padLeft(this.formatDuration(loop.elapsed), 9); return `│ ${id}${status}${progress}${elapsed} │`; } /** * Format aggregate metrics * @param {AggregateMetrics} metrics - Metrics data * @returns {string} */ formatMetrics(metrics) { const text = `Aggregate: ${metrics.totalIterations} iterations │ ${metrics.successRate}% success │ ${metrics.crashesToday} crashes today`; return this.centerText(text, 70); } /** * Get summary of all loops * @returns {Array<LoopSummary>} */ getLoopsSummary() { const loops = []; if (!existsSync(this.loopsDir)) { return loops; } const loopDirs = readdirSync(this.loopsDir, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const loopId of loopDirs) { const stateFile = join(this.loopsDir, loopId, 'state.json'); if (!existsSync(stateFile)) { continue; } try { const state = JSON.parse(readFileSync(stateFile, 'utf8')); const startTime = new Date(state.startTime).getTime(); const elapsed = Date.now() - startTime; const progress = Math.round((state.currentIteration / state.maxIterations) * 100); loops.push({ loopId, status: state.status, iteration: state.currentIteration, maxIterations: state.maxIterations, progress, elapsed, objective: state.objective, }); } catch { // Skip corrupted state files } } // Sort by status (running first, then paused, then others) const statusOrder = { running: 0, paused: 1, recovering: 2, completed: 3, failed: 4 }; loops.sort((a, b) => { const aOrder = statusOrder[a.status] ?? 99; const bOrder = statusOrder[b.status] ?? 99; return aOrder - bOrder; }); return loops; } /** * Get aggregate metrics across all loops * @returns {AggregateMetrics} */ getAggregateMetrics() { const loops = this.getLoopsSummary(); const totalIterations = loops.reduce((sum, l) => sum + l.iteration, 0); const completed = loops.filter(l => l.status === 'completed').length; const failed = loops.filter(l => l.status === 'failed').length; const total = completed + failed; const successRate = total > 0 ? Math.round((completed / total) * 100) : 0; const crashesToday = this.countCrashesToday(); const avgProgress = loops.length > 0 ? Math.round(loops.reduce((sum, l) => sum + l.progress, 0) / loops.length) : 0; const totalCost = this.calculateTotalCost(loops); return { totalIterations, successRate, crashesToday, avgProgress, totalCost, }; } /** * Count crashes in last 24 hours * @returns {number} */ countCrashesToday() { let count = 0; const yesterday = Date.now() - 24 * 60 * 60 * 1000; if (!existsSync(this.loopsDir)) { return count; } const loopDirs = readdirSync(this.loopsDir, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const loopId of loopDirs) { const crashLog = join(this.loopsDir, loopId, 'crash.log'); if (!existsSync(crashLog)) { continue; } try { const log = readFileSync(crashLog, 'utf8'); const lines = log.split('\n'); for (const line of lines) { const match = line.match(/\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)\]/); if (match) { const timestamp = new Date(match[1]).getTime(); if (timestamp > yesterday) { count++; } } } } catch { // Skip corrupted crash logs } } return count; } /** * Calculate total cost across all loops * @param {Array<LoopSummary>} loops - All loops * @returns {number} - Total cost in USD */ calculateTotalCost(loops) { let total = 0; for (const loop of loops) { const stateFile = join(this.loopsDir, loop.loopId, 'state.json'); if (!existsSync(stateFile)) { continue; } try { const state = JSON.parse(readFileSync(stateFile, 'utf8')); if (state.iterations) { for (const iteration of state.iterations) { if (iteration.analysis?.tokenCost) { total += iteration.analysis.tokenCost; } } } } catch { // Skip } } return total; } /** * Format duration in human-readable form * @param {number} ms - Duration in milliseconds * @returns {string} */ formatDuration(ms) { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) { return `${days}d ${hours % 24}h`; } if (hours > 0) { return `${hours}h ${minutes % 60}m`; } if (minutes > 0) { return `${minutes}m`; } return `${seconds}s`; } /** * Truncate string to max length * @param {string} str - String to truncate * @param {number} maxLength - Maximum length * @returns {string} */ truncate(str, maxLength) { if (str.length <= maxLength) { return this.padRight(str, maxLength); } return str.slice(0, maxLength - 2) + '..'; } /** * Pad string to the right * @param {string} str - String to pad * @param {number} length - Target length * @returns {string} */ padRight(str, length) { return str + ' '.repeat(Math.max(0, length - str.length)); } /** * Pad string to the left * @param {string} str - String to pad * @param {number} length - Target length * @returns {string} */ padLeft(str, length) { return ' '.repeat(Math.max(0, length - str.length)) + str; } /** * Render dashboard and log to console */ display() { console.log(this.render()); } } export default Dashboard;