From 0033096444e348c10a6a3195993164ffb2ca7e72 Mon Sep 17 00:00:00 2001 From: Manthan Thakar Date: Thu, 19 Feb 2026 00:40:11 -0800 Subject: [PATCH] Add Change Intelligence service integration for CLI Integrate with the Change Intelligence service (github.com/Runbook-Agent/change-intelligence) to surface recent changes during incident triage, correlate changes with affected services, and predict blast radius. The integration is optional and disabled by default. - Add HTTP adapter with inlined types (no new dependencies) - Register 4 agent tools: query_change_events, correlate_changes, predict_blast_radius, get_change_velocity - Gate tools behind providers.changeIntelligence.enabled config (default: false) - Wire correlate_changes into investigation orchestrator triage sources - Add deployment_issues causal query patterns for change correlation - Add tool summarizers for change correlation and event query results - Detect mutations in postToolUse hook and register as change events - Expose query_changes and predict_change_impact via MCP server - Add `runbook changes` CLI subcommands (list, register, correlate, blast-radius, velocity) Co-Authored-By: Claude Opus 4.6 --- src/agent/causal-query.ts | 10 + src/agent/investigation-orchestrator.ts | 5 + src/agent/tool-summarizer.ts | 95 +++++++ src/cli.tsx | 270 +++++++++++++++++++ src/cli/runtime-tools.ts | 10 + src/integrations/hook-handlers.ts | 75 +++++- src/mcp/server.ts | 166 ++++++++++++ src/providers/change-intelligence/adapter.ts | 227 ++++++++++++++++ src/tools/registry.ts | 195 ++++++++++++++ src/utils/config.ts | 7 + 10 files changed, 1057 insertions(+), 3 deletions(-) create mode 100644 src/providers/change-intelligence/adapter.ts diff --git a/src/agent/causal-query.ts b/src/agent/causal-query.ts index 36eef1b..5d39391 100644 --- a/src/agent/causal-query.ts +++ b/src/agent/causal-query.ts @@ -146,6 +146,16 @@ const FAILURE_PATTERNS: Record< deployment_issues: { keywords: ['deploy', 'release', 'rollout', 'version', 'update', 'change'], queries: [ + { + tool: 'correlate_changes', + parameters: { window_minutes: 120 }, + description: 'Correlate recent changes with incident', + }, + { + tool: 'query_change_events', + parameters: { since_minutes: 180, limit: 10 }, + description: 'List recent changes', + }, { tool: 'aws_query', parameters: { services: ['ecs', 'lambda', 'codepipeline'] }, diff --git a/src/agent/investigation-orchestrator.ts b/src/agent/investigation-orchestrator.ts index cbef53a..90dc22a 100644 --- a/src/agent/investigation-orchestrator.ts +++ b/src/agent/investigation-orchestrator.ts @@ -942,6 +942,11 @@ export class InvestigationOrchestrator { // Try to gather some initial context from tools const triageSources: Array<{ tool: string; params: Record; label: string }> = [ + { + tool: 'correlate_changes', + params: { affected_services: [], window_minutes: 120 }, + label: 'Correlated Recent Changes', + }, { tool: 'search_knowledge', params: { diff --git a/src/agent/tool-summarizer.ts b/src/agent/tool-summarizer.ts index 05e7c1f..5f721da 100644 --- a/src/agent/tool-summarizer.ts +++ b/src/agent/tool-summarizer.ts @@ -720,6 +720,99 @@ function summarizeDefault( // Registry of Summarizers // ============================================================================ +function summarizeCorrelateChanges(result: unknown): CompactToolResult { + const resultId = `correlate_changes_${Date.now()}`; + const obj = result as Record | null; + if (!obj || obj.error) { + return { + summary: 'correlate_changes: no results', + highlights: {}, + itemCount: 0, + resultId, + hasErrors: !!obj?.error, + services: [], + healthStatus: 'unknown', + }; + } + const correlations = (obj.correlations || obj) as Array>; + if (!Array.isArray(correlations) || correlations.length === 0) { + return { + summary: 'correlate_changes: no correlated changes found', + highlights: {}, + itemCount: 0, + resultId, + hasErrors: false, + services: [], + healthStatus: 'healthy', + }; + } + const top3 = correlations.slice(0, 3); + const highlights: Record = {}; + const services: string[] = []; + for (const c of top3) { + const event = (c.changeEvent as Record) || c; + const svc = (event.service as string) || 'unknown'; + const score = typeof c.correlationScore === 'number' ? c.correlationScore.toFixed(2) : '?'; + const summary = (event.summary as string) || (event.changeType as string) || 'change'; + highlights[svc] = `score=${score}: ${summary}`; + if (!services.includes(svc)) services.push(svc); + } + return { + summary: `correlate_changes: ${correlations.length} correlated change(s)`, + highlights, + itemCount: correlations.length, + resultId, + hasErrors: false, + services, + healthStatus: 'unknown', + }; +} + +function summarizeQueryChangeEvents(result: unknown): CompactToolResult { + const resultId = `query_change_events_${Date.now()}`; + const events = Array.isArray(result) ? result : []; + if (events.length === 0) { + return { + summary: 'query_change_events: no changes found', + highlights: {}, + itemCount: 0, + resultId, + hasErrors: false, + services: [], + healthStatus: 'healthy', + }; + } + const byType: Record = {}; + const services: string[] = []; + for (const e of events) { + const evt = e as Record; + const ct = (evt.changeType as string) || 'unknown'; + byType[ct] = (byType[ct] || 0) + 1; + const svc = evt.service as string; + if (svc && !services.includes(svc)) services.push(svc); + } + const typeSummary = Object.entries(byType) + .map(([k, v]) => `${k}:${v}`) + .join(', '); + const highlights: Record = { types: typeSummary }; + const deployments = events.filter( + (e: unknown) => (e as Record).changeType === 'deployment' + ); + if (deployments.length > 0) { + const recent = deployments[0] as Record; + highlights.recentDeploy = `${recent.service}: ${recent.summary}`; + } + return { + summary: `query_change_events: ${events.length} change(s) — ${typeSummary}`, + highlights, + itemCount: events.length, + resultId, + hasErrors: false, + services, + healthStatus: 'unknown', + }; +} + const SUMMARIZERS: Record = { aws_query: summarizeAwsQuery, cloudwatch_alarms: summarizeCloudwatchAlarms, @@ -729,6 +822,8 @@ const SUMMARIZERS: Record = { datadog: summarizeDatadog, prometheus: summarizePrometheus, search_knowledge: summarizeKnowledgeSearch, + correlate_changes: summarizeCorrelateChanges, + query_change_events: summarizeQueryChangeEvents, }; // ============================================================================ diff --git a/src/cli.tsx b/src/cli.tsx index 13606a8..25dd852 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -2460,5 +2460,275 @@ checkpoint } ); +// Change Intelligence commands +const changes = program + .command('changes') + .description('Change intelligence — track and correlate changes'); + +changes + .command('list') + .description('List recent change events') + .option('-s, --service ', 'Filter by service') + .option('-t, --type ', 'Filter by change type') + .option('--since ', 'Show changes from last N minutes', '60') + .option('-l, --limit ', 'Maximum results', '20') + .action(async (options: { service?: string; type?: string; since: string; limit: string }) => { + const { createChangeIntelligenceAdapter } = + await import('./providers/change-intelligence/adapter'); + const config = await loadConfig(); + const adapter = createChangeIntelligenceAdapter(config.providers.changeIntelligence); + if (!adapter) { + console.error( + chalk.red( + 'Change Intelligence is not enabled. Set providers.changeIntelligence.enabled: true in config.' + ) + ); + process.exit(1); + } + try { + const since = new Date(Date.now() - parseInt(options.since, 10) * 60_000).toISOString(); + const events = await adapter.queryEvents({ + services: options.service ? [options.service] : undefined, + changeTypes: options.type ? [options.type] : undefined, + since, + limit: parseInt(options.limit, 10), + }); + if (events.length === 0) { + console.log(chalk.yellow('No changes found.')); + return; + } + console.log(chalk.cyan(`\nRecent Changes (${events.length}):\n`)); + for (const event of events) { + const time = new Date(event.timestamp).toLocaleString(); + const risk = event.blastRadius?.riskLevel ? ` [${event.blastRadius.riskLevel}]` : ''; + console.log( + ` ${chalk.green(event.service)} ${chalk.gray(event.changeType)}${chalk.yellow(risk)}` + ); + console.log(` ${event.summary}`); + console.log(` ${chalk.gray(time)} ${chalk.gray(event.status)}`); + console.log(); + } + } catch (error) { + console.error(chalk.red(`Failed: ${error instanceof Error ? error.message : String(error)}`)); + process.exit(1); + } + }); + +changes + .command('register') + .description('Register a change event') + .requiredOption('-s, --service ', 'Service name') + .option('-t, --type ', 'Change type', 'deployment') + .requiredOption('--summary ', 'Change summary') + .option('-e, --environment ', 'Environment', 'production') + .option('--commit ', 'Commit SHA') + .action( + async (options: { + service: string; + type: string; + summary: string; + environment: string; + commit?: string; + }) => { + const { createChangeIntelligenceAdapter } = + await import('./providers/change-intelligence/adapter'); + const config = await loadConfig(); + const adapter = createChangeIntelligenceAdapter(config.providers.changeIntelligence); + if (!adapter) { + console.error(chalk.red('Change Intelligence is not enabled.')); + process.exit(1); + } + try { + const event = await adapter.registerEvent({ + service: options.service, + changeType: options.type, + summary: options.summary, + environment: options.environment, + commitSha: options.commit, + source: 'manual', + initiator: 'human', + }); + console.log(chalk.green(`Change registered: ${event.id}`)); + if (event.blastRadius) { + console.log( + chalk.gray( + ` Blast radius: ${event.blastRadius.riskLevel} — ${event.blastRadius.directServices.length} direct, ${event.blastRadius.downstreamServices.length} downstream` + ) + ); + } + } catch (error) { + console.error( + chalk.red(`Failed: ${error instanceof Error ? error.message : String(error)}`) + ); + process.exit(1); + } + } + ); + +changes + .command('correlate') + .description('Correlate changes with an incident') + .requiredOption('-s, --services ', 'Affected service names') + .option('--time ', 'Incident time (ISO format, defaults to now)') + .option('-w, --window ', 'Time window in minutes', '120') + .action(async (options: { services: string[]; time?: string; window: string }) => { + const { createChangeIntelligenceAdapter } = + await import('./providers/change-intelligence/adapter'); + const config = await loadConfig(); + const adapter = createChangeIntelligenceAdapter(config.providers.changeIntelligence); + if (!adapter) { + console.error(chalk.red('Change Intelligence is not enabled.')); + process.exit(1); + } + try { + const result = await adapter.correlateWithIncident( + options.services, + options.time, + parseInt(options.window, 10) + ); + if (result.correlations.length === 0) { + console.log(chalk.yellow('No correlated changes found.')); + return; + } + console.log(chalk.cyan(`\nCorrelated Changes (${result.correlations.length}):\n`)); + for (const c of result.correlations) { + const score = (c.correlationScore * 100).toFixed(0); + console.log( + ` ${chalk.green(c.changeEvent.service)} ${chalk.yellow(`${score}%`)} — ${c.changeEvent.summary}` + ); + console.log(` ${chalk.gray(c.correlationReasons.slice(0, 3).join('; '))}`); + console.log(); + } + } catch (error) { + console.error(chalk.red(`Failed: ${error instanceof Error ? error.message : String(error)}`)); + process.exit(1); + } + }); + +changes + .command('blast-radius') + .description('Predict blast radius for a change') + .requiredOption('-s, --services ', 'Services being changed') + .option('-t, --type ', 'Change type') + .action(async (options: { services: string[]; type?: string }) => { + const { createChangeIntelligenceAdapter } = + await import('./providers/change-intelligence/adapter'); + const config = await loadConfig(); + const adapter = createChangeIntelligenceAdapter(config.providers.changeIntelligence); + if (!adapter) { + console.error(chalk.red('Change Intelligence is not enabled.')); + process.exit(1); + } + try { + const prediction = await adapter.predictBlastRadius(options.services, options.type); + console.log(chalk.cyan('\nBlast Radius Prediction:\n')); + const riskColors: Record = { + critical: chalk.red, + high: chalk.yellow, + medium: chalk.hex('#FFA500'), + low: chalk.green, + }; + const colorFn = riskColors[prediction.riskLevel] || chalk.gray; + console.log(` Risk Level: ${colorFn(prediction.riskLevel.toUpperCase())}`); + console.log( + ` Critical Path: ${prediction.criticalPathAffected ? chalk.red('YES') : chalk.green('No')}` + ); + if (prediction.directServices.length > 0) { + console.log( + ` Direct (${prediction.directServices.length}): ${prediction.directServices.join(', ')}` + ); + } + if (prediction.downstreamServices.length > 0) { + console.log( + ` Downstream (${prediction.downstreamServices.length}): ${prediction.downstreamServices.join(', ')}` + ); + } + console.log(); + for (const r of prediction.rationale) { + console.log(` ${chalk.gray('•')} ${r}`); + } + console.log(); + } catch (error) { + console.error(chalk.red(`Failed: ${error instanceof Error ? error.message : String(error)}`)); + process.exit(1); + } + }); + +changes + .command('velocity') + .description('Show change velocity for a service') + .requiredOption('-s, --service ', 'Service name') + .option('-w, --window ', 'Time window in minutes', '60') + .option('-p, --periods ', 'Number of periods for trend') + .action(async (options: { service: string; window: string; periods?: string }) => { + const { createChangeIntelligenceAdapter } = + await import('./providers/change-intelligence/adapter'); + const config = await loadConfig(); + const adapter = createChangeIntelligenceAdapter(config.providers.changeIntelligence); + if (!adapter) { + console.error(chalk.red('Change Intelligence is not enabled.')); + process.exit(1); + } + try { + const result = await adapter.getVelocity( + options.service, + parseInt(options.window, 10), + options.periods ? parseInt(options.periods, 10) : undefined + ); + + if ('trend' in result) { + console.log( + chalk.cyan( + `\nChange Velocity Trend for ${options.service} (${(result as { trend: unknown[] }).trend.length} periods):\n` + ) + ); + for (const period of ( + result as { + trend: Array<{ + windowStart: string; + windowEnd: string; + changeCount: number; + changeTypes: Record; + }>; + } + ).trend) { + const start = new Date(period.windowStart).toLocaleString(); + const end = new Date(period.windowEnd).toLocaleString(); + const types = Object.entries(period.changeTypes) + .map(([k, v]) => `${k}:${v}`) + .join(', '); + console.log( + ` ${chalk.gray(start)} → ${chalk.gray(end)}: ${chalk.green(String(period.changeCount))} changes${types ? ` (${types})` : ''}` + ); + } + } else { + const velocity = result as { + changeCount: number; + changeTypes: Record; + averageIntervalMinutes: number; + windowStart: string; + windowEnd: string; + }; + console.log(chalk.cyan(`\nChange Velocity for ${options.service}:\n`)); + console.log(` Changes: ${chalk.green(String(velocity.changeCount))}`); + if (velocity.averageIntervalMinutes > 0) { + console.log( + ` Avg interval: ${chalk.gray(velocity.averageIntervalMinutes.toFixed(1) + ' min')}` + ); + } + const types = Object.entries(velocity.changeTypes) + .map(([k, v]) => `${k}: ${v}`) + .join(', '); + if (types) { + console.log(` By type: ${types}`); + } + } + console.log(); + } catch (error) { + console.error(chalk.red(`Failed: ${error instanceof Error ? error.message : String(error)}`)); + process.exit(1); + } + }); + // Parse and run program.parse(); diff --git a/src/cli/runtime-tools.ts b/src/cli/runtime-tools.ts index 9620515..6da6a30 100644 --- a/src/cli/runtime-tools.ts +++ b/src/cli/runtime-tools.ts @@ -11,6 +11,13 @@ const AWS_TOOLS = new Set([ 'cloudwatch_logs', ]); +const CHANGE_INTELLIGENCE_TOOLS = new Set([ + 'query_change_events', + 'correlate_changes', + 'predict_blast_radius', + 'get_change_velocity', +]); + const CLOUDWATCH_TOOLS = new Set(['cloudwatch_alarms', 'cloudwatch_logs']); /** @@ -63,6 +70,9 @@ export async function getRuntimeTools(config: Config, tools: Tool[]): Promise { - // For now, just log tool usage for future analysis - // This could be expanded to track runbook step execution + // Detect mutation-like tool usage and register as change events + try { + const changeEvent = detectChangeEvent(payload); + if (changeEvent) { + await registerChangeEvent(changeEvent); + } + } catch { + // Fail silently — change intelligence is optional + } return {}; } +function detectChangeEvent(payload: HookPayload): Record | null { + const toolName = payload.tool_name; + const toolInput = payload.tool_input || {}; + + // Detect aws_mutate tool + if (toolName === 'aws_mutate') { + return { + service: (toolInput.service as string) || 'aws', + changeType: 'infra_modification', + source: 'claude_hook', + initiator: 'agent', + summary: `AWS mutation via ${toolInput.action || 'unknown action'} on ${toolInput.service || 'unknown'}`, + environment: 'production', + metadata: { tool: toolName, input: toolInput }, + }; + } + + // Detect deploy-like bash commands + if (toolName === 'bash' || toolName === 'Bash') { + const command = (toolInput.command as string) || ''; + const deployPatterns = [ + /kubectl\s+(apply|rollout|set\s+image)/, + /aws\s+ecs\s+update-service/, + /aws\s+lambda\s+update-function/, + /terraform\s+apply/, + /docker\s+push/, + /helm\s+(install|upgrade)/, + ]; + for (const pattern of deployPatterns) { + if (pattern.test(command)) { + return { + service: 'unknown', + changeType: 'deployment', + source: 'claude_hook', + initiator: 'agent', + summary: `Deploy-like command: ${command.slice(0, 200)}`, + environment: 'production', + metadata: { tool: toolName, command: command.slice(0, 500) }, + }; + } + } + } + + return null; +} + +async function registerChangeEvent(event: Record): Promise { + const baseUrl = process.env.CHANGE_INTEL_URL || 'http://localhost:3001'; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + try { + await fetch(`${baseUrl}/api/v1/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + /** * Hook handler for Stop events * Create checkpoints and trigger learning diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8eb4c93..2d6a21b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -173,6 +173,51 @@ export const MCP_TOOLS: MCPTool[] = [ }, }, }, + { + name: 'query_changes', + description: + 'Query recent change events (deployments, config changes, migrations) from the Change Intelligence Service. Returns a list of changes with metadata.', + inputSchema: { + type: 'object', + properties: { + services: { + type: 'array', + description: 'Filter by service names', + items: { type: 'string', description: 'Service name' }, + }, + since_minutes: { + type: 'number', + description: 'Only show changes from the last N minutes (default: 60)', + default: 60, + }, + limit: { + type: 'number', + description: 'Maximum number of results (default: 10)', + default: 10, + }, + }, + }, + }, + { + name: 'predict_change_impact', + description: + 'Predict the blast radius and risk level of a change using the service dependency graph. Shows directly and transitively affected services.', + inputSchema: { + type: 'object', + properties: { + services: { + type: 'array', + description: 'Services being changed', + items: { type: 'string', description: 'Service name' }, + }, + change_type: { + type: 'string', + description: 'Type of change (deployment, config_change, db_migration, etc.)', + }, + }, + required: ['services'], + }, + }, ]; /** @@ -380,6 +425,123 @@ async function handleListServices( }; } +/** + * Handle query_changes tool call — delegates to Change Intelligence Service + */ +async function handleQueryChanges(args: Record): Promise { + const { createChangeIntelligenceAdapter } = + await import('../providers/change-intelligence/adapter'); + const { loadConfig } = await import('../utils/config'); + + try { + const config = await loadConfig(); + const adapter = createChangeIntelligenceAdapter(config.providers.changeIntelligence); + if (!adapter) { + return { + content: [ + { + type: 'text', + text: 'Change Intelligence Service is not configured. Enable it in .runbook/config.yaml under providers.changeIntelligence.', + }, + ], + }; + } + + const sinceMinutes = typeof args.since_minutes === 'number' ? args.since_minutes : 60; + const since = new Date(Date.now() - sinceMinutes * 60_000).toISOString(); + const services = Array.isArray(args.services) ? args.services.map(String) : undefined; + const limit = typeof args.limit === 'number' ? args.limit : 10; + + const events = await adapter.queryEvents({ services, since, limit }); + + if (events.length === 0) { + return { content: [{ type: 'text', text: 'No recent changes found.' }] }; + } + + const lines = [`## Recent Changes (${events.length})\n`]; + for (const event of events) { + lines.push(`### ${event.summary}`); + lines.push(`- **Service:** ${event.service}`); + lines.push(`- **Type:** ${event.changeType}`); + lines.push(`- **Status:** ${event.status}`); + lines.push(`- **Time:** ${event.timestamp}`); + if (event.commitSha) lines.push(`- **Commit:** ${event.commitSha.slice(0, 8)}`); + lines.push(''); + } + + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error querying changes: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } +} + +/** + * Handle predict_change_impact tool call — delegates to Change Intelligence Service + */ +async function handlePredictChangeImpact( + args: Record +): Promise { + const { createChangeIntelligenceAdapter } = + await import('../providers/change-intelligence/adapter'); + const { loadConfig } = await import('../utils/config'); + + try { + const config = await loadConfig(); + const adapter = createChangeIntelligenceAdapter(config.providers.changeIntelligence); + if (!adapter) { + return { + content: [ + { + type: 'text', + text: 'Change Intelligence Service is not configured. Enable it in .runbook/config.yaml under providers.changeIntelligence.', + }, + ], + }; + } + + const services = Array.isArray(args.services) ? args.services.map(String) : []; + const changeType = typeof args.change_type === 'string' ? args.change_type : undefined; + + const prediction = await adapter.predictBlastRadius(services, changeType); + + const lines = ['## Blast Radius Prediction\n']; + lines.push(`**Risk Level:** ${prediction.riskLevel}`); + lines.push(`**Critical Path Affected:** ${prediction.criticalPathAffected ? 'Yes' : 'No'}`); + if (prediction.directServices.length > 0) { + lines.push(`\n### Direct Dependencies (${prediction.directServices.length})`); + for (const svc of prediction.directServices) lines.push(`- ${svc}`); + } + if (prediction.downstreamServices.length > 0) { + lines.push(`\n### Downstream Services (${prediction.downstreamServices.length})`); + for (const svc of prediction.downstreamServices) lines.push(`- ${svc}`); + } + if (prediction.rationale.length > 0) { + lines.push('\n### Rationale'); + for (const r of prediction.rationale) lines.push(`- ${r}`); + } + + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error predicting impact: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } +} + /** * MCP Server class */ @@ -430,6 +592,10 @@ export class MCPServer { return await handleGetKnowledgeStats(retriever); case 'list_services': return await handleListServices(request.arguments, retriever); + case 'query_changes': + return await handleQueryChanges(request.arguments); + case 'predict_change_impact': + return await handlePredictChangeImpact(request.arguments); default: return { content: [ diff --git a/src/providers/change-intelligence/adapter.ts b/src/providers/change-intelligence/adapter.ts new file mode 100644 index 0000000..25c7764 --- /dev/null +++ b/src/providers/change-intelligence/adapter.ts @@ -0,0 +1,227 @@ +/** + * Change Intelligence Adapter — HTTP client for the Change Intelligence Service + * + * Follows the pattern from src/providers/operability-context/adapters/http.ts. + * Types are inlined to avoid shared-package dependency. + */ + +// --- Inlined types (minimal subset of service types) --- + +export interface ChangeEvent { + id: string; + timestamp: string; + service: string; + additionalServices: string[]; + changeType: string; + source: string; + initiator: string; + initiatorIdentity?: string; + status: string; + environment: string; + commitSha?: string; + prNumber?: string; + prUrl?: string; + repository?: string; + branch?: string; + summary: string; + diff?: string; + filesChanged?: string[]; + configKeys?: string[]; + previousVersion?: string; + newVersion?: string; + blastRadius?: BlastRadiusPrediction; + tags: string[]; + metadata: Record; + createdAt: string; + updatedAt: string; +} + +export interface BlastRadiusPrediction { + directServices: string[]; + downstreamServices: string[]; + criticalPathAffected: boolean; + riskLevel: 'low' | 'medium' | 'high' | 'critical'; + impactPaths: { from: string; to: string; hops: number; criticality: string; path: string[] }[]; + rationale: string[]; +} + +export interface ChangeCorrelation { + changeEvent: ChangeEvent; + correlationScore: number; + correlationReasons: string[]; + serviceOverlap: string[]; + timeDeltaMinutes: number; +} + +export interface ChangeVelocityMetric { + service: string; + windowStart: string; + windowEnd: string; + changeCount: number; + changeTypes: Record; + averageIntervalMinutes: number; +} + +export interface ChangeQueryOptions { + services?: string[]; + changeTypes?: string[]; + sources?: string[]; + environment?: string; + since?: string; + until?: string; + initiator?: string; + status?: string; + query?: string; + limit?: number; +} + +// --- Adapter --- + +export interface ChangeIntelligenceConfig { + enabled: boolean; + baseUrl: string; + apiKey?: string; + timeoutMs?: number; +} + +export class ChangeIntelligenceAdapter { + private baseUrl: string; + private apiKey?: string; + private timeoutMs: number; + + constructor(config: ChangeIntelligenceConfig) { + this.baseUrl = config.baseUrl.replace(/\/+$/, ''); + this.apiKey = config.apiKey; + this.timeoutMs = config.timeoutMs || 5000; + } + + async queryEvents(options: ChangeQueryOptions = {}): Promise { + const params = new URLSearchParams(); + if (options.services?.length) params.set('services', options.services.join(',')); + if (options.changeTypes?.length) params.set('change_types', options.changeTypes.join(',')); + if (options.sources?.length) params.set('sources', options.sources.join(',')); + if (options.environment) params.set('environment', options.environment); + if (options.since) params.set('since', options.since); + if (options.until) params.set('until', options.until); + if (options.initiator) params.set('initiator', options.initiator); + if (options.status) params.set('status', options.status); + if (options.query) params.set('q', options.query); + if (options.limit) params.set('limit', String(options.limit)); + + const qs = params.toString(); + const path = `/api/v1/events${qs ? `?${qs}` : ''}`; + return this.request('GET', path); + } + + async correlateWithIncident( + affectedServices: string[], + incidentTime?: string, + windowMinutes?: number + ): Promise<{ correlations: ChangeCorrelation[] }> { + return this.request<{ correlations: ChangeCorrelation[] }>('POST', '/api/v1/correlate', { + affected_services: affectedServices, + incident_time: incidentTime, + window_minutes: windowMinutes, + }); + } + + async predictBlastRadius( + services: string[], + changeType?: string + ): Promise { + return this.request('POST', '/api/v1/blast-radius', { + services, + change_type: changeType, + }); + } + + async getVelocity( + service: string, + windowMinutes?: number, + periods?: number + ): Promise { + const params = new URLSearchParams(); + if (windowMinutes) params.set('window_minutes', String(windowMinutes)); + if (periods) params.set('periods', String(periods)); + const qs = params.toString(); + return this.request( + 'GET', + `/api/v1/velocity/${encodeURIComponent(service)}${qs ? `?${qs}` : ''}` + ); + } + + async registerEvent( + event: Partial & { service: string; changeType: string; summary: string } + ): Promise { + return this.request('POST', '/api/v1/events', event); + } + + async healthcheck(): Promise<{ status: string }> { + try { + return await this.request<{ status: string }>('GET', '/api/v1/health'); + } catch { + return { status: 'unavailable' }; + } + } + + private async request(method: string, path: string, body?: unknown): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + + const headers: Record = { + Accept: 'application/json', + }; + + if (this.apiKey) { + headers['Authorization'] = `Bearer ${this.apiKey}`; + } + + if (method === 'POST' || method === 'PATCH') { + headers['Content-Type'] = 'application/json'; + } + + try { + const response = await fetch(`${this.baseUrl}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + const text = await response.text().catch(() => ''); + let payload: unknown = null; + if (text.trim()) { + try { + payload = JSON.parse(text); + } catch { + payload = { message: text }; + } + } + + if (!response.ok) { + const msg = + payload && typeof payload === 'object' + ? (payload as Record).error || + (payload as Record).message + : ''; + throw new Error( + `Change Intelligence request failed (${response.status}): ${msg || response.statusText}` + ); + } + + return payload as T; + } finally { + clearTimeout(timeout); + } + } +} + +/** + * Factory: returns adapter or null if disabled + */ +export function createChangeIntelligenceAdapter( + config: ChangeIntelligenceConfig +): ChangeIntelligenceAdapter | null { + if (!config.enabled) return null; + return new ChangeIntelligenceAdapter(config); +} diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 5ce3293..3bbef9b 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -3689,3 +3689,198 @@ toolRegistry.registerCategory('diagram', 'Diagrams & Visualization', [ visualizeMetricsTool, renderMermaidTool, ]); + +// Change Intelligence tools — delegate to Change Intelligence Service via HTTP adapter +import { + createChangeIntelligenceAdapter, + type ChangeIntelligenceAdapter, +} from '../providers/change-intelligence/adapter'; + +let _ciAdapter: ChangeIntelligenceAdapter | null | undefined; + +async function getCIAdapter(): Promise { + if (_ciAdapter !== undefined) return _ciAdapter; + try { + const config = await loadConfig(); + _ciAdapter = createChangeIntelligenceAdapter(config.providers.changeIntelligence); + } catch { + _ciAdapter = null; + } + return _ciAdapter; +} + +const queryChangeEventsTool = defineTool( + 'query_change_events', + `Query recent change events from the Change Intelligence Service. + + Use to find recent deployments, config changes, infra modifications, etc. + Supports filtering by service, change type, environment, time range, and free-text search.`, + { + type: 'object', + properties: { + services: { + type: 'array', + description: 'Filter by service names', + items: { type: 'string' }, + }, + change_types: { + type: 'array', + description: + 'Filter by change types (deployment, config_change, infra_modification, feature_flag, db_migration, rollback, scaling, security_patch)', + items: { type: 'string' }, + }, + environment: { + type: 'string', + description: 'Filter by environment (e.g. production, staging)', + }, + since_minutes: { + type: 'number', + description: 'Only show changes from the last N minutes', + }, + query: { + type: 'string', + description: 'Free-text search across change summaries', + }, + limit: { + type: 'number', + description: 'Maximum number of results (default: 20)', + }, + }, + }, + async (args) => { + const adapter = await getCIAdapter(); + if (!adapter) return { error: 'Change Intelligence Service not configured' }; + try { + const since = + typeof args.since_minutes === 'number' + ? new Date(Date.now() - (args.since_minutes as number) * 60_000).toISOString() + : undefined; + return await adapter.queryEvents({ + services: args.services as string[] | undefined, + changeTypes: args.change_types as string[] | undefined, + environment: args.environment as string | undefined, + since, + query: args.query as string | undefined, + limit: (args.limit as number) || 20, + }); + } catch (error) { + return { error: error instanceof Error ? error.message : 'Query failed' }; + } + } +); + +const correlateChangesTool = defineTool( + 'correlate_changes', + `Correlate recent changes with an incident. Identifies which changes are most likely + related to the affected services using time proximity, service graph analysis, + and change risk scoring.`, + { + type: 'object', + properties: { + affected_services: { + type: 'array', + description: 'Services affected by the incident', + items: { type: 'string' }, + }, + incident_time: { + type: 'string', + description: 'ISO timestamp of the incident (defaults to now)', + }, + window_minutes: { + type: 'number', + description: 'Time window to search for changes (default: 120 minutes)', + }, + }, + required: ['affected_services'], + }, + async (args) => { + const adapter = await getCIAdapter(); + if (!adapter) return { error: 'Change Intelligence Service not configured' }; + try { + return await adapter.correlateWithIncident( + args.affected_services as string[], + args.incident_time as string | undefined, + args.window_minutes as number | undefined + ); + } catch (error) { + return { error: error instanceof Error ? error.message : 'Correlation failed' }; + } + } +); + +const predictBlastRadiusTool = defineTool( + 'predict_blast_radius', + `Predict the blast radius of a change. Uses the service dependency graph to identify + which services will be directly and transitively affected, and assesses risk level.`, + { + type: 'object', + properties: { + services: { + type: 'array', + description: 'Services being changed', + items: { type: 'string' }, + }, + change_type: { + type: 'string', + description: 'Type of change (deployment, config_change, db_migration, etc.)', + }, + }, + required: ['services'], + }, + async (args) => { + const adapter = await getCIAdapter(); + if (!adapter) return { error: 'Change Intelligence Service not configured' }; + try { + return await adapter.predictBlastRadius( + args.services as string[], + args.change_type as string | undefined + ); + } catch (error) { + return { error: error instanceof Error ? error.message : 'Prediction failed' }; + } + } +); + +const getChangeVelocityTool = defineTool( + 'get_change_velocity', + `Get change velocity metrics for a service. Shows how frequently a service + is being changed, which can indicate instability or active development.`, + { + type: 'object', + properties: { + service: { + type: 'string', + description: 'Service name to check velocity for', + }, + window_minutes: { + type: 'number', + description: 'Time window in minutes (default: 60)', + }, + periods: { + type: 'number', + description: 'Number of periods for trend analysis (omit for single window)', + }, + }, + required: ['service'], + }, + async (args) => { + const adapter = await getCIAdapter(); + if (!adapter) return { error: 'Change Intelligence Service not configured' }; + try { + return await adapter.getVelocity( + args.service as string, + args.window_minutes as number | undefined, + args.periods as number | undefined + ); + } catch (error) { + return { error: error instanceof Error ? error.message : 'Velocity query failed' }; + } + } +); + +toolRegistry.registerCategory('change_intelligence', 'Change Intelligence', [ + queryChangeEventsTool, + correlateChangesTool, + predictBlastRadiusTool, + getChangeVelocityTool, +]); diff --git a/src/utils/config.ts b/src/utils/config.ts index 6f88c1a..79bd854 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -197,6 +197,12 @@ const IntegrationsConfigSchema = z.object({ claude: ClaudeIntegrationSchema.default({}), }); +const ChangeIntelligenceConfigSchema = z.object({ + enabled: z.boolean().default(false), + baseUrl: z.string().default('http://localhost:3001'), + apiKey: z.string().optional(), +}); + const ConfigSchema = z.object({ llm: LLMConfigSchema.default({}), providers: z @@ -206,6 +212,7 @@ const ConfigSchema = z.object({ github: GitHubConfigSchema.default({}), gitlab: GitLabConfigSchema.default({}), operabilityContext: OperabilityContextConfigSchema.default({}), + changeIntelligence: ChangeIntelligenceConfigSchema.default({}), }) .default({}), incident: IncidentConfigSchema.default({}),