From e2d3481fea4f7a548cb1a7dd1c7b1878b8ebd0ec Mon Sep 17 00:00:00 2001 From: rafaelscosta Date: Thu, 7 May 2026 16:56:54 -0300 Subject: [PATCH 1/7] docs: improve core JSDoc coverage [Issue #89] --- .aiox-core/core/config/config-loader.js | 73 +++++++++++++++---- .aiox-core/core/events/dashboard-emitter.js | 73 ++++++++++++++----- .../core/orchestration/bob-orchestrator.js | 32 ++++++-- .../core/orchestration/brownfield-handler.js | 24 +++++- .../orchestration/data-lifecycle-manager.js | 19 ++++- .../core/orchestration/gate-evaluator.js | 35 +++++++-- .../core/orchestration/greenfield-handler.js | 21 +++++- .aiox-core/core/orchestration/lock-manager.js | 30 ++++++-- .../core/orchestration/master-orchestrator.js | 64 +++++++++++++--- .../core/orchestration/recovery-handler.js | 53 ++++++++++++-- .../core/orchestration/session-state.js | 35 +++++++-- .aiox-core/install-manifest.yaml | 46 ++++++------ 12 files changed, 399 insertions(+), 106 deletions(-) diff --git a/.aiox-core/core/config/config-loader.js b/.aiox-core/core/config/config-loader.js index 0f454520c3..23db384ec7 100644 --- a/.aiox-core/core/config/config-loader.js +++ b/.aiox-core/core/config/config-loader.js @@ -77,7 +77,9 @@ const performanceMetrics = { }; /** - * Checks if cache is still valid + * Checks whether the in-memory configuration cache is still valid. + * + * @returns {boolean} True when a cache timestamp exists and is still within the TTL window. */ function isCacheValid() { if (!configCache.lastLoad) return false; @@ -89,7 +91,13 @@ function isCacheValid() { } /** - * Loads full config file (used for initial load or cache refresh) + * Loads the full core-config.yaml file from disk. + * + * Used for initial load or cache refresh. The parsed result is stored in the + * in-memory cache and performance metrics are updated for observability. + * + * @returns {Promise} Parsed configuration object. + * @throws {Error} If the config file cannot be read or parsed. */ async function loadFullConfig() { const configPath = path.join('.aiox-core', 'core-config.yaml'); @@ -121,8 +129,12 @@ async function loadFullConfig() { /** * Loads specific config sections on demand * + * Uses the cached full config when possible; otherwise reloads the full config + * and returns only the requested sections. Unknown sections are omitted. + * * @param {string[]} sections - Array of section names to load - * @returns {Promise} Config object with requested sections + * @returns {Promise} Config object containing the requested sections + * @throws {Error} If config file cannot be read or parsed on cache miss. */ async function loadConfigSections(sections) { const startTime = Date.now(); @@ -160,10 +172,18 @@ async function loadConfigSections(sections) { } /** - * Loads config for specific agent with lazy loading + * Loads config for a specific agent with lazy loading. + * + * Only sections listed in the agent requirements map are loaded. Unknown agent + * IDs fall back to the always-loaded baseline sections. * * @param {string} agentId - Agent ID (e.g., 'dev', 'qa', 'po') - * @returns {Promise} Config object with sections needed by agent + * @returns {Promise} Config object with sections needed by the agent + * @throws {Error} If config file cannot be read or parsed. + * + * @example + * const config = await loadAgentConfig('dev'); + * console.log(config.frameworkDocsLocation); */ async function loadAgentConfig(agentId) { const startTime = Date.now(); @@ -186,16 +206,26 @@ async function loadAgentConfig(agentId) { } /** - * Loads always-loaded sections (minimal config) + * Loads the always-loaded baseline configuration sections. + * + * This lightweight subset is enough for startup paths that do not need + * agent-specific or tool-specific configuration yet. * * @returns {Promise} Minimal config with always-loaded sections + * @throws {Error} If config file cannot be read or parsed. */ async function loadMinimalConfig() { return await loadConfigSections(ALWAYS_LOADED); } /** - * Preloads config into cache (useful for startup optimization) + * Preloads the full configuration into the in-memory cache. + * + * Useful during startup when subsequent commands need config access without + * paying the initial disk-read cost. + * + * @returns {Promise} + * @throws {Error} If config file cannot be read or parsed. */ async function preloadConfig() { console.log('🔄 Preloading config into cache...'); @@ -204,7 +234,12 @@ async function preloadConfig() { } /** - * Clears config cache (useful for testing or forcing reload) + * Clears the full config cache and section cache. + * + * Useful for tests or for forcing the next config access to read fresh data + * from disk. + * + * @returns {void} */ function clearCache() { configCache.full = null; @@ -214,9 +249,9 @@ function clearCache() { } /** - * Gets performance metrics + * Gets configuration loader performance metrics. * - * @returns {Object} Performance statistics + * @returns {Object} Performance statistics including load counts, cache hit rate, and average load time. */ function getPerformanceMetrics() { return { @@ -229,10 +264,15 @@ function getPerformanceMetrics() { } /** - * Validates that required sections exist in config + * Validates that required sections exist in config for a specific agent. + * + * Loads the full config and checks each section required by the agent + * requirements map. Unknown agent IDs validate against the always-loaded + * baseline sections. * * @param {string} agentId - Agent ID to validate - * @returns {Promise} Validation result + * @returns {Promise<{valid: boolean, agentId: string, requiredSections: string[], missingSections: string[], availableSections: string[]}>} Validation result + * @throws {Error} If config file cannot be read or parsed. */ async function validateAgentConfig(agentId) { const requiredSections = agentRequirements[agentId] || ALWAYS_LOADED; @@ -253,10 +293,15 @@ async function validateAgentConfig(agentId) { } /** - * Gets config section on demand (async lazy load) + * Gets a single config section on demand. + * + * Uses the same lazy loading and cache behavior as loadConfigSections. * * @param {string} sectionName - Section to load - * @returns {Promise} Section content + * @returns {Promise<*>} Section content, or undefined if the section is not present + * + * @example + * const tools = await getConfigSection('toolConfigurations'); */ async function getConfigSection(sectionName) { const config = await loadConfigSections([sectionName]); diff --git a/.aiox-core/core/events/dashboard-emitter.js b/.aiox-core/core/events/dashboard-emitter.js index c752a0cae3..ca775d11d2 100644 --- a/.aiox-core/core/events/dashboard-emitter.js +++ b/.aiox-core/core/events/dashboard-emitter.js @@ -22,11 +22,27 @@ const MONITOR_SERVER_URL = process.env.AIOX_MONITOR_URL || 'http://localhost:400 const EMIT_TIMEOUT_MS = 500; /** - * DashboardEmitter - Singleton for emitting events to monitor-server + * DashboardEmitter - Singleton for emitting events to monitor-server. + * + * Sends high-level CLI events such as agent activation, command execution, + * story status changes, session lifecycle, and Bob orchestration events via + * non-blocking HTTP POST requests. Falls back to JSONL file writes when the + * monitor server is unavailable. + * + * @example + * const emitter = DashboardEmitter.getInstance(); + * emitter.setAgent('dev'); + * await emitter.emitCommandStart('*develop', ['--story', '3.1']); */ class DashboardEmitter { static instance = null; + /** + * Creates a dashboard emitter instance. + * + * Uses CLAUDE_CODE_SESSION_ID when available; otherwise generates a UUID for + * session tracking. The emitter disables itself automatically in test runs. + */ constructor() { this.sessionId = process.env.CLAUDE_CODE_SESSION_ID || randomUUID(); this.projectRoot = process.cwd(); @@ -42,8 +58,9 @@ class DashboardEmitter { } /** - * Get singleton instance - * @returns {DashboardEmitter} + * Gets the singleton DashboardEmitter instance. + * + * @returns {DashboardEmitter} Shared dashboard emitter instance. */ static getInstance() { if (!DashboardEmitter.instance) { @@ -53,42 +70,54 @@ class DashboardEmitter { } /** - * Set current agent context - * @param {string|null} agentId + * Sets the current agent context attached to future events. + * + * @param {string|null} agentId - Current agent ID, or null to clear agent context. + * @returns {void} */ setAgent(agentId) { this.currentAgent = agentId; } /** - * Set current story context - * @param {string|null} storyId + * Sets the current story context attached to future events. + * + * @param {string|null} storyId - Current story ID, or null to clear story context. + * @returns {void} */ setStoryId(storyId) { this.currentStoryId = storyId; } /** - * Set session ID - * @param {string} sessionId + * Sets the session ID attached to future events. + * + * @param {string} sessionId - Session identifier. + * @returns {void} */ setSessionId(sessionId) { this.sessionId = sessionId; } /** - * Enable/disable emitter - * @param {boolean} enabled + * Enables or disables event emission. + * + * @param {boolean} enabled - Whether the emitter should send events. + * @returns {void} */ setEnabled(enabled) { this.enabled = enabled; } /** - * Emit an event to monitor-server - * Non-blocking, silent failure + * Emits an event to the monitor server. + * + * Emission is non-blocking for the CLI caller. HTTP failures fall back to a + * local JSONL file and never throw to the command path. + * * @param {string} type - Event type * @param {Object} data - Event data + * @returns {Promise} */ async emit(type, data = {}) { if (!this.enabled) return; @@ -303,9 +332,12 @@ class DashboardEmitter { } /** - * POST event to monitor-server with timeout + * Posts an event to the monitor server with a short timeout. + * + * @param {Object} event - Normalized dashboard event payload. + * @returns {Promise} + * @throws {Error} If the HTTP request fails or returns a non-2xx status. * @private - * @param {Object} event */ async _postEvent(event) { const controller = new AbortController(); @@ -339,9 +371,11 @@ class DashboardEmitter { } /** - * Write event to fallback file + * Writes an event to the local JSONL fallback file. + * + * @param {Object} event - Normalized dashboard event payload. + * @returns {Promise} * @private - * @param {Object} event */ async _writeToFile(event) { try { @@ -355,8 +389,9 @@ class DashboardEmitter { } /** - * Get singleton emitter instance - * @returns {DashboardEmitter} + * Gets the singleton dashboard emitter instance. + * + * @returns {DashboardEmitter} Shared dashboard emitter instance. */ function getDashboardEmitter() { return DashboardEmitter.getInstance(); diff --git a/.aiox-core/core/orchestration/bob-orchestrator.js b/.aiox-core/core/orchestration/bob-orchestrator.js index 27bfe1dbc6..30fe84d373 100644 --- a/.aiox-core/core/orchestration/bob-orchestrator.js +++ b/.aiox-core/core/orchestration/bob-orchestrator.js @@ -71,14 +71,24 @@ const ProjectState = { */ /** - * BobOrchestrator — Main decision tree and orchestration entry point + * BobOrchestrator - Main decision tree and orchestration entry point. + * + * Coordinates Bob's project-state detection, session recovery, educational + * mode handling, workflow routing, and observability bridge without relying on + * LLM reasoning for routing decisions. */ class BobOrchestrator { /** - * Creates a new BobOrchestrator instance - * @param {string} projectRoot - Project root directory + * Creates a new BobOrchestrator instance. + * + * Initializes all orchestration collaborators used by the Bob decision tree, + * including workflow execution, session state, file locks, lifecycle cleanup, + * brownfield and greenfield handlers, dashboard status, and message formatting. + * + * @param {string} projectRoot - Project root directory. * @param {Object} [options] - Orchestrator options * @param {boolean} [options.debug=false] - Enable debug logging + * @throws {Error} If projectRoot is missing or not a string. */ constructor(projectRoot, options = {}) { if (!projectRoot || typeof projectRoot !== 'string') { @@ -140,6 +150,12 @@ class BobOrchestrator { /** * Sets up observability panel callbacks (Story 12.6 - AC1, AC2, AC6-11) + * + * Wires workflow and handler lifecycle events into the CLI panel, status + * writer, and dashboard emitter. Callback failures are logged but never block + * orchestration progress. + * + * @returns {void} * @private */ _setupObservabilityCallbacks() { @@ -294,9 +310,7 @@ class BobOrchestrator { * - "desativa modo educativo", "desativa educativo", "modo educativo off", "educational mode off" * * @param {string} userInput - User input text - * @returns {Object|null} Toggle result or null if not a toggle command - * @returns {boolean} result.enable - Whether to enable educational mode - * @returns {string} result.command - Matched command + * @returns {{enable: boolean, command: string}|null} Toggle result, or null if not a toggle command. */ _detectEducationalModeToggle(userInput) { if (!userInput || typeof userInput !== 'string') { @@ -396,6 +410,7 @@ class BobOrchestrator { /** * Gets the persistence prompt for educational mode toggle (Story 12.7 - AC6) + * * @returns {string} Prompt message */ getEducationalModePersistencePrompt() { @@ -537,7 +552,10 @@ class BobOrchestrator { /** * Checks for existing session and builds formatted summary (Story 12.5 - AC1, AC2, AC4) * - * @returns {Promise} Session check result + * Loads the current session state, detects crash risk, computes elapsed time, + * and returns both machine-readable resume data and a user-facing summary. + * + * @returns {Promise<{hasSession: boolean, state?: Object, crashInfo?: Object, elapsedString?: string, formattedMessage?: string, summary?: Object, epicTitle?: string, currentStory?: string, currentPhase?: string}>} Session check result. * @private */ async _checkExistingSession() { diff --git a/.aiox-core/core/orchestration/brownfield-handler.js b/.aiox-core/core/orchestration/brownfield-handler.js index 7c35b6d0ec..9f7e7b2d56 100644 --- a/.aiox-core/core/orchestration/brownfield-handler.js +++ b/.aiox-core/core/orchestration/brownfield-handler.js @@ -72,16 +72,26 @@ const PhaseFailureAction = { // ═══════════════════════════════════════════════════════════════════════════════════ /** - * BrownfieldHandler - Manages first execution behavior for existing projects + * BrownfieldHandler - Manages first execution behavior for existing projects. + * + * Owns the brownfield discovery handshake, workflow execution, idempotent + * artifact writes, phase progress tracking, and post-discovery routing for + * projects that have code but do not yet have AIOX documentation. */ class BrownfieldHandler extends EventEmitter { /** + * Creates a new BrownfieldHandler instance. + * + * Dependencies can be injected by BobOrchestrator or lazily loaded on demand + * to keep standalone tests lightweight. + * * @param {string} projectRoot - Project root path * @param {Object} [options] - Configuration options * @param {boolean} [options.debug=false] - Enable debug logging * @param {Object} [options.workflowExecutor] - WorkflowExecutor instance * @param {Object} [options.surfaceChecker] - SurfaceChecker instance * @param {Object} [options.sessionState] - SessionState instance + * @throws {Error} If projectRoot is missing or not a string. */ constructor(projectRoot, options = {}) { super(); @@ -118,7 +128,9 @@ class BrownfieldHandler extends EventEmitter { // ═══════════════════════════════════════════════════════════════════════════════════ /** - * Get WorkflowExecutor instance + * Gets or lazily creates the WorkflowExecutor dependency. + * + * @returns {Object|null} WorkflowExecutor instance, or null when unavailable. * @private */ _getWorkflowExecutor() { @@ -136,7 +148,9 @@ class BrownfieldHandler extends EventEmitter { } /** - * Get SurfaceChecker instance + * Gets or lazily creates the SurfaceChecker dependency. + * + * @returns {Object|null} SurfaceChecker instance, or null when unavailable. * @private */ _getSurfaceChecker() { @@ -152,7 +166,9 @@ class BrownfieldHandler extends EventEmitter { } /** - * Get SessionState instance + * Gets or lazily creates the SessionState dependency. + * + * @returns {Object|null} SessionState instance, or null when unavailable. * @private */ _getSessionState() { diff --git a/.aiox-core/core/orchestration/data-lifecycle-manager.js b/.aiox-core/core/orchestration/data-lifecycle-manager.js index 8b30613ca7..f8fd551a81 100644 --- a/.aiox-core/core/orchestration/data-lifecycle-manager.js +++ b/.aiox-core/core/orchestration/data-lifecycle-manager.js @@ -39,16 +39,22 @@ const SNAPSHOTS_INDEX = 'index.json'; */ /** - * DataLifecycleManager - Manages data cleanup and archival + * DataLifecycleManager - Manages data cleanup and archival. + * + * Runs startup hygiene for Bob orchestration by delegating stale lock cleanup, + * archiving old session state, removing old snapshots, and recording removed + * snapshot references for auditability. */ class DataLifecycleManager { /** - * Creates a new DataLifecycleManager instance + * Creates a new DataLifecycleManager instance. + * * @param {string} projectRoot - Project root directory * @param {Object} [options] - Manager options * @param {boolean} [options.debug=false] - Enable debug logging * @param {number} [options.staleSessionDays=30] - Days before session is stale * @param {number} [options.staleSnapshotDays=90] - Days before snapshot is stale + * @throws {Error} If projectRoot is missing or not a string. */ constructor(projectRoot, options = {}) { if (!projectRoot || typeof projectRoot !== 'string') { @@ -253,8 +259,15 @@ class DataLifecycleManager { } /** - * Updates snapshots index.json with removed snapshot references + * Updates snapshots index.json with removed snapshot references. + * * @param {Object[]} removedSnapshots - Array of removed snapshot info + * @param {string} removedSnapshots[].filename - Removed snapshot filename + * @param {string} removedSnapshots[].removed_at - ISO timestamp of removal + * @param {string} removedSnapshots[].original_created - Original snapshot timestamp + * @param {number} removedSnapshots[].age_days - Snapshot age in days + * @param {string} removedSnapshots[].epic_id - Associated epic ID or unknown + * @param {string} removedSnapshots[].story_id - Associated story ID or unknown * @returns {Promise} * @private */ diff --git a/.aiox-core/core/orchestration/gate-evaluator.js b/.aiox-core/core/orchestration/gate-evaluator.js index 12f28a021a..80f1d27847 100644 --- a/.aiox-core/core/orchestration/gate-evaluator.js +++ b/.aiox-core/core/orchestration/gate-evaluator.js @@ -27,7 +27,9 @@ const yaml = require('js-yaml'); // ═══════════════════════════════════════════════════════════════════════════════════ /** - * Gate verdict enum + * Gate verdict enum. + * + * @enum {string} */ const GateVerdict = { /** Output meets all quality criteria */ @@ -39,7 +41,9 @@ const GateVerdict = { }; /** - * Default gate configurations + * Default gate configurations used when core-config.yaml does not override them. + * + * @constant {Object} */ const DEFAULT_GATE_CONFIG = { // Epic 3 -> Epic 4 gate @@ -83,7 +87,9 @@ class GateEvaluator { } /** - * Load gate configuration from core-config.yaml (AC5) + * Loads gate configuration from core-config.yaml (AC5). + * + * @returns {Promise>} Gate configuration keyed by transition. * @private */ async _loadConfig() { @@ -109,7 +115,11 @@ class GateEvaluator { } /** - * Get gate key for transition + * Gets the config key for an epic transition. + * + * @param {number} fromEpic - Source epic number. + * @param {number} toEpic - Target epic number. + * @returns {string} Gate key in the form epic{from}_to_epic{to}. * @private */ _getGateKey(fromEpic, toEpic) { @@ -459,7 +469,12 @@ class GateEvaluator { } /** - * Clear all results + * Clears all stored gate results and logs. + * + * Resets the evaluator to its initial state, removing all previously + * recorded gate evaluations and log entries. + * + * @returns {void} */ clear() { this.results = []; @@ -467,7 +482,11 @@ class GateEvaluator { } /** - * Log message + * Records a gate evaluator log message. + * + * @param {string} message - Log message. + * @param {string} [level='info'] - Log level. + * @returns {void} * @private */ _log(message, level = 'info') { @@ -476,7 +495,9 @@ class GateEvaluator { } /** - * Get logs + * Gets all evaluation logs. + * + * @returns {Array<{timestamp: string, level: string, message: string}>} Copy of all log entries. */ getLogs() { return [...this.logs]; diff --git a/.aiox-core/core/orchestration/greenfield-handler.js b/.aiox-core/core/orchestration/greenfield-handler.js index 3527257d1f..b59e3ad8a2 100644 --- a/.aiox-core/core/orchestration/greenfield-handler.js +++ b/.aiox-core/core/orchestration/greenfield-handler.js @@ -128,7 +128,12 @@ class GreenfieldHandler extends EventEmitter { // LAZY DEPENDENCY LOADING // ═══════════════════════════════════════════════════════════════════════════════════ - /** @private */ + /** + * Gets or lazily creates the WorkflowExecutor dependency. + * + * @returns {Object|null} WorkflowExecutor instance, or null when unavailable. + * @private + */ _getWorkflowExecutor() { if (!this._workflowExecutor) { try { @@ -143,7 +148,12 @@ class GreenfieldHandler extends EventEmitter { return this._workflowExecutor; } - /** @private */ + /** + * Gets or lazily creates the SurfaceChecker dependency. + * + * @returns {Object|null} SurfaceChecker instance, or null when unavailable. + * @private + */ _getSurfaceChecker() { if (!this._surfaceChecker) { try { @@ -156,7 +166,12 @@ class GreenfieldHandler extends EventEmitter { return this._surfaceChecker; } - /** @private */ + /** + * Gets or lazily creates the SessionState dependency. + * + * @returns {Object|null} SessionState instance, or null when unavailable. + * @private + */ _getSessionState() { if (!this._sessionState) { try { diff --git a/.aiox-core/core/orchestration/lock-manager.js b/.aiox-core/core/orchestration/lock-manager.js index 3a452982a9..15c39efb57 100644 --- a/.aiox-core/core/orchestration/lock-manager.js +++ b/.aiox-core/core/orchestration/lock-manager.js @@ -31,7 +31,22 @@ const MAX_RETRIES = 3; const LOCKS_DIR = '.aiox/locks'; /** - * LockManager - File-based locking with PID/TTL management + * File lock payload persisted to .aiox/locks. + * + * @typedef {Object} LockFile + * @property {number} pid - Process ID that created the lock + * @property {string} owner - Owner module or workflow identifier + * @property {string} created_at - ISO timestamp when the lock was created + * @property {number} ttl_seconds - Lock time-to-live in seconds + * @property {string} resource - Protected resource name + */ + +/** + * LockManager - File-based locking with PID/TTL management. + * + * Provides cooperative mutual exclusion for Bob orchestration resources using + * YAML lock files, stale-lock cleanup, TTL expiration, and process liveness + * checks. */ class LockManager { /** @@ -228,9 +243,10 @@ class LockManager { } /** - * Reads and parses a lock file + * Reads and parses a lock file. + * * @param {string} lockPath - Path to lock file - * @returns {Promise} Lock data or null + * @returns {Promise} Lock data, or null when the file is absent or unreadable * @private */ async _readLock(lockPath) { @@ -243,8 +259,12 @@ class LockManager { } /** - * Checks if a lock is stale (TTL expired or PID dead) - * @param {Object} lock - Lock data + * Checks if a lock is stale. + * + * A lock is stale when its TTL has expired or its owning process ID is no + * longer alive. + * + * @param {LockFile|null} lock - Lock data * @returns {boolean} True if lock is stale * @private */ diff --git a/.aiox-core/core/orchestration/master-orchestrator.js b/.aiox-core/core/orchestration/master-orchestrator.js index 354cafee1e..f03cd606c2 100644 --- a/.aiox-core/core/orchestration/master-orchestrator.js +++ b/.aiox-core/core/orchestration/master-orchestrator.js @@ -934,6 +934,11 @@ class MasterOrchestrator extends EventEmitter { /** * Start dashboard monitoring (Story 0.8 - AC1) + * + * Begins periodic state updates to the dashboard integration layer. Must be + * called after initialization to enable real-time orchestration observability. + * + * @returns {Promise} */ async startDashboard() { await this.dashboardIntegration.start(); @@ -941,6 +946,11 @@ class MasterOrchestrator extends EventEmitter { /** * Stop dashboard monitoring (Story 0.8) + * + * Stops periodic dashboard updates and clears dashboard integration timers. + * Safe to call multiple times. + * + * @returns {void} */ stopDashboard() { this.dashboardIntegration.stop(); @@ -1060,8 +1070,12 @@ class MasterOrchestrator extends EventEmitter { /** * Save current state to disk (AC1, AC3) - * Called after each epic completion and state transition - * @returns {Promise} Success status + * + * Persists epic progress, timestamps, tech-stack profile, recovery tracking, + * errors, and collected insights. Called after epic completion and state + * transitions. + * + * @returns {Promise} True if state was saved successfully, false on failure. */ async saveState() { try { @@ -1287,8 +1301,12 @@ class MasterOrchestrator extends EventEmitter { } /** - * Clear saved state for current story - * @returns {Promise} Success status + * Clears saved state for the current story. + * + * Removes the persisted state file so the next orchestration run starts fresh + * instead of resuming previous progress. + * + * @returns {Promise} True if state file was deleted, false if not found or on error. */ async clearState() { try { @@ -1304,8 +1322,12 @@ class MasterOrchestrator extends EventEmitter { } /** - * List all saved states - * @returns {Promise} List of state summaries + * Lists all saved orchestration states. + * + * Scans the master-orchestrator state directory and returns summaries sorted + * by most recently updated first. + * + * @returns {Promise>} List of state summaries. */ async listSavedStates() { const stateDir = path.join(this.projectRoot, '.aiox', 'master-orchestrator'); @@ -1371,8 +1393,17 @@ class MasterOrchestrator extends EventEmitter { /** * Finalize pipeline execution and generate summary - * @param {Object} pipelineResult - Raw pipeline result - * @returns {Object} Finalized result + * + * Produces a comprehensive result object with human-readable duration, + * tech-stack summary, epic execution details, and accumulated insights. + * + * @param {Object} [pipelineResult={}] - Raw pipeline result from executeFullPipeline. + * @param {boolean} [pipelineResult.success] - Whether the pipeline succeeded. + * @param {number[]} [pipelineResult.epicsExecuted] - Epic numbers that executed. + * @param {number[]} [pipelineResult.epicsFailed] - Epic numbers that failed. + * @param {number[]} [pipelineResult.epicsSkipped] - Epic numbers that were skipped. + * @param {number} [pipelineResult.duration] - Duration in milliseconds. + * @returns {Object} Finalized result with workflowId, status, duration, and epic breakdown. */ finalize(pipelineResult = {}) { const duration = @@ -1512,15 +1543,30 @@ class MasterOrchestrator extends EventEmitter { /** * Stub Epic Executor - placeholder for Story 0.3 - * Real executors will be implemented in Story 0.3 + * + * Provides a no-op executor for epics that do not yet have a real + * implementation. Returns a minimal success result so the pipeline can + * continue during development. */ class StubEpicExecutor { + /** + * Creates a new StubEpicExecutor. + * + * @param {MasterOrchestrator} orchestrator - Parent orchestrator instance. + * @param {number} epicNum - Epic number handled by this stub. + */ constructor(orchestrator, epicNum) { this.orchestrator = orchestrator; this.epicNum = epicNum; this.config = EPIC_CONFIG[epicNum]; } + /** + * Executes the stub epic. + * + * @param {Object} _context - Execution context ignored by the stub. + * @returns {Promise} Minimal result with status 'stub'. + */ async execute(_context) { console.log(chalk.yellow(` ⚠️ Using stub executor for Epic ${this.epicNum}`)); console.log(chalk.gray(` Real executor (${this.config.executor}) not yet implemented`)); diff --git a/.aiox-core/core/orchestration/recovery-handler.js b/.aiox-core/core/orchestration/recovery-handler.js index a159541cd3..55af358b11 100644 --- a/.aiox-core/core/orchestration/recovery-handler.js +++ b/.aiox-core/core/orchestration/recovery-handler.js @@ -656,14 +656,26 @@ class RecoveryHandler extends EventEmitter { } /** - * Get all logs (AC7) + * Gets all recovery logs (AC7). + * + * @returns {Array<{timestamp: string, level: string, message: string}>} Copy of all log entries. + * + * @example + * const logs = recoveryHandler.getLogs(); + * logs.forEach((log) => console.log(`[${log.level}] ${log.message}`)); */ getLogs() { return [...this.logs]; } /** - * Get logs for specific epic + * Gets logs filtered by a specific epic number. + * + * @param {number} epicNum - Epic number to filter logs for. + * @returns {Array<{timestamp: string, level: string, message: string}>} Filtered log entries. + * + * @example + * const epic4Logs = recoveryHandler.getEpicLogs(4); */ getEpicLogs(epicNum) { return this.logs.filter( @@ -672,28 +684,50 @@ class RecoveryHandler extends EventEmitter { } /** - * Get attempt history for all epics + * Gets attempt history for all epics. + * + * Returns a shallow copy of the internal attempts map, keyed by epic number, + * with each value containing attempt records. + * + * @returns {Object} Map of epic numbers to attempt records. */ getAttemptHistory() { return { ...this.attempts }; } /** - * Get attempt count for specific epic (AC5) + * Gets the recovery attempt count for a specific epic (AC5). + * + * @param {number} epicNum - Epic number to inspect. + * @returns {number} Number of recovery attempts made for the epic. */ getAttemptCount(epicNum) { return (this.attempts[epicNum] || []).length; } /** - * Check if can retry (under max retries) (AC5) + * Checks whether an epic can still be retried (AC5). + * + * @param {number} epicNum - Epic number to inspect. + * @returns {boolean} True if the epic attempt count is below maxRetries. + * + * @example + * if (recoveryHandler.canRetry(4)) { + * await orchestrator.executeEpic(4); + * } */ canRetry(epicNum) { return this.getAttemptCount(epicNum) < this.maxRetries; } /** - * Reset attempts for an epic + * Resets all recovery attempts for a specific epic. + * + * Clears attempt history so the epic can be retried from scratch up to the + * configured maxRetries limit. + * + * @param {number} epicNum - Epic number to reset. + * @returns {void} */ resetAttempts(epicNum) { this.attempts[epicNum] = []; @@ -701,7 +735,12 @@ class RecoveryHandler extends EventEmitter { } /** - * Clear all state + * Clears all internal recovery state. + * + * Resets attempts and logs, returning the recovery handler to its initial + * state for a fresh orchestration run. + * + * @returns {void} */ clear() { this.attempts = {}; diff --git a/.aiox-core/core/orchestration/session-state.js b/.aiox-core/core/orchestration/session-state.js index 2290f7dcab..00e5eb03c3 100644 --- a/.aiox-core/core/orchestration/session-state.js +++ b/.aiox-core/core/orchestration/session-state.js @@ -68,13 +68,21 @@ const ResumeOption = { }; /** - * Session State Manager class + * SessionState - Persistent session state manager. + * + * Stores Bob orchestration progress in docs/stories/.session-state.yaml so + * long-running epic/story work can resume after pauses, crashes, or terminal + * restarts. The manager also supports session-scoped overrides and migration + * from the legacy workflow-state format. */ class SessionState { /** - * Creates a new SessionState instance + * Creates a new SessionState instance. + * * @param {string} projectRoot - Project root directory - * @param {Object} options - Options + * @param {Object} [options] - Options + * @param {boolean} [options.debug=false] - Enable debug logging + * @param {boolean} [options.autoMigrate=true] - Attempt migration from legacy workflow state on load */ constructor(projectRoot, options = {}) { this.projectRoot = projectRoot; @@ -221,9 +229,20 @@ class SessionState { } /** - * Updates the session state + * Updates selected sections of the session state. + * + * Supported update sections are progress, workflow, last_action, + * context_snapshot, and overrides. The method refreshes last_updated, + * regenerates resume instructions, and persists the updated state to disk. + * * @param {Object} updates - Fields to update + * @param {Object} [updates.progress] - Progress fields to merge + * @param {Object} [updates.workflow] - Workflow fields to merge + * @param {Object} [updates.last_action] - Last action fields to replace with a fresh timestamp + * @param {Object} [updates.context_snapshot] - Context snapshot fields to merge + * @param {Object} [updates.overrides] - Session override values to merge * @returns {Promise} Updated session state + * @throws {Error} If session state has not been initialized */ async updateSessionState(updates) { if (!this.state) { @@ -434,8 +453,14 @@ class SessionState { } /** - * Generates human-readable resume instructions + * Generates human-readable resume instructions. + * * @param {Object} context - Context for instructions + * @param {string|null} context.currentStory - Current story ID + * @param {number} context.storiesDone - Number of completed stories + * @param {number} context.totalStories - Total stories in the epic + * @param {string|null} context.lastPhase - Last recorded workflow phase + * @param {string|null} context.lastExecutor - Last executor agent * @returns {string} Resume instructions text */ generateResumeInstructions(context) { diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 73558be09b..8a55225b9b 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.1.15 -generated_at: "2026-05-07T19:24:04.148Z" +generated_at: "2026-05-07T19:55:39.252Z" generator: scripts/generate-install-manifest.js file_count: 1103 files: @@ -245,9 +245,9 @@ files: type: core size: 5039 - path: core/config/config-loader.js - hash: sha256:bbc6a9e57e9db7a74ae63c901b199f8b8a79eb093f23a280b6420d1aa5f7f813 + hash: sha256:e70385b1173a6d1e4f4354b6afd88896986a82699d5e0fb779eb5abfcc446e6d type: core - size: 8534 + size: 10631 - path: core/config/config-resolver.js hash: sha256:3b29df6954cec440debef87cb4e4e7610c94dc74e562d32c74b8ec57d893213b type: core @@ -389,9 +389,9 @@ files: type: elicitation size: 10935 - path: core/events/dashboard-emitter.js - hash: sha256:20f3d2297c29a64bae34ac8097cc0b54f4f706b19f0dbd6c752a20d7c4ad9eb1 + hash: sha256:6c694e4fc5765d6c396b25617cc34e3447068af780fdbb202b060a31cd357549 type: core - size: 9165 + size: 10721 - path: core/events/index.js hash: sha256:c5a3a1ba660f1a0d7963e4e8a29ee536a301621c941d962c00f67ade17ba7db3 type: core @@ -809,9 +809,9 @@ files: type: core size: 16573 - path: core/orchestration/bob-orchestrator.js - hash: sha256:35219c5113c262a39f249866e66c76f9f1206465dd68a66eb27d82c623b46048 + hash: sha256:971a21ed77f09ab76dc2822030c4b36edd1fd9d8ec405b25b32f36f0cd606ec8 type: core - size: 35841 + size: 36831 - path: core/orchestration/bob-status-writer.js hash: sha256:d0368d44b8b45549f503d737b0fdb21afb02db8d544b19f4d0289828501ac016 type: core @@ -821,9 +821,9 @@ files: type: core size: 8113 - path: core/orchestration/brownfield-handler.js - hash: sha256:370599a3de3c936e8ebf2f5d14a1d09b8b33c3ccda3027d68740171314888215 + hash: sha256:153eed3dcad1b5f58e0abb9ec4fb0c7013f441efeb169ea4a0cfda23f75928fc type: core - size: 26070 + size: 26866 - path: core/orchestration/checklist-runner.js hash: sha256:40f17b74c2fe6746f45aa5750c0b85b5af22221e010951eb6e93f5796fb70a8f type: core @@ -845,9 +845,9 @@ files: type: core size: 15116 - path: core/orchestration/data-lifecycle-manager.js - hash: sha256:031c351aa28e96eb232db709741eb3474fbe8c25bfc834f607834fdddd8cb632 + hash: sha256:7fe6c9d9a278a1d7714e7fdba8710fafbc221520b096639be46f2ce1549a5c2a type: core - size: 10867 + size: 11622 - path: core/orchestration/epic-context-accumulator.js hash: sha256:4f342f7fc05f404de2b899358f86143106737b56d6c486c98e988a67d420078b type: core @@ -885,29 +885,29 @@ files: type: core size: 1945 - path: core/orchestration/gate-evaluator.js - hash: sha256:66a6ff6afefcdbf3e5149100b8e10aad95feaa5a84a0a993255ddb939b1c0eb4 + hash: sha256:4e9e78745f937ee78b13704f3acdb45b19d4aab9bc0f17a5adaf5eecfc58e653 type: core - size: 15814 + size: 16655 - path: core/orchestration/gemini-model-selector.js hash: sha256:2fe54c401ae60c0b5dc07f74c7a464992a0558b10c0b61f183c06943441d0d57 type: core size: 4383 - path: core/orchestration/greenfield-handler.js - hash: sha256:e4e951e1347f846f4df0af2a7692838a734418030331cb25ce6fa86721beafdf + hash: sha256:949c80c9c0430d4ddb1fa6e721661bad479ab476a4fb529aad8bf8dafa6e57fd type: core - size: 35270 + size: 35723 - path: core/orchestration/index.js hash: sha256:8baea00058c1dcae86328fff06b04c52cfad8ad9a77e3f49458f062609aa478a type: core size: 8835 - path: core/orchestration/lock-manager.js - hash: sha256:3917ef9443f8a0e0de0c79715de54fe1e9451faa9860ee2f0c0f588dfaaf7a09 + hash: sha256:30af501b40da2a7bcfd591c62e367c5667eefd40af91d265c986521c93e7d1f2 type: core - size: 8663 + size: 9382 - path: core/orchestration/master-orchestrator.js - hash: sha256:61c988509c2edc03c1cd4dec333b284f7d15273f3e799c56950706a2e88f341b + hash: sha256:39ce48a0caa7657ce06a0549374c067f80a2fa516f1e7a311f44e0d9966c9e85 type: core - size: 54621 + size: 56733 - path: core/orchestration/message-formatter.js hash: sha256:b7413c04fa22db1c5fc2f5c2aa47bb8ca0374e079894a44df21b733da6c258ae type: core @@ -917,13 +917,13 @@ files: type: core size: 5820 - path: core/orchestration/recovery-handler.js - hash: sha256:6a9ef025f95885849aa893188299aca698cea2ea428cc302012833032c9e106e + hash: sha256:a70f23fe9424c2cba4ed554bf0b0ee4687349af960eadb8ee752d45ff5b7cea7 type: core - size: 24355 + size: 25872 - path: core/orchestration/session-state.js - hash: sha256:72aa24d7a7a256a56973d7b4e7b03c968eabeb0d22be23af75f65f2e45ac88b3 + hash: sha256:70d511bf6a03e8cacefcec0065a4727cfb380471c89762bb291840968f55b44b type: core - size: 24781 + size: 26320 - path: core/orchestration/skill-dispatcher.js hash: sha256:5ebee66973a1df5d9dfed195ac6d83765a92023ba504e1814674345094fb8117 type: core From 595deda345a7318e3f0c35adf884dd9da6239ba1 Mon Sep 17 00:00:00 2001 From: rafaelscosta Date: Thu, 7 May 2026 17:20:01 -0300 Subject: [PATCH 2/7] fix: address CodeRabbit quick wins [Issue #89] --- .aiox-core/core/config/config-loader.js | 2 + .../core/orchestration/master-orchestrator.js | 6 ++- .../core/orchestration/recovery-handler.js | 49 +++++++++++++++++-- .aiox-core/install-manifest.yaml | 14 +++--- tests/core/master-orchestrator.test.js | 10 ++++ tests/core/recovery-handler.test.js | 20 ++++++++ 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/.aiox-core/core/config/config-loader.js b/.aiox-core/core/config/config-loader.js index 23db384ec7..87bfc705c1 100644 --- a/.aiox-core/core/config/config-loader.js +++ b/.aiox-core/core/config/config-loader.js @@ -8,6 +8,8 @@ * - Agent config: const { AgentConfigLoader } = require('./agent-config-loader'); * const loader = new AgentConfigLoader(agentId); * const config = await loader.load(coreConfig); + * - Single section: const config = await loadConfigSections([sectionName]); + * const section = config[sectionName]; * * AIOX Config Loader with Lazy Loading * diff --git a/.aiox-core/core/orchestration/master-orchestrator.js b/.aiox-core/core/orchestration/master-orchestrator.js index f03cd606c2..dda357b532 100644 --- a/.aiox-core/core/orchestration/master-orchestrator.js +++ b/.aiox-core/core/orchestration/master-orchestrator.js @@ -941,7 +941,11 @@ class MasterOrchestrator extends EventEmitter { * @returns {Promise} */ async startDashboard() { - await this.dashboardIntegration.start(); + try { + await this.dashboardIntegration.start(); + } catch (error) { + this._log(`Dashboard start failed (non-blocking): ${error.message}`, { level: 'warn' }); + } } /** diff --git a/.aiox-core/core/orchestration/recovery-handler.js b/.aiox-core/core/orchestration/recovery-handler.js index 55af358b11..4250c968d9 100644 --- a/.aiox-core/core/orchestration/recovery-handler.js +++ b/.aiox-core/core/orchestration/recovery-handler.js @@ -673,11 +673,14 @@ class RecoveryHandler extends EventEmitter { * * @param {number} epicNum - Epic number to filter logs for. * @returns {Array<{timestamp: string, level: string, message: string}>} Filtered log entries. + * @throws {TypeError} If epicNum is not a non-negative integer. * * @example * const epic4Logs = recoveryHandler.getEpicLogs(4); */ getEpicLogs(epicNum) { + this._assertValidEpicNum(epicNum); + return this.logs.filter( (log) => log.message.includes(`Epic ${epicNum}`) || log.message.includes(`epic-${epicNum}`), ); @@ -686,13 +689,18 @@ class RecoveryHandler extends EventEmitter { /** * Gets attempt history for all epics. * - * Returns a shallow copy of the internal attempts map, keyed by epic number, - * with each value containing attempt records. + * Returns cloned attempt arrays to prevent callers from mutating internal + * recovery state. * * @returns {Object} Map of epic numbers to attempt records. */ getAttemptHistory() { - return { ...this.attempts }; + return Object.fromEntries( + Object.entries(this.attempts).map(([epicNum, attempts]) => [ + epicNum, + attempts.map((attempt) => this._cloneAttempt(attempt)), + ]), + ); } /** @@ -700,8 +708,11 @@ class RecoveryHandler extends EventEmitter { * * @param {number} epicNum - Epic number to inspect. * @returns {number} Number of recovery attempts made for the epic. + * @throws {TypeError} If epicNum is not a non-negative integer. */ getAttemptCount(epicNum) { + this._assertValidEpicNum(epicNum); + return (this.attempts[epicNum] || []).length; } @@ -710,6 +721,7 @@ class RecoveryHandler extends EventEmitter { * * @param {number} epicNum - Epic number to inspect. * @returns {boolean} True if the epic attempt count is below maxRetries. + * @throws {TypeError} If epicNum is not a non-negative integer. * * @example * if (recoveryHandler.canRetry(4)) { @@ -728,12 +740,43 @@ class RecoveryHandler extends EventEmitter { * * @param {number} epicNum - Epic number to reset. * @returns {void} + * @throws {TypeError} If epicNum is not a non-negative integer. */ resetAttempts(epicNum) { + this._assertValidEpicNum(epicNum); + this.attempts[epicNum] = []; this._log(`Reset attempts for Epic ${epicNum}`, 'info'); } + /** + * Validates an epic number argument. + * + * @param {number} epicNum - Epic number to validate. + * @throws {TypeError} If epicNum is not a non-negative integer. + * @private + */ + _assertValidEpicNum(epicNum) { + if (typeof epicNum !== 'number' || !Number.isInteger(epicNum) || epicNum < 0) { + throw new TypeError(`epicNum must be a non-negative integer, got: ${JSON.stringify(epicNum)}`); + } + } + + /** + * Clones an attempt record for safe external access. + * + * @param {Object} attempt - Attempt record. + * @returns {Object} Cloned attempt record. + * @private + */ + _cloneAttempt(attempt) { + if (typeof structuredClone === 'function') { + return structuredClone(attempt); + } + + return JSON.parse(JSON.stringify(attempt)); + } + /** * Clears all internal recovery state. * diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 8a55225b9b..eb3c5137ee 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.1.15 -generated_at: "2026-05-07T19:55:39.252Z" +generated_at: "2026-05-07T20:19:07.382Z" generator: scripts/generate-install-manifest.js file_count: 1103 files: @@ -245,9 +245,9 @@ files: type: core size: 5039 - path: core/config/config-loader.js - hash: sha256:e70385b1173a6d1e4f4354b6afd88896986a82699d5e0fb779eb5abfcc446e6d + hash: sha256:05c8cfa5fe8c0bd659ac65791e5b551767e581a465cad6bb42a9c0a31847e4b4 type: core - size: 10631 + size: 10772 - path: core/config/config-resolver.js hash: sha256:3b29df6954cec440debef87cb4e4e7610c94dc74e562d32c74b8ec57d893213b type: core @@ -905,9 +905,9 @@ files: type: core size: 9382 - path: core/orchestration/master-orchestrator.js - hash: sha256:39ce48a0caa7657ce06a0549374c067f80a2fa516f1e7a311f44e0d9966c9e85 + hash: sha256:29b69047fe780751ae89f6c593cb835254e2e8a536f5581473f474115deec496 type: core - size: 56733 + size: 56868 - path: core/orchestration/message-formatter.js hash: sha256:b7413c04fa22db1c5fc2f5c2aa47bb8ca0374e079894a44df21b733da6c258ae type: core @@ -917,9 +917,9 @@ files: type: core size: 5820 - path: core/orchestration/recovery-handler.js - hash: sha256:a70f23fe9424c2cba4ed554bf0b0ee4687349af960eadb8ee752d45ff5b7cea7 + hash: sha256:36bf239d2d47caf6fbc33a6b4e884af63654e8a0cc524d9fdffea0c3a8a0146d type: core - size: 25872 + size: 27171 - path: core/orchestration/session-state.js hash: sha256:70d511bf6a03e8cacefcec0065a4727cfb380471c89762bb291840968f55b44b type: core diff --git a/tests/core/master-orchestrator.test.js b/tests/core/master-orchestrator.test.js index 18918f7ead..fcc827ce68 100644 --- a/tests/core/master-orchestrator.test.js +++ b/tests/core/master-orchestrator.test.js @@ -433,6 +433,16 @@ describe('MasterOrchestrator', () => { }); }); + describe('Dashboard Integration', () => { + it('should not throw when dashboard start fails', async () => { + orchestrator.dashboardIntegration.start = jest + .fn() + .mockRejectedValue(new Error('dashboard unavailable')); + + await expect(orchestrator.startDashboard()).resolves.toBeUndefined(); + }); + }); + describe('Progress Tracking', () => { it('should calculate progress percentage', async () => { await orchestrator.initialize(); diff --git a/tests/core/recovery-handler.test.js b/tests/core/recovery-handler.test.js index 702d285078..31a5cfed73 100644 --- a/tests/core/recovery-handler.test.js +++ b/tests/core/recovery-handler.test.js @@ -204,6 +204,19 @@ describe('Recovery Handler (Story 0.5)', () => { expect(history[3][0].error).toBe('Specific error'); }); + it('should return cloned attempt history', async () => { + await handler.handleEpicFailure(3, new Error('Specific error'), { + approach: 'custom approach', + }); + + const history = handler.getAttemptHistory(); + history[3].push({ fake: true }); + history[3][0].approach = 'mutated approach'; + + expect(handler.getAttemptCount(3)).toBe(1); + expect(handler.getAttemptHistory()[3][0].approach).toBe('custom approach'); + }); + it('should get logs for specific epic', async () => { await handler.handleEpicFailure(3, new Error('Epic 3 error')); await handler.handleEpicFailure(4, new Error('Epic 4 error')); @@ -224,6 +237,13 @@ describe('Recovery Handler (Story 0.5)', () => { expect(handler.getAttemptCount(3)).toBe(0); }); + it('should reject invalid epic numbers for public attempt APIs', () => { + expect(() => handler.getAttemptCount(null)).toThrow(TypeError); + expect(() => handler.canRetry(undefined)).toThrow(TypeError); + expect(() => handler.resetAttempts(-1)).toThrow(TypeError); + expect(() => handler.getEpicLogs(1.5)).toThrow(TypeError); + }); + it('should clear all state', async () => { await handler.handleEpicFailure(3, new Error('Fail')); await handler.handleEpicFailure(4, new Error('Fail')); From 665db70a38519e6067988b6cf897e4ad2d94be32 Mon Sep 17 00:00:00 2001 From: rafaelscosta Date: Thu, 7 May 2026 17:27:51 -0300 Subject: [PATCH 3/7] fix: validate recovery handler epic failures [Issue #89] --- .aiox-core/core/orchestration/recovery-handler.js | 3 +++ .aiox-core/install-manifest.yaml | 6 +++--- tests/core/recovery-handler.test.js | 8 ++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.aiox-core/core/orchestration/recovery-handler.js b/.aiox-core/core/orchestration/recovery-handler.js index 4250c968d9..75e4c0a4a7 100644 --- a/.aiox-core/core/orchestration/recovery-handler.js +++ b/.aiox-core/core/orchestration/recovery-handler.js @@ -163,8 +163,11 @@ class RecoveryHandler extends EventEmitter { * @param {Error|string} error - Error that caused failure * @param {Object} [context={}] - Additional context * @returns {Promise} Recovery result + * @throws {TypeError} If epicNum is not a non-negative integer. */ async handleEpicFailure(epicNum, error, context = {}) { + this._assertValidEpicNum(epicNum); + const errorMessage = error instanceof Error ? error.message : String(error); const timestamp = new Date().toISOString(); diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index eb3c5137ee..323bd1107b 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.1.15 -generated_at: "2026-05-07T20:19:07.382Z" +generated_at: "2026-05-07T20:26:55.945Z" generator: scripts/generate-install-manifest.js file_count: 1103 files: @@ -917,9 +917,9 @@ files: type: core size: 5820 - path: core/orchestration/recovery-handler.js - hash: sha256:36bf239d2d47caf6fbc33a6b4e884af63654e8a0cc524d9fdffea0c3a8a0146d + hash: sha256:7c6f448c70bae1ad774e3dc6056d5da2e8ddaf187c3a9c9b6e180965effdd9cc type: core - size: 27171 + size: 27278 - path: core/orchestration/session-state.js hash: sha256:70d511bf6a03e8cacefcec0065a4727cfb380471c89762bb291840968f55b44b type: core diff --git a/tests/core/recovery-handler.test.js b/tests/core/recovery-handler.test.js index 31a5cfed73..0e5a1aee4c 100644 --- a/tests/core/recovery-handler.test.js +++ b/tests/core/recovery-handler.test.js @@ -122,6 +122,14 @@ describe('Recovery Handler (Story 0.5)', () => { expect(result.strategy).toBe(RecoveryStrategy.ESCALATE_TO_HUMAN); expect(result.escalated).toBe(true); }); + + it('should reject invalid epic numbers before mutating attempts', async () => { + await expect(handler.handleEpicFailure(null, new Error('Invalid epic'))).rejects.toThrow( + TypeError, + ); + + expect(Object.keys(handler.getAttemptHistory())).toHaveLength(0); + }); }); describe('Max Retries (AC5)', () => { From fbb34e4595334feaa9b982101dee1609c29af37a Mon Sep 17 00:00:00 2001 From: rafaelscosta Date: Thu, 7 May 2026 17:41:24 -0300 Subject: [PATCH 4/7] fix: harden recovery epic validation message [Issue #89] --- .aiox-core/core/orchestration/recovery-handler.js | 2 +- .aiox-core/install-manifest.yaml | 6 +++--- tests/core/recovery-handler.test.js | 4 ++++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.aiox-core/core/orchestration/recovery-handler.js b/.aiox-core/core/orchestration/recovery-handler.js index 75e4c0a4a7..2ce810e4f9 100644 --- a/.aiox-core/core/orchestration/recovery-handler.js +++ b/.aiox-core/core/orchestration/recovery-handler.js @@ -761,7 +761,7 @@ class RecoveryHandler extends EventEmitter { */ _assertValidEpicNum(epicNum) { if (typeof epicNum !== 'number' || !Number.isInteger(epicNum) || epicNum < 0) { - throw new TypeError(`epicNum must be a non-negative integer, got: ${JSON.stringify(epicNum)}`); + throw new TypeError(`epicNum must be a non-negative integer, got: ${String(epicNum)}`); } } diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 323bd1107b..6271c3c75e 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.1.15 -generated_at: "2026-05-07T20:26:55.945Z" +generated_at: "2026-05-07T20:38:05.363Z" generator: scripts/generate-install-manifest.js file_count: 1103 files: @@ -917,9 +917,9 @@ files: type: core size: 5820 - path: core/orchestration/recovery-handler.js - hash: sha256:7c6f448c70bae1ad774e3dc6056d5da2e8ddaf187c3a9c9b6e180965effdd9cc + hash: sha256:af33b1d36656a47bb57c822f05a8c3ad186af71ba747e748bc51387f3f389d76 type: core - size: 27278 + size: 27270 - path: core/orchestration/session-state.js hash: sha256:70d511bf6a03e8cacefcec0065a4727cfb380471c89762bb291840968f55b44b type: core diff --git a/tests/core/recovery-handler.test.js b/tests/core/recovery-handler.test.js index 0e5a1aee4c..c941759074 100644 --- a/tests/core/recovery-handler.test.js +++ b/tests/core/recovery-handler.test.js @@ -127,6 +127,9 @@ describe('Recovery Handler (Story 0.5)', () => { await expect(handler.handleEpicFailure(null, new Error('Invalid epic'))).rejects.toThrow( TypeError, ); + await expect(handler.handleEpicFailure(1n, new Error('Invalid epic'))).rejects.toThrow( + 'got: 1', + ); expect(Object.keys(handler.getAttemptHistory())).toHaveLength(0); }); @@ -250,6 +253,7 @@ describe('Recovery Handler (Story 0.5)', () => { expect(() => handler.canRetry(undefined)).toThrow(TypeError); expect(() => handler.resetAttempts(-1)).toThrow(TypeError); expect(() => handler.getEpicLogs(1.5)).toThrow(TypeError); + expect(() => handler.getAttemptCount(1n)).toThrow('got: 1'); }); it('should clear all state', async () => { From f36afc26e4503f83e8ca04ee893599b4fcd8986e Mon Sep 17 00:00:00 2001 From: rafaelscosta Date: Thu, 7 May 2026 18:20:10 -0300 Subject: [PATCH 5/7] fix: sanitize recovery attempt history [Issue #89] --- .../core/orchestration/recovery-handler.js | 139 +++++++++++++++++- .aiox-core/install-manifest.yaml | 6 +- tests/core/recovery-handler.test.js | 33 +++++ 3 files changed, 171 insertions(+), 7 deletions(-) diff --git a/.aiox-core/core/orchestration/recovery-handler.js b/.aiox-core/core/orchestration/recovery-handler.js index 2ce810e4f9..50996eed8e 100644 --- a/.aiox-core/core/orchestration/recovery-handler.js +++ b/.aiox-core/core/orchestration/recovery-handler.js @@ -693,9 +693,10 @@ class RecoveryHandler extends EventEmitter { * Gets attempt history for all epics. * * Returns cloned attempt arrays to prevent callers from mutating internal - * recovery state. + * recovery state. Epic number keys are stringified because JavaScript object + * keys are strings. * - * @returns {Object} Map of epic numbers to attempt records. + * @returns {Object} Map of stringified epic numbers to attempt records. */ getAttemptHistory() { return Object.fromEntries( @@ -768,16 +769,146 @@ class RecoveryHandler extends EventEmitter { /** * Clones an attempt record for safe external access. * + * Uses structuredClone when available, falls back to JSON for simple data, and + * returns a sanitized data-only representation for non-serializable context. + * * @param {Object} attempt - Attempt record. * @returns {Object} Cloned attempt record. * @private */ _cloneAttempt(attempt) { if (typeof structuredClone === 'function') { - return structuredClone(attempt); + try { + return structuredClone(attempt); + } catch (error) { + // Fall through to serializable/sanitized fallback for unsupported values. + } + } + + if (!this._requiresSanitizedClone(attempt)) { + try { + return JSON.parse(JSON.stringify(attempt)); + } catch (error) { + // Fall through to sanitized fallback if JSON cloning unexpectedly fails. + } + } + + return this._sanitizeValue(attempt); + } + + /** + * Checks if JSON cloning would lose data or throw for a value. + * + * @param {*} value - Value to inspect. + * @param {WeakSet} [seen=new WeakSet()] - Objects in the current path. + * @returns {boolean} True when the value needs the sanitizer fallback. + * @private + */ + _requiresSanitizedClone(value, seen = new WeakSet()) { + if (value === undefined) { + return true; + } + + const valueType = typeof value; + + if (valueType === 'bigint' || valueType === 'function' || valueType === 'symbol') { + return true; + } + + if (value === null || valueType !== 'object') { + return false; + } + + if (value instanceof Error || value instanceof Map || value instanceof Set) { + return true; + } + + if (seen.has(value)) { + return true; + } + + seen.add(value); + + try { + const childValues = Array.isArray(value) ? value : Object.values(value); + return childValues.some((childValue) => this._requiresSanitizedClone(childValue, seen)); + } catch (error) { + return true; + } finally { + seen.delete(value); + } + } + + /** + * Converts arbitrary values into stable data-only representations. + * + * @param {*} value - Value to sanitize. + * @param {WeakSet} [seen=new WeakSet()] - Objects in the current path. + * @returns {*} Data-only representation that does not throw during cloning. + * @private + */ + _sanitizeValue(value, seen = new WeakSet()) { + if (value === undefined || value === null) { + return value; + } + + const valueType = typeof value; + + if (valueType === 'bigint') { + return value.toString(); + } + + if (valueType === 'function' || valueType === 'symbol') { + return String(value); + } + + if (valueType !== 'object') { + return value; } - return JSON.parse(JSON.stringify(attempt)); + if (seen.has(value)) { + return '[Circular]'; + } + + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + }; + } + + seen.add(value); + + try { + if (value instanceof Map) { + return Array.from(value.entries()).map(([key, entryValue]) => [ + this._sanitizeValue(key, seen), + this._sanitizeValue(entryValue, seen), + ]); + } + + if (value instanceof Set) { + return Array.from(value.values()).map((entryValue) => this._sanitizeValue(entryValue, seen)); + } + + if (Array.isArray(value)) { + return value.map((entryValue) => this._sanitizeValue(entryValue, seen)); + } + + return Object.keys(value).reduce((safeValue, key) => { + try { + safeValue[key] = this._sanitizeValue(value[key], seen); + } catch (error) { + safeValue[key] = `[Unserializable: ${error.message}]`; + } + return safeValue; + }, {}); + } catch (error) { + return `[Unserializable: ${error.message}]`; + } finally { + seen.delete(value); + } } /** diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 6271c3c75e..f0da7fd452 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.1.15 -generated_at: "2026-05-07T20:38:05.363Z" +generated_at: "2026-05-07T21:19:14.725Z" generator: scripts/generate-install-manifest.js file_count: 1103 files: @@ -917,9 +917,9 @@ files: type: core size: 5820 - path: core/orchestration/recovery-handler.js - hash: sha256:af33b1d36656a47bb57c822f05a8c3ad186af71ba747e748bc51387f3f389d76 + hash: sha256:715b9e7f51a6881a174b3f9e5c321bbd3a4ab3630ba7353ca5826223472d6abc type: core - size: 27270 + size: 30823 - path: core/orchestration/session-state.js hash: sha256:70d511bf6a03e8cacefcec0065a4727cfb380471c89762bb291840968f55b44b type: core diff --git a/tests/core/recovery-handler.test.js b/tests/core/recovery-handler.test.js index c941759074..a4244e900a 100644 --- a/tests/core/recovery-handler.test.js +++ b/tests/core/recovery-handler.test.js @@ -228,6 +228,39 @@ describe('Recovery Handler (Story 0.5)', () => { expect(handler.getAttemptHistory()[3][0].approach).toBe('custom approach'); }); + it('should return sanitized attempt history when JSON cloning would lose data', async () => { + const originalStructuredClone = global.structuredClone; + global.structuredClone = undefined; + + const circular = {}; + circular.self = circular; + + try { + await handler.handleEpicFailure(3, new Error('Specific error'), { + circular, + error: new Error('Context failure'), + map: new Map([['attempt', 1n]]), + maybe: undefined, + set: new Set([2n]), + }); + + const history = handler.getAttemptHistory(); + const context = history['3'][0].context; + + expect(context.circular.self).toBe('[Circular]'); + expect(context.error).toMatchObject({ + name: 'Error', + message: 'Context failure', + }); + expect(context.map).toEqual([['attempt', '1']]); + expect(Object.prototype.hasOwnProperty.call(context, 'maybe')).toBe(true); + expect(context.maybe).toBeUndefined(); + expect(context.set).toEqual(['2']); + } finally { + global.structuredClone = originalStructuredClone; + } + }); + it('should get logs for specific epic', async () => { await handler.handleEpicFailure(3, new Error('Epic 3 error')); await handler.handleEpicFailure(4, new Error('Epic 4 error')); From 485f502b686098e7974d5f51b7bb40a0c9864514 Mon Sep 17 00:00:00 2001 From: rafaelscosta Date: Thu, 7 May 2026 18:33:31 -0300 Subject: [PATCH 6/7] fix: normalize recovery attempt history [Issue #89] --- .../core/orchestration/recovery-handler.js | 72 +++---------------- .aiox-core/install-manifest.yaml | 6 +- tests/core/recovery-handler.test.js | 49 ++++++------- 3 files changed, 33 insertions(+), 94 deletions(-) diff --git a/.aiox-core/core/orchestration/recovery-handler.js b/.aiox-core/core/orchestration/recovery-handler.js index 50996eed8e..9efbd91bff 100644 --- a/.aiox-core/core/orchestration/recovery-handler.js +++ b/.aiox-core/core/orchestration/recovery-handler.js @@ -692,9 +692,12 @@ class RecoveryHandler extends EventEmitter { /** * Gets attempt history for all epics. * - * Returns cloned attempt arrays to prevent callers from mutating internal + * Returns deep-cloned attempt arrays to prevent callers from mutating internal * recovery state. Epic number keys are stringified because JavaScript object - * keys are strings. + * keys are strings. Reference equality with internal state is intentionally + * not preserved. Attempt records are normalized through _cloneAttempt as + * data-only objects, so Error, Map, Set, BigInt, and circular context values + * are represented with stable serializable shapes. * * @returns {Object} Map of stringified epic numbers to attempt records. */ @@ -733,6 +736,8 @@ class RecoveryHandler extends EventEmitter { * } */ canRetry(epicNum) { + this._assertValidEpicNum(epicNum); + return this.getAttemptCount(epicNum) < this.maxRetries; } @@ -769,76 +774,17 @@ class RecoveryHandler extends EventEmitter { /** * Clones an attempt record for safe external access. * - * Uses structuredClone when available, falls back to JSON for simple data, and - * returns a sanitized data-only representation for non-serializable context. + * Always returns a sanitized data-only representation so callers observe the + * same output shape across Node versions and runtime clone capabilities. * * @param {Object} attempt - Attempt record. * @returns {Object} Cloned attempt record. * @private */ _cloneAttempt(attempt) { - if (typeof structuredClone === 'function') { - try { - return structuredClone(attempt); - } catch (error) { - // Fall through to serializable/sanitized fallback for unsupported values. - } - } - - if (!this._requiresSanitizedClone(attempt)) { - try { - return JSON.parse(JSON.stringify(attempt)); - } catch (error) { - // Fall through to sanitized fallback if JSON cloning unexpectedly fails. - } - } - return this._sanitizeValue(attempt); } - /** - * Checks if JSON cloning would lose data or throw for a value. - * - * @param {*} value - Value to inspect. - * @param {WeakSet} [seen=new WeakSet()] - Objects in the current path. - * @returns {boolean} True when the value needs the sanitizer fallback. - * @private - */ - _requiresSanitizedClone(value, seen = new WeakSet()) { - if (value === undefined) { - return true; - } - - const valueType = typeof value; - - if (valueType === 'bigint' || valueType === 'function' || valueType === 'symbol') { - return true; - } - - if (value === null || valueType !== 'object') { - return false; - } - - if (value instanceof Error || value instanceof Map || value instanceof Set) { - return true; - } - - if (seen.has(value)) { - return true; - } - - seen.add(value); - - try { - const childValues = Array.isArray(value) ? value : Object.values(value); - return childValues.some((childValue) => this._requiresSanitizedClone(childValue, seen)); - } catch (error) { - return true; - } finally { - seen.delete(value); - } - } - /** * Converts arbitrary values into stable data-only representations. * diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index f0da7fd452..2cd6731f10 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.1.15 -generated_at: "2026-05-07T21:19:14.725Z" +generated_at: "2026-05-07T21:32:27.906Z" generator: scripts/generate-install-manifest.js file_count: 1103 files: @@ -917,9 +917,9 @@ files: type: core size: 5820 - path: core/orchestration/recovery-handler.js - hash: sha256:715b9e7f51a6881a174b3f9e5c321bbd3a4ab3630ba7353ca5826223472d6abc + hash: sha256:37bc632071d07fdf7c944c3b8f2c98e6c03a676a0fa7cc9ada6691269ecbd19b type: core - size: 30823 + size: 29569 - path: core/orchestration/session-state.js hash: sha256:70d511bf6a03e8cacefcec0065a4727cfb380471c89762bb291840968f55b44b type: core diff --git a/tests/core/recovery-handler.test.js b/tests/core/recovery-handler.test.js index a4244e900a..2009ebc8dc 100644 --- a/tests/core/recovery-handler.test.js +++ b/tests/core/recovery-handler.test.js @@ -228,37 +228,30 @@ describe('Recovery Handler (Story 0.5)', () => { expect(handler.getAttemptHistory()[3][0].approach).toBe('custom approach'); }); - it('should return sanitized attempt history when JSON cloning would lose data', async () => { - const originalStructuredClone = global.structuredClone; - global.structuredClone = undefined; - + it('should return data-only attempt history for non-JSON context values', async () => { const circular = {}; circular.self = circular; - try { - await handler.handleEpicFailure(3, new Error('Specific error'), { - circular, - error: new Error('Context failure'), - map: new Map([['attempt', 1n]]), - maybe: undefined, - set: new Set([2n]), - }); - - const history = handler.getAttemptHistory(); - const context = history['3'][0].context; - - expect(context.circular.self).toBe('[Circular]'); - expect(context.error).toMatchObject({ - name: 'Error', - message: 'Context failure', - }); - expect(context.map).toEqual([['attempt', '1']]); - expect(Object.prototype.hasOwnProperty.call(context, 'maybe')).toBe(true); - expect(context.maybe).toBeUndefined(); - expect(context.set).toEqual(['2']); - } finally { - global.structuredClone = originalStructuredClone; - } + await handler.handleEpicFailure(3, new Error('Specific error'), { + circular, + error: new Error('Context failure'), + map: new Map([['attempt', 1n]]), + maybe: undefined, + set: new Set([2n]), + }); + + const history = handler.getAttemptHistory(); + const context = history['3'][0].context; + + expect(context.circular.self).toBe('[Circular]'); + expect(context.error).toMatchObject({ + name: 'Error', + message: 'Context failure', + }); + expect(context.map).toEqual([['attempt', '1']]); + expect(Object.prototype.hasOwnProperty.call(context, 'maybe')).toBe(true); + expect(context.maybe).toBeUndefined(); + expect(context.set).toEqual(['2']); }); it('should get logs for specific epic', async () => { From ce31c80cc916df1880950b3ddc378f78206e44f8 Mon Sep 17 00:00:00 2001 From: rafaelscosta Date: Thu, 7 May 2026 18:44:30 -0300 Subject: [PATCH 7/7] fix: preserve recovery error diagnostics [Issue #89] --- .../core/orchestration/recovery-handler.js | 22 ++++++++++++++++++- .aiox-core/install-manifest.yaml | 6 ++--- tests/core/recovery-handler.test.js | 17 +++++++++++++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/.aiox-core/core/orchestration/recovery-handler.js b/.aiox-core/core/orchestration/recovery-handler.js index 9efbd91bff..5d996e798e 100644 --- a/.aiox-core/core/orchestration/recovery-handler.js +++ b/.aiox-core/core/orchestration/recovery-handler.js @@ -817,11 +817,31 @@ class RecoveryHandler extends EventEmitter { } if (value instanceof Error) { - return { + const safeError = { name: value.name, message: value.message, stack: value.stack, }; + + seen.add(value); + + try { + Object.getOwnPropertyNames(value).forEach((key) => { + if (key === 'name' || key === 'message' || key === 'stack') { + return; + } + + try { + safeError[key] = this._sanitizeValue(value[key], seen); + } catch (error) { + safeError[key] = `[Unserializable: ${error.message}]`; + } + }); + + return safeError; + } finally { + seen.delete(value); + } } seen.add(value); diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 2cd6731f10..88830fc28d 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.1.15 -generated_at: "2026-05-07T21:32:27.906Z" +generated_at: "2026-05-07T21:43:49.303Z" generator: scripts/generate-install-manifest.js file_count: 1103 files: @@ -917,9 +917,9 @@ files: type: core size: 5820 - path: core/orchestration/recovery-handler.js - hash: sha256:37bc632071d07fdf7c944c3b8f2c98e6c03a676a0fa7cc9ada6691269ecbd19b + hash: sha256:3224718aa40bbdd08e117b27ac71f4ec66252d9874acaf9bb3addfe8ca4d0c06 type: core - size: 29569 + size: 30067 - path: core/orchestration/session-state.js hash: sha256:70d511bf6a03e8cacefcec0065a4727cfb380471c89762bb291840968f55b44b type: core diff --git a/tests/core/recovery-handler.test.js b/tests/core/recovery-handler.test.js index 2009ebc8dc..0f1d1b28f7 100644 --- a/tests/core/recovery-handler.test.js +++ b/tests/core/recovery-handler.test.js @@ -231,10 +231,17 @@ describe('Recovery Handler (Story 0.5)', () => { it('should return data-only attempt history for non-JSON context values', async () => { const circular = {}; circular.self = circular; + const cause = new Error('Cause failure'); + cause.code = 'ECAUSE'; + const contextError = new Error('Context failure'); + contextError.cause = cause; + contextError.code = 'ECONTEXT'; + contextError.details = new Map([['attempt', 3n]]); + contextError.status = 500; await handler.handleEpicFailure(3, new Error('Specific error'), { circular, - error: new Error('Context failure'), + error: contextError, map: new Map([['attempt', 1n]]), maybe: undefined, set: new Set([2n]), @@ -247,6 +254,14 @@ describe('Recovery Handler (Story 0.5)', () => { expect(context.error).toMatchObject({ name: 'Error', message: 'Context failure', + cause: { + code: 'ECAUSE', + message: 'Cause failure', + name: 'Error', + }, + code: 'ECONTEXT', + details: [['attempt', '3']], + status: 500, }); expect(context.map).toEqual([['attempt', '1']]); expect(Object.prototype.hasOwnProperty.call(context, 'maybe')).toBe(true);