aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
520 lines (442 loc) • 15.1 kB
JavaScript
/**
* Dashboard Integration - Story 0.8
*
* Epic: Epic 0 - ADE Master Orchestrator
*
* Integrates orchestrator with dashboard for real-time monitoring.
*
* Features:
* - AC1: Updates `.aios/dashboard/status.json`
* - AC2: Status includes: currentEpic, progress, estimatedTime
* - AC3: Event emitter for real-time updates
* - AC4: getProgressPercentage() method for UI
* - AC5: History of completed/failed epics
* - AC6: Link to detailed logs
* - AC7: Notification when blocked or complete
*
* @module core/orchestration/dashboard-integration
* @version 1.0.0
*/
const fs = require('fs-extra');
const path = require('path');
const EventEmitter = require('events');
const { getDashboardEmitter } = require('../events');
// ═══════════════════════════════════════════════════════════════════════════════════
// NOTIFICATION TYPES (AC7)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Notification types for dashboard
*/
const NotificationType = {
INFO: 'info',
SUCCESS: 'success',
WARNING: 'warning',
ERROR: 'error',
BLOCKED: 'blocked',
COMPLETE: 'complete',
};
// ═══════════════════════════════════════════════════════════════════════════════════
// DASHBOARD INTEGRATION CLASS
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* DashboardIntegration - Real-time orchestrator monitoring (AC1)
*/
class DashboardIntegration extends EventEmitter {
/**
* @param {Object} options - Configuration options
* @param {string} options.projectRoot - Project root path
* @param {Object} options.orchestrator - MasterOrchestrator instance
* @param {boolean} [options.autoUpdate=true] - Automatically update status file
* @param {number} [options.updateInterval=5000] - Update interval in ms
*/
constructor(options = {}) {
super();
this.projectRoot = options.projectRoot || process.cwd();
this.orchestrator = options.orchestrator || null;
this.autoUpdate = options.autoUpdate ?? true;
this.updateInterval = options.updateInterval ?? 5000;
// Paths
this.dashboardDir = path.join(this.projectRoot, '.aios', 'dashboard');
this.statusPath = path.join(this.dashboardDir, 'status.json');
this.logsDir = path.join(this.projectRoot, '.aios', 'logs');
// State
this.history = [];
this.notifications = [];
this.updateTimer = null;
this.isRunning = false;
// Bind orchestrator events if provided
if (this.orchestrator) {
this._bindOrchestratorEvents();
}
}
/**
* Start dashboard integration
*/
async start() {
if (this.isRunning) return;
this.isRunning = true;
await fs.ensureDir(this.dashboardDir);
await fs.ensureDir(this.logsDir);
// Initial update
await this.updateStatus();
// Start auto-update if enabled
if (this.autoUpdate) {
this.updateTimer = setInterval(async () => {
await this.updateStatus();
}, this.updateInterval);
}
this.emit('started');
}
/**
* Stop dashboard integration
*/
stop() {
if (this.updateTimer) {
clearInterval(this.updateTimer);
this.updateTimer = null;
}
this.isRunning = false;
this.emit('stopped');
}
/**
* Bind to orchestrator events (AC3)
* @private
*/
_bindOrchestratorEvents() {
const orch = this.orchestrator;
const emitter = getDashboardEmitter();
// State changes
orch.on('stateChange', async (data) => {
await this.updateStatus();
this.emit('statusUpdate', { type: 'stateChange', data });
// Emit story status change to dashboard
if (orch.storyId) {
emitter.emitStoryStatusChange(
orch.storyId,
data.previousState || 'unknown',
data.newState,
orch.getProgressPercentage?.() || 0,
);
}
// Notification for blocked state (AC7)
if (data.newState === 'blocked') {
this.addNotification({
type: NotificationType.BLOCKED,
title: 'Pipeline Blocked',
message: data.context?.reason || 'Pipeline execution has been blocked',
timestamp: new Date().toISOString(),
});
}
// Notification for complete state (AC7)
if (data.newState === 'complete') {
this.addNotification({
type: NotificationType.COMPLETE,
title: 'Pipeline Complete',
message: `Story ${orch.storyId} completed successfully`,
timestamp: new Date().toISOString(),
});
}
});
// Epic start
orch.on('epicStart', async (data) => {
await this.updateStatus();
this.emit('statusUpdate', { type: 'epicStart', data });
// Emit command start for epic execution
const epicConfig = orch.constructor.EPIC_CONFIG || {};
const epicName = epicConfig[data.epicNum]?.name || `Epic ${data.epicNum}`;
emitter.emitCommandStart(epicName);
});
// Epic complete (AC5)
orch.on('epicComplete', async (data) => {
// Add to history
this.addToHistory({
type: 'epicComplete',
epicNum: data.epicNum,
result: data.result,
gateResult: data.gateResult,
timestamp: new Date().toISOString(),
});
await this.updateStatus();
this.emit('statusUpdate', { type: 'epicComplete', data });
// Emit command complete for epic execution
const epicConfig = orch.constructor.EPIC_CONFIG || {};
const epicName = epicConfig[data.epicNum]?.name || `Epic ${data.epicNum}`;
emitter.emitCommandComplete(epicName, data.duration_ms || 0, true, data.result);
});
// Epic failed (AC5)
orch.on('epicFailed', async (data) => {
this.addToHistory({
type: 'epicFailed',
epicNum: data.epicNum,
error: data.error,
timestamp: new Date().toISOString(),
});
await this.updateStatus();
this.emit('statusUpdate', { type: 'epicFailed', data });
// Emit command error for epic execution
const epicConfig = orch.constructor.EPIC_CONFIG || {};
const epicName = epicConfig[data.epicNum]?.name || `Epic ${data.epicNum}`;
emitter.emitCommandError(epicName, data.error?.message || 'Epic execution failed', data.duration_ms);
// Add error notification
this.addNotification({
type: NotificationType.ERROR,
title: `Epic ${data.epicNum} Failed`,
message: data.error?.message || 'Epic execution failed',
timestamp: new Date().toISOString(),
});
});
// Agent activation (for agent systems that use orchestrator)
orch.on('agentActivated', async (data) => {
emitter.emitAgentActivated(data.agentId, data.agentName, data.persona);
});
// Agent deactivation
orch.on('agentDeactivated', async (data) => {
emitter.emitAgentDeactivated(data.agentId, data.agentName, data.reason);
});
// Command execution (for task/command systems)
orch.on('commandStart', async (data) => {
emitter.emitCommandStart(data.command, data.args);
});
orch.on('commandComplete', async (data) => {
emitter.emitCommandComplete(data.command, data.duration_ms, data.success, data.result);
});
orch.on('commandError', async (data) => {
emitter.emitCommandError(data.command, data.error, data.duration_ms);
});
}
/**
* Update status file (AC1)
*/
async updateStatus() {
if (!this.orchestrator) return;
const status = this.buildStatus();
try {
await fs.ensureDir(this.dashboardDir);
await fs.writeJson(this.statusPath, status, { spaces: 2 });
this.emit('statusUpdated', status);
} catch (error) {
this._emitSafeError({ type: 'statusUpdate', error });
}
return status;
}
/**
* Emit error event only when listeners are present, otherwise degrade to warning.
* Avoids unhandled EventEmitter 'error' exceptions in background update flows.
* @private
* @param {Object} payload
*/
_emitSafeError(payload) {
if (this.listenerCount('error') > 0) {
this.emit('error', payload);
return;
}
const message = payload?.error?.message || 'unknown dashboard error';
console.warn(`[DashboardIntegration] ${payload?.type || 'error'}: ${message}`);
}
/**
* Build status object (AC2)
* @returns {Object} Status object
*/
buildStatus() {
const orch = this.orchestrator;
if (!orch) return {};
const storyId = orch.storyId || 'current';
const currentEpic = orch.executionState.currentEpic;
// Get epic name
const epicConfig = orch.constructor.EPIC_CONFIG || {};
const epicName = currentEpic && epicConfig[currentEpic] ? epicConfig[currentEpic].name : null;
// Build per-epic progress
const epicProgress = {};
for (const [num, epic] of Object.entries(orch.executionState.epics)) {
if (epic.status === 'completed') {
epicProgress[`epic${num}`] = 100;
} else if (epic.status === 'in_progress') {
epicProgress[`epic${num}`] = 50; // Estimate 50% for in-progress
} else {
epicProgress[`epic${num}`] = 0;
}
}
return {
orchestrator: {
[storyId]: {
// Core status (AC2)
status: orch.state,
currentEpic,
epicName,
// Progress (AC2, AC4)
progress: {
overall: orch.getProgressPercentage(),
...epicProgress,
},
// Task info
currentTask: this._getCurrentTask(),
// Timing
startedAt: orch.executionState.startedAt,
updatedAt: new Date().toISOString(),
// History (AC5)
history: this.history.slice(-20), // Last 20 entries
// Errors
errors: orch.executionState.errors.map((e) => ({
message: e.message,
timestamp: e.timestamp,
})),
// Status flags
blocked: orch.state === 'blocked',
// Logs path (AC6)
logsPath: this._getLogsPath(),
// Notifications (AC7)
notifications: this.notifications.slice(-10), // Last 10
},
},
};
}
/**
* Get current task description
* @private
*/
_getCurrentTask() {
const orch = this.orchestrator;
if (!orch) return null;
const currentEpic = orch.executionState.currentEpic;
if (!currentEpic) return null;
const epicConfig = orch.constructor.EPIC_CONFIG || {};
const config = epicConfig[currentEpic];
if (config) {
return `Executing ${config.name}`;
}
return `Executing Epic ${currentEpic}`;
}
/**
* Get logs path for current story (AC6)
* @private
*/
_getLogsPath() {
const orch = this.orchestrator;
if (!orch || !orch.storyId) return null;
return path.join(this.logsDir, `${orch.storyId}.log`);
}
/**
* Get overall progress percentage (AC4)
* @returns {number} Progress 0-100
*/
getProgressPercentage() {
if (!this.orchestrator) return 0;
return this.orchestrator.getProgressPercentage();
}
/**
* Add entry to history (AC5)
* @param {Object} entry - History entry
*/
addToHistory(entry) {
this.history.push({
...entry,
id: `hist-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
});
// Keep last 100 entries
if (this.history.length > 100) {
this.history = this.history.slice(-100);
}
}
/**
* Get history (AC5)
* @returns {Object[]} History entries
*/
getHistory() {
return [...this.history];
}
/**
* Get history for specific epic (AC5)
* @param {number} epicNum - Epic number
* @returns {Object[]} History entries for epic
*/
getHistoryForEpic(epicNum) {
return this.history.filter((h) => h.epicNum === epicNum);
}
/**
* Add notification (AC7)
* @param {Object} notification - Notification object
*/
addNotification(notification) {
const notif = {
...notification,
id: `notif-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
read: false,
};
this.notifications.push(notif);
this.emit('notification', notif);
// Keep last 50 notifications
if (this.notifications.length > 50) {
this.notifications = this.notifications.slice(-50);
}
}
/**
* Get notifications (AC7)
* @param {boolean} [unreadOnly=false] - Only return unread notifications
* @returns {Object[]} Notifications
*/
getNotifications(unreadOnly = false) {
if (unreadOnly) {
return this.notifications.filter((n) => !n.read);
}
return [...this.notifications];
}
/**
* Mark notification as read (AC7)
* @param {string} notificationId - Notification ID
*/
markNotificationRead(notificationId) {
const notif = this.notifications.find((n) => n.id === notificationId);
if (notif) {
notif.read = true;
}
}
/**
* Mark all notifications as read (AC7)
*/
markAllNotificationsRead() {
for (const notif of this.notifications) {
notif.read = true;
}
}
/**
* Clear notifications (AC7)
*/
clearNotifications() {
this.notifications = [];
}
/**
* Get status file path
* @returns {string} Path to status file
*/
getStatusPath() {
return this.statusPath;
}
/**
* Read current status from file
* @returns {Promise<Object|null>} Status object or null
*/
async readStatus() {
try {
if (await fs.pathExists(this.statusPath)) {
return await fs.readJson(this.statusPath);
}
} catch (error) {
this.emit('error', { type: 'readStatus', error });
}
return null;
}
/**
* Clear all state
*/
clear() {
this.history = [];
this.notifications = [];
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════════════
module.exports = {
DashboardIntegration,
NotificationType,
};