aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
740 lines (649 loc) • 26.1 kB
JavaScript
/**
* Brownfield Handler - Story 12.8
*
* Epic 12: Bob Full Integration — Completando o PRD v2.0
*
* Handles first execution behavior for existing projects without AIOS documentation.
* Executes the brownfield-discovery.yaml workflow to analyze the codebase and
* generate technical debt assessment.
*
* Features:
* - AC1: Detects first execution via EXISTING_NO_DOCS state
* - AC2: Welcome conversation with time estimate (4-8h)
* - AC3: Executes brownfield-discovery.yaml via WorkflowExecutor
* - AC4: Generates system-architecture.md and TECHNICAL-DEBT-REPORT.md
* - AC5: Post-discovery flow: resolve debts or add feature
* - AC6: Idempotent re-execution (update, don't duplicate)
*
* @module core/orchestration/brownfield-handler
* @version 1.0.0
*/
'use strict';
const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
// ═══════════════════════════════════════════════════════════════════════════════════
// BROWNFIELD PHASES
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Brownfield discovery phases
* @enum {string}
*/
const BrownfieldPhase = {
WELCOME: 'welcome',
SYSTEM_DOCUMENTATION: 'system_documentation',
DATABASE_DOCUMENTATION: 'database_documentation',
FRONTEND_DOCUMENTATION: 'frontend_documentation',
INITIAL_CONSOLIDATION: 'initial_consolidation',
DATABASE_REVIEW: 'database_specialist_review',
UX_REVIEW: 'ux_specialist_review',
QA_REVIEW: 'qa_general_review',
FINAL_ASSESSMENT: 'final_assessment',
EXECUTIVE_REPORT: 'executive_awareness_report',
PLANNING: 'epic_creation',
COMPLETE: 'complete',
};
/**
* User decision after discovery
* @enum {string}
*/
const PostDiscoveryChoice = {
RESOLVE_DEBTS: 'resolve_debts',
ADD_FEATURE: 'add_feature',
};
/**
* Phase failure action
* @enum {string}
*/
const PhaseFailureAction = {
RETRY: 'retry',
SKIP: 'skip',
ABORT: 'abort',
};
// ═══════════════════════════════════════════════════════════════════════════════════
// BROWNFIELD HANDLER CLASS
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* BrownfieldHandler - Manages first execution behavior for existing projects
*/
class BrownfieldHandler extends EventEmitter {
/**
* @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
*/
constructor(projectRoot, options = {}) {
super();
if (!projectRoot || typeof projectRoot !== 'string') {
throw new Error('projectRoot is required and must be a string');
}
this.projectRoot = projectRoot;
this.options = {
debug: false,
...options,
};
// Lazy-loaded dependencies
this._workflowExecutor = options.workflowExecutor || null;
this._surfaceChecker = options.surfaceChecker || null;
this._sessionState = options.sessionState || null;
// Workflow path
this.workflowPath = path.join(
projectRoot,
'.aios-core/development/workflows/brownfield-discovery.yaml',
);
// Phase progress tracking
this.phaseProgress = {};
this._log('BrownfieldHandler initialized');
}
// ═══════════════════════════════════════════════════════════════════════════════════
// LAZY DEPENDENCY LOADING
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Get WorkflowExecutor instance
* @private
*/
_getWorkflowExecutor() {
if (!this._workflowExecutor) {
try {
const { WorkflowExecutor } = require('./workflow-executor');
this._workflowExecutor = new WorkflowExecutor(this.projectRoot, {
debug: this.options.debug,
});
} catch (error) {
this._log(`WorkflowExecutor not available: ${error.message}`, 'warn');
}
}
return this._workflowExecutor;
}
/**
* Get SurfaceChecker instance
* @private
*/
_getSurfaceChecker() {
if (!this._surfaceChecker) {
try {
const { SurfaceChecker } = require('./surface-checker');
this._surfaceChecker = new SurfaceChecker();
} catch (error) {
this._log(`SurfaceChecker not available: ${error.message}`, 'warn');
}
}
return this._surfaceChecker;
}
/**
* Get SessionState instance
* @private
*/
_getSessionState() {
if (!this._sessionState) {
try {
const { SessionState } = require('./session-state');
this._sessionState = new SessionState(this.projectRoot, {
debug: this.options.debug,
});
} catch (error) {
this._log(`SessionState not available: ${error.message}`, 'warn');
}
}
return this._sessionState;
}
// ═══════════════════════════════════════════════════════════════════════════════════
// MAIN HANDLER (AC1-6)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Main entry point - handles brownfield discovery flow
*
* @param {Object} context - Execution context
* @param {Object} [context.techStack] - Detected tech stack
* @param {boolean} [context.userAccepted] - Whether user accepted analysis
* @param {string} [context.postDiscoveryChoice] - User choice after discovery
* @returns {Promise<Object>} Handler result
*/
async handle(context = {}) {
this._log('🔍 First execution detected — project has code but no AIOS docs');
// Step 1: Check if user has already accepted (resuming)
if (context.userAccepted === true) {
return this._executeDiscovery(context);
}
// Step 2: Check if this is post-discovery routing
if (context.postDiscoveryChoice) {
return this._handlePostDiscoveryChoice(context.postDiscoveryChoice, context);
}
// Step 3: Present welcome message and ask for acceptance (AC2)
return this._presentWelcomeMessage(context);
}
/**
* Presents welcome message with time estimate (AC2 - PRD §3.2)
*
* @param {Object} context - Execution context
* @returns {Object} Welcome result with surface prompt
* @private
*/
_presentWelcomeMessage(context) {
this._log('Presenting welcome message for first execution');
const welcomeMessage = `Bem-vindo! Percebi que é a primeira vez que trabalho neste projeto.
Posso dar uma olhada no que você tem aqui e configurar tudo para a gente
trabalhar bem. Isso leva entre 4-8 horas dependendo do tamanho do projeto.
Quer que eu comece?`;
// Use SurfaceChecker to determine if we should surface this decision
const surfaceChecker = this._getSurfaceChecker();
const surfaceResult = surfaceChecker
? surfaceChecker.shouldSurface({
valid_options_count: 2,
options_with_tradeoffs: [
'1. Sim — Iniciar análise completa do projeto (4-8 horas)',
'2. Não — Pular análise e usar configurações padrão',
].join('\n'),
})
: { should_surface: true };
return {
action: 'brownfield_welcome',
phase: BrownfieldPhase.WELCOME,
data: {
message: welcomeMessage,
timeEstimate: '4-8 horas',
options: ['accept', 'decline'],
surfaceResult,
context,
},
};
}
/**
* Handles user acceptance/decline of brownfield analysis
*
* @param {boolean} accepted - Whether user accepted
* @param {Object} context - Execution context
* @returns {Promise<Object>} Next step result
*/
async handleUserDecision(accepted, context = {}) {
this._log(`User decision: ${accepted ? 'ACCEPTED' : 'DECLINED'}`);
if (!accepted) {
// User declined - route to existing project handler with defaults
return {
action: 'brownfield_declined',
data: {
message: 'Análise pulada. Usando configurações padrão.',
nextStep: 'existing_project_defaults',
context,
},
};
}
// User accepted - execute discovery workflow
return this._executeDiscovery({ ...context, userAccepted: true });
}
// ═══════════════════════════════════════════════════════════════════════════════════
// DISCOVERY EXECUTION (AC3)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Executes the brownfield-discovery.yaml workflow (AC3)
*
* @param {Object} context - Execution context
* @returns {Promise<Object>} Execution result
* @private
*/
async _executeDiscovery(context) {
this._log('Starting brownfield discovery workflow execution');
const workflowExecutor = this._getWorkflowExecutor();
if (!workflowExecutor) {
return {
action: 'brownfield_error',
error: 'WorkflowExecutor not available',
};
}
// Check if workflow file exists
if (!fs.existsSync(this.workflowPath)) {
return {
action: 'brownfield_error',
error: `Workflow file not found: ${this.workflowPath}`,
};
}
try {
// Record phase in session state
await this._recordPhase(BrownfieldPhase.SYSTEM_DOCUMENTATION, context);
// Execute workflow
const result = await workflowExecutor.executeWorkflow(this.workflowPath, {
projectRoot: this.projectRoot,
techStack: context.techStack || {},
onPhaseStart: (phase) => this._onPhaseStart(phase, context),
onPhaseComplete: (phase, output) => this._onPhaseComplete(phase, output, context),
onPhaseError: (phase, error) => this._onPhaseError(phase, error, context),
});
// Check if workflow completed successfully
if (result.success) {
return this._handleDiscoveryComplete(result, context);
}
// Workflow failed
return {
action: 'brownfield_failed',
data: {
result,
message: 'Brownfield discovery workflow failed',
canRetry: true,
},
};
} catch (error) {
this._log(`Discovery execution error: ${error.message}`, 'error');
return {
action: 'brownfield_error',
error: error.message,
canRetry: true,
};
}
}
/**
* Handles phase start event
* @private
*/
async _onPhaseStart(phase, context) {
this._log(`Phase started: ${phase}`);
this.phaseProgress[phase] = { status: 'in_progress', startTime: Date.now() };
await this._recordPhase(phase, context);
this.emit('phaseStart', { phase, context });
}
/**
* Handles phase complete event
* @private
*/
async _onPhaseComplete(phase, output, context) {
this._log(`Phase completed: ${phase}`);
this.phaseProgress[phase] = {
...this.phaseProgress[phase],
status: 'complete',
endTime: Date.now(),
output,
};
this.emit('phaseComplete', { phase, output, context });
}
/**
* Handles phase error event (AC3 - Task 3.5)
*
* @param {string} phase - Phase that failed
* @param {Error} error - Error that occurred
* @param {Object} context - Execution context
* @returns {Promise<Object>} Error handling result with options
* @private
*/
async _onPhaseError(phase, error, context) {
this._log(`Phase failed: ${phase} - ${error.message}`, 'error');
this.phaseProgress[phase] = {
...this.phaseProgress[phase],
status: 'failed',
endTime: Date.now(),
error: error.message,
};
this.emit('phaseError', { phase, error, context });
// Return failure options for user decision
return {
action: 'phase_failure',
phase,
error: error.message,
options: [
{ action: PhaseFailureAction.RETRY, label: '1. Tentar novamente' },
{ action: PhaseFailureAction.SKIP, label: '2. Pular esta fase' },
{ action: PhaseFailureAction.ABORT, label: '3. Cancelar discovery' },
],
};
}
/**
* Handles phase failure action from user
*
* @param {string} phase - Failed phase
* @param {string} action - User chosen action (retry/skip/abort)
* @param {Object} context - Execution context
* @returns {Promise<Object>} Next step result
*/
async handlePhaseFailureAction(phase, action, context = {}) {
this._log(`Handling phase failure action: ${action} for phase ${phase}`);
switch (action) {
case PhaseFailureAction.RETRY:
this._log(`Retrying phase: ${phase}`);
return { action: 'retry_phase', phase, context };
case PhaseFailureAction.SKIP:
this._log(`Skipping phase: ${phase}`);
this.phaseProgress[phase] = { ...this.phaseProgress[phase], status: 'skipped' };
return { action: 'skip_phase', phase, context };
case PhaseFailureAction.ABORT:
this._log(`Aborting discovery at phase: ${phase}`);
return {
action: 'brownfield_aborted',
data: {
message: 'Discovery cancelado pelo usuário',
lastPhase: phase,
progress: this.phaseProgress,
},
};
default:
return { action: 'invalid_action', error: `Unknown action: ${action}` };
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// POST-DISCOVERY FLOW (AC4, AC5)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Handles discovery complete and presents summary (AC4, AC5)
*
* @param {Object} result - Workflow execution result
* @param {Object} context - Execution context
* @returns {Object} Summary with next step options
* @private
*/
_handleDiscoveryComplete(result, context) {
this._log('Discovery workflow completed successfully');
// Build summary from generated outputs
const summary = this._buildDiscoverySummary();
// Format summary message (PRD §3.2)
const summaryMessage = this._formatSummaryMessage(summary);
// Next step question
const nextStepQuestion =
'Quer que eu monte um plano para resolver os débitos primeiro, ou prefere adicionar uma feature nova?';
return {
action: 'brownfield_complete',
phase: BrownfieldPhase.COMPLETE,
data: {
summary,
summaryMessage,
nextStepQuestion,
options: [
{
choice: PostDiscoveryChoice.RESOLVE_DEBTS,
label: '1. Resolver débitos técnicos',
},
{
choice: PostDiscoveryChoice.ADD_FEATURE,
label: '2. Adicionar feature nova',
},
],
outputs: {
systemArchitecture: 'docs/architecture/system-architecture.md',
technicalDebtReport: 'docs/reports/TECHNICAL-DEBT-REPORT.md',
},
result,
context,
},
};
}
/**
* Builds summary from generated outputs
* @private
*/
_buildDiscoverySummary() {
const summary = {
structureOk: false,
databaseIssues: 0,
testingConfigured: false,
estimatedDebt: 'N/A',
indicators: [],
};
// Check for generated files and extract info
const archPath = path.join(this.projectRoot, 'docs/architecture/system-architecture.md');
const debtReportPath = path.join(this.projectRoot, 'docs/reports/TECHNICAL-DEBT-REPORT.md');
if (fs.existsSync(archPath)) {
summary.structureOk = true;
summary.indicators.push({ type: 'success', message: 'Estrutura de pastas organizada' });
}
if (fs.existsSync(debtReportPath)) {
try {
const reportContent = fs.readFileSync(debtReportPath, 'utf8');
// Extract debt estimate from report (simple regex)
const debtMatch = reportContent.match(/Custo Estimado[:\s]*R\$\s*([\d.,]+)/i);
if (debtMatch) {
summary.estimatedDebt = `R$ ${debtMatch[1]}`;
}
// Count issues
const dbIssuesMatch = reportContent.match(/Database[:\s]*(\d+)\s*(?:issues?|problemas?)/i);
if (dbIssuesMatch) {
summary.databaseIssues = parseInt(dbIssuesMatch[1], 10);
}
// Check testing
if (reportContent.includes('testes configurados') || reportContent.includes('tests configured')) {
summary.testingConfigured = true;
}
} catch {
// Ignore read errors
}
}
// Build indicators based on findings
if (summary.databaseIssues > 0) {
summary.indicators.push({
type: 'warning',
message: `${summary.databaseIssues} problemas de banco de dados`,
});
}
if (!summary.testingConfigured) {
summary.indicators.push({
type: 'warning',
message: 'Sem testes configurados',
});
}
if (summary.estimatedDebt !== 'N/A') {
summary.indicators.push({
type: 'critical',
message: `${summary.estimatedDebt} estimados em débito técnico`,
});
}
return summary;
}
/**
* Formats summary message with indicators (PRD §3.2)
* @private
*/
_formatSummaryMessage(summary) {
const lines = ['Encontrei algumas coisas:'];
for (const indicator of summary.indicators) {
let icon;
switch (indicator.type) {
case 'success':
icon = '✅';
break;
case 'warning':
icon = '⚠️';
break;
case 'critical':
icon = '❌';
break;
default:
icon = '•';
}
lines.push(`- ${icon} ${indicator.message}`);
}
return lines.join('\n');
}
/**
* Handles post-discovery choice (AC5 - Task 2.6, 2.7)
*
* @param {string} choice - User choice (resolve_debts | add_feature)
* @param {Object} context - Execution context
* @returns {Promise<Object>} Routing result
* @private
*/
async _handlePostDiscoveryChoice(choice, context) {
this._log(`Handling post-discovery choice: ${choice}`);
switch (choice) {
case PostDiscoveryChoice.RESOLVE_DEBTS:
// Route to brownfield-create-epic task
return {
action: 'route_to_debt_resolution',
data: {
message: 'Vou criar um plano para resolver os débitos técnicos.',
nextStep: 'brownfield_create_epic',
taskPath: '.aios-core/development/tasks/brownfield-create-epic.md',
context,
},
};
case PostDiscoveryChoice.ADD_FEATURE:
// Route to existing project handler (enhancement workflow)
return {
action: 'route_to_enhancement',
data: {
message: 'Ok! Vamos adicionar uma feature nova.',
nextStep: 'existing_project_enhancement',
context,
},
};
default:
return {
action: 'invalid_choice',
error: `Unknown post-discovery choice: ${choice}`,
};
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// IDEMPOTENCY (AC6)
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Checks if output file exists and prepares for idempotent update (AC6)
*
* @param {string} outputPath - Path to output file
* @returns {Object} Idempotency info
*/
checkIdempotency(outputPath) {
const fullPath = path.join(this.projectRoot, outputPath);
const exists = fs.existsSync(fullPath);
if (exists) {
this._log(`📄 Updating existing ${outputPath} (idempotent re-run)`);
try {
const existingContent = fs.readFileSync(fullPath, 'utf8');
return {
exists: true,
existingContent,
path: fullPath,
};
} catch {
return { exists: true, existingContent: null, path: fullPath };
}
}
return { exists: false, existingContent: null, path: fullPath };
}
/**
* Writes output file idempotently (AC6 - Task 5.3)
*
* @param {string} outputPath - Path to output file
* @param {string} content - Content to write
* @returns {boolean} Success
*/
writeOutputIdempotent(outputPath, content) {
const fullPath = path.join(this.projectRoot, outputPath);
// Ensure directory exists
const dir = path.dirname(fullPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Overwrite (not append) for idempotency
fs.writeFileSync(fullPath, content, 'utf8');
this._log(`📄 Wrote ${outputPath}`);
return true;
}
// ═══════════════════════════════════════════════════════════════════════════════════
// SESSION STATE TRACKING
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Records phase progress in session state (Task 3.4)
*
* @param {string} phase - Current phase
* @param {Object} context - Execution context
* @private
*/
async _recordPhase(phase, context) {
const sessionState = this._getSessionState();
if (!sessionState) {
return;
}
try {
const exists = await sessionState.exists();
if (exists) {
await sessionState.loadSessionState();
await sessionState.recordPhaseChange(`brownfield_${phase}`, 'brownfield-discovery', '@architect');
this._log(`Phase recorded in session state: ${phase}`);
}
} catch (error) {
this._log(`Failed to record phase: ${error.message}`, 'warn');
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// LOGGING
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Debug logger
* @param {string} message - Log message
* @param {string} [level='info'] - Log level
* @private
*/
_log(message, level = 'info') {
if (this.options.debug || level === 'error' || level === 'warn') {
const prefix = level === 'error' ? '❌' : level === 'warn' ? '⚠️' : '🔍';
console.log(`[BrownfieldHandler] ${prefix} ${message}`);
}
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════════════
module.exports = {
BrownfieldHandler,
BrownfieldPhase,
PostDiscoveryChoice,
PhaseFailureAction,
};