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.

356 lines (317 loc) 10.5 kB
/** * State Manager for External Ralph Loop * * Provides atomic state persistence with backup/recovery support. * State is stored in .aiwg/ralph-external/ directory. * * @implements @.aiwg/requirements/design-ralph-external.md */ import { existsSync, mkdirSync, copyFileSync, renameSync, readFileSync, writeFileSync } from 'fs'; import { join, dirname } from 'path'; import { randomUUID } from 'crypto'; /** * @typedef {Object} IterationRecord * @property {number} number - Iteration number * @property {string} sessionId - Claude session ID * @property {string} promptFile - Path to prompt file * @property {string} stdoutFile - Path to stdout capture * @property {string} stderrFile - Path to stderr capture * @property {number} exitCode - Process exit code * @property {number} duration - Duration in milliseconds * @property {string} status - completed|failed|crashed * @property {Object} analysis - Output analysis result * @property {string[]} learnings - Extracted learnings * @property {string[]} filesModified - Files changed during iteration * @property {string} progress - Progress summary */ /** * @typedef {Object} GiteaIntegration * @property {boolean} enabled - Whether Gitea tracking is enabled * @property {string} owner - Repository owner * @property {string} repo - Repository name * @property {number} issueNumber - Issue number for tracking * @property {number} [lastCommentId] - ID of last progress comment */ /** * @typedef {Object} LoopState * @property {string} version - State schema version * @property {string} loopId - Unique loop identifier * @property {string} objective - Full objective text * @property {string} completionCriteria - Verifiable completion criteria * @property {string} status - running|completed|failed|paused|aborted * @property {number} maxIterations - Maximum external iterations * @property {number} currentIteration - Current iteration number * @property {string} startTime - ISO timestamp of loop start * @property {string} lastUpdate - ISO timestamp of last update * @property {string} sessionId - Base session ID for Claude * @property {number} [currentPid] - PID of current Claude process * @property {IterationRecord[]} iterations - History of iterations * @property {string} accumulatedLearnings - Combined learnings from all iterations * @property {string[]} filesModified - All files modified across iterations * @property {GiteaIntegration} [giteaIntegration] - Gitea issue tracking * @property {Object} config - Original configuration */ const STATE_VERSION = '1.0.0'; const STATE_FILENAME = 'session-state.json'; const BACKUP_SUFFIX = '.bak'; export class StateManager { /** * @param {string} projectRoot - Project root directory */ constructor(projectRoot) { this.projectRoot = projectRoot; this.stateDir = join(projectRoot, '.aiwg', 'ralph-external'); this.statePath = join(this.stateDir, STATE_FILENAME); this.backupPath = `${this.statePath}${BACKUP_SUFFIX}`; } /** * Initialize a new loop state * @param {Object} config - Loop configuration * @returns {LoopState} */ initialize(config) { const now = new Date().toISOString(); const loopId = randomUUID(); const sessionId = randomUUID(); /** @type {LoopState} */ const state = { version: STATE_VERSION, loopId, objective: config.objective, completionCriteria: config.completionCriteria, status: 'running', maxIterations: config.maxIterations || 10, currentIteration: 0, startTime: now, lastUpdate: now, sessionId, currentPid: null, iterations: [], accumulatedLearnings: '', filesModified: [], giteaIntegration: config.giteaIntegration || null, config: { model: config.model || 'opus', budgetPerIteration: config.budgetPerIteration || 2.0, timeoutMinutes: config.timeoutMinutes || 60, mcpConfig: config.mcpConfig || null, workingDir: config.workingDir || this.projectRoot, // Enhanced capture options verbose: config.verbose || false, checkpointIntervalMinutes: config.checkpointIntervalMinutes || 30, enableCheckpoints: config.enableCheckpoints !== false, enableSnapshots: config.enableSnapshots !== false, useClaudeAssessment: config.useClaudeAssessment || false, keyFiles: config.keyFiles || [], // Research-backed options memory: config.memory || 3, crossTask: config.crossTask !== false, enableAnalytics: config.enableAnalytics !== false, enableBestOutput: config.enableBestOutput !== false, enableEarlyStopping: config.enableEarlyStopping !== false, // Epic #26 options enablePIDControl: config.enablePIDControl !== false, enableOverseer: config.enableOverseer !== false, enableSemanticMemory: config.enableSemanticMemory !== false, enableClaudeIntelligence: config.enableClaudeIntelligence !== false, }, }; // Ensure state directory exists this.ensureStateDir(); // Save initial state this.save(state); return state; } /** * Ensure state directory exists */ ensureStateDir() { if (!existsSync(this.stateDir)) { mkdirSync(this.stateDir, { recursive: true }); } // Create subdirectories const subdirs = ['iterations', 'prompts', 'outputs', 'analysis']; for (const subdir of subdirs) { const path = join(this.stateDir, subdir); if (!existsSync(path)) { mkdirSync(path, { recursive: true }); } } } /** * Check if a loop state exists * @returns {boolean} */ exists() { return existsSync(this.statePath); } /** * Load existing state with recovery from backup * @returns {LoopState|null} */ load() { if (!this.exists()) { return null; } try { const content = readFileSync(this.statePath, 'utf8'); return JSON.parse(content); } catch (e) { // Try backup if (existsSync(this.backupPath)) { try { const backupContent = readFileSync(this.backupPath, 'utf8'); const state = JSON.parse(backupContent); // Restore from backup this.save(state); return state; } catch (backupError) { throw new Error(`State corrupted and backup recovery failed: ${backupError.message}`); } } throw new Error(`Failed to load state: ${e.message}`); } } /** * Save state atomically with backup * @param {LoopState} state */ save(state) { this.ensureStateDir(); // Update timestamp state.lastUpdate = new Date().toISOString(); // Create backup of existing state if (existsSync(this.statePath)) { copyFileSync(this.statePath, this.backupPath); } // Write to temp file then rename (atomic) const tempPath = `${this.statePath}.tmp`; writeFileSync(tempPath, JSON.stringify(state, null, 2)); renameSync(tempPath, this.statePath); } /** * Update state with partial changes * @param {Partial<LoopState>} changes * @returns {LoopState} */ update(changes) { const state = this.load(); if (!state) { throw new Error('No existing state to update'); } const updated = { ...state, ...changes }; this.save(updated); return updated; } /** * Add an iteration record * @param {IterationRecord} iteration * @returns {LoopState} */ addIteration(iteration) { const state = this.load(); if (!state) { throw new Error('No existing state'); } state.iterations.push(iteration); // Merge learnings if (iteration.learnings && iteration.learnings.length > 0) { const newLearnings = iteration.learnings.join('\n'); state.accumulatedLearnings = state.accumulatedLearnings ? `${state.accumulatedLearnings}\n\n### Iteration ${iteration.number}\n${newLearnings}` : `### Iteration ${iteration.number}\n${newLearnings}`; } // Merge files modified if (iteration.filesModified) { const existingFiles = new Set(state.filesModified); for (const file of iteration.filesModified) { existingFiles.add(file); } state.filesModified = Array.from(existingFiles); } this.save(state); return state; } /** * Set current process PID * @param {number|null} pid */ setCurrentPid(pid) { this.update({ currentPid: pid }); } /** * Set loop status * @param {string} status */ setStatus(status) { this.update({ status }); } /** * Get iteration directory path * @param {number} iteration * @returns {string} */ getIterationDir(iteration) { return join(this.stateDir, 'iterations', String(iteration).padStart(3, '0')); } /** * Get prompt file path * @param {number} iteration * @returns {string} */ getPromptPath(iteration) { return join(this.stateDir, 'prompts', `${String(iteration).padStart(3, '0')}-prompt.md`); } /** * Get output file paths * @param {number} iteration * @returns {{stdout: string, stderr: string}} */ getOutputPaths(iteration) { const prefix = String(iteration).padStart(3, '0'); return { stdout: join(this.stateDir, 'outputs', `${prefix}-stdout.log`), stderr: join(this.stateDir, 'outputs', `${prefix}-stderr.log`), }; } /** * Get analysis file path * @param {number} iteration * @returns {string} */ getAnalysisPath(iteration) { return join(this.stateDir, 'analysis', `${String(iteration).padStart(3, '0')}-analysis.json`); } /** * Save analysis result * @param {number} iteration * @param {Object} analysis */ saveAnalysis(iteration, analysis) { const path = this.getAnalysisPath(iteration); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify(analysis, null, 2)); } /** * Clear state (for cleanup/abort) */ clear() { if (existsSync(this.statePath)) { const state = this.load(); if (state) { state.status = 'aborted'; this.save(state); } } } /** * Get state directory path * @returns {string} */ getStateDir() { return this.stateDir; } } // Re-export multi-loop manager for backward compatibility and future use export { ExternalMultiLoopStateManager } from './external-multi-loop-state-manager.mjs'; export default StateManager;