Skip to main content
Glama
build-progress-tracker.ts14.6 kB
/** * Build Progress Tracker for TeamCity * Provides real-time build progress monitoring and estimation */ import { EventEmitter } from 'events'; import type { BuildStatusManager, BuildStatusResult } from './build-status-manager'; /** * Progress update event data */ export interface ProgressUpdate extends BuildStatusResult { velocity?: number; // Percentage per second estimatedTimeRemaining?: number; // Seconds isOverdue?: boolean; overdueSeconds?: number; stageDuration?: number; stageProgress?: number; } /** * Options for progress tracking */ export interface ProgressOptions { pollingInterval?: number; // Milliseconds between polls (default: 5000) calculateVelocity?: boolean; // Calculate progress velocity useHistoricalData?: boolean; // Use historical averages for estimation trackStages?: boolean; // Track stage changes calculateStageMetrics?: boolean; // Calculate per-stage metrics includeTests?: boolean; // Include test results in updates includeProblems?: boolean; // Include build problems stallThreshold?: number; // Time without progress before considered stalled (ms) maxRetries?: number; // Maximum retry attempts on error maxDuration?: number; // Maximum tracking duration (ms) } /** * Tracking state for a build */ interface TrackingState { buildId: string; buildTypeId?: string; emitter: EventEmitter; timer?: NodeJS.Timeout; lastUpdate?: BuildStatusResult; lastProgress?: number; lastProgressTime?: Date; currentStage?: string; stageStartTime?: Date; stageStartProgress?: number; pollCount: number; errorCount: number; startTime: Date; options: ProgressOptions; } /** * Stage completion metrics */ export interface StageMetrics { stageName: string; duration: number; // Seconds percentageOfBuild: number; startProgress: number; endProgress: number; } /** * Build Progress Tracker implementation */ export class BuildProgressTracker { private statusManager: BuildStatusManager; private tracking: Map<string, TrackingState>; private historicalAverages: Map<string, number>; // buildTypeId -> average duration constructor(statusManager: BuildStatusManager) { this.statusManager = statusManager; this.tracking = new Map(); this.historicalAverages = new Map(); } /** * Start tracking build progress */ trackBuildProgress(buildId: string, options: ProgressOptions = {}): EventEmitter { // Stop any existing tracking for this build this.stopTracking(buildId); // Set default options const trackingOptions: ProgressOptions = { pollingInterval: 5000, calculateVelocity: false, useHistoricalData: false, trackStages: false, calculateStageMetrics: false, includeTests: false, includeProblems: false, stallThreshold: 30000, maxRetries: 3, ...options, }; // Create tracking state const emitter = new EventEmitter(); const state: TrackingState = { buildId, emitter, pollCount: 0, errorCount: 0, startTime: new Date(), options: trackingOptions, }; this.tracking.set(buildId, state); // Schedule first poll this.schedulePoll(buildId); // Handle max duration if specified if (trackingOptions.maxDuration) { setTimeout(() => { if (this.tracking.has(buildId)) { emitter.emit('stopped', 'maxDurationExceeded'); this.stopTracking(buildId); } }, trackingOptions.maxDuration); } return emitter; } /** * Perform a single poll without scheduling next */ async pollOnce(buildId: string): Promise<BuildStatusResult | null> { const state = this.tracking.get(buildId); if (!state) { // Create temporary state for one-off poll const tempState: TrackingState = { buildId, emitter: new EventEmitter(), pollCount: 0, errorCount: 0, startTime: new Date(), options: {}, }; this.tracking.set(buildId, tempState); try { const result = await this.pollBuildStatus(buildId); this.tracking.delete(buildId); return result; } catch (error) { this.tracking.delete(buildId); throw error; } } return this.pollBuildStatus(buildId); } /** * Stop tracking a specific build */ stopTracking(buildId: string): void { const state = this.tracking.get(buildId); if (state) { if (state.timer) { clearTimeout(state.timer); } state.emitter.emit('stopped', 'manual'); this.tracking.delete(buildId); } } /** * Stop all active tracking */ stopAllTracking(): void { const buildIds = Array.from(this.tracking.keys()); buildIds.forEach((id) => this.stopTracking(id)); } /** * Get list of actively tracked builds */ getActiveTracking(): string[] { return Array.from(this.tracking.keys()); } /** * Get tracking information for a build */ getTrackingInfo(buildId: string): unknown { const state = this.tracking.get(buildId); if (!state) { return null; } return { buildId, isTracking: true, lastUpdate: state.lastUpdate, pollCount: state.pollCount, errorCount: state.errorCount, startTime: state.startTime, currentStage: state.currentStage, }; } /** * Set historical average duration for a build type */ setHistoricalAverage(buildTypeId: string, averageDuration: number): void { this.historicalAverages.set(buildTypeId, averageDuration); } /** * Schedule next poll for a build */ private schedulePoll(buildId: string): void { const state = this.tracking.get(buildId); if (!state) { return; } // Immediate poll for first time, otherwise use interval const delay = state.pollCount === 0 ? 0 : (state.options.pollingInterval ?? 5000); state.timer = setTimeout(async () => { try { await this.pollBuildStatus(buildId); // Schedule next poll if build is still running const currentState = this.tracking.get(buildId); if ( currentState?.lastUpdate && (currentState.lastUpdate.state === 'running' || currentState.lastUpdate.state === 'queued') ) { this.schedulePoll(buildId); } } catch (error) { this.handlePollError(buildId, error as Error); } }, delay); } /** * Poll build status and emit events */ private async pollBuildStatus(buildId: string): Promise<BuildStatusResult | null> { const state = this.tracking.get(buildId); if (!state) { return null; } try { // Get current status const status = await this.statusManager.getBuildStatus({ buildId, includeTests: state.options.includeTests, includeProblems: state.options.includeProblems, forceRefresh: true, }); state.pollCount++; state.errorCount = 0; // Reset error count on success // Store build type ID if available if (status.buildTypeId && !state.buildTypeId) { state.buildTypeId = status.buildTypeId; } // Process the status update const update = this.processStatusUpdate(state, status); // Emit appropriate events this.emitEvents(state, update); // Update state state.lastUpdate = status; // Only update lastProgressTime when progress actually changes if (state.lastProgress !== status.percentageComplete) { state.lastProgress = status.percentageComplete; state.lastProgressTime = new Date(); } else if (state.lastProgress === undefined) { // First time seeing progress state.lastProgress = status.percentageComplete; state.lastProgressTime = new Date(); } return status; } catch (error) { state.errorCount++; state.emitter.emit('error', error); if (state.errorCount >= (state.options.maxRetries ?? 3)) { state.emitter.emit('stopped', 'maxRetriesExceeded'); this.stopTracking(buildId); } else { // Retry this.schedulePoll(buildId); } throw error; } } /** * Process status update and calculate additional metrics */ private processStatusUpdate(state: TrackingState, status: BuildStatusResult): ProgressUpdate { const update: ProgressUpdate = { ...status }; // Calculate velocity if requested if ( state.options.calculateVelocity && state.lastProgress !== undefined && state.lastProgressTime ) { const progressDelta = status.percentageComplete - state.lastProgress; const timeDelta = (new Date().getTime() - state.lastProgressTime.getTime()) / 1000; if (timeDelta > 0 && progressDelta > 0) { update.velocity = progressDelta / timeDelta; // Estimate time remaining based on velocity const remainingProgress = 100 - status.percentageComplete; if (update.velocity > 0) { update.estimatedTimeRemaining = remainingProgress / update.velocity; } } } // Use historical data for estimation if available if (state.options.useHistoricalData && state.buildTypeId) { const historicalAverage = this.historicalAverages.get(state.buildTypeId); if (historicalAverage && !update.estimatedTotalSeconds) { update.estimatedTotalSeconds = historicalAverage; if (status.percentageComplete > 0) { const estimatedElapsed = (historicalAverage * status.percentageComplete) / 100; update.estimatedTimeRemaining = historicalAverage - estimatedElapsed; } } } // Check if build is overdue if (status.estimatedTotalSeconds && status.elapsedSeconds) { if (status.elapsedSeconds > status.estimatedTotalSeconds) { update.isOverdue = true; update.overdueSeconds = status.elapsedSeconds - status.estimatedTotalSeconds; } } // Track stage metrics if (state.options.trackStages && status.currentStageText) { if (status.currentStageText !== state.currentStage) { // Stage changed if (state.currentStage && state.options.calculateStageMetrics) { const stageMetrics = this.calculateStageMetrics(state, status); if (stageMetrics) { state.emitter.emit('stageCompleted', stageMetrics); } } state.currentStage = status.currentStageText; state.stageStartTime = new Date(); state.stageStartProgress = status.percentageComplete; state.emitter.emit('stageChanged', status.currentStageText); } // Calculate current stage progress if (state.stageStartProgress !== undefined) { update.stageProgress = status.percentageComplete - state.stageStartProgress; } if (state.stageStartTime) { update.stageDuration = (new Date().getTime() - state.stageStartTime.getTime()) / 1000; } } return update; } /** * Calculate metrics for completed stage */ private calculateStageMetrics( state: TrackingState, currentStatus: BuildStatusResult ): StageMetrics | null { if ( !state.currentStage || state.stageStartTime === undefined || state.stageStartProgress === undefined ) { return null; } const duration = (new Date().getTime() - state.stageStartTime.getTime()) / 1000; const percentageOfBuild = currentStatus.percentageComplete - state.stageStartProgress; return { stageName: state.currentStage, duration, percentageOfBuild, startProgress: state.stageStartProgress, endProgress: currentStatus.percentageComplete, }; } /** * Emit appropriate events based on status changes */ private emitEvents(state: TrackingState, update: ProgressUpdate): void { const prevState = state.lastUpdate?.state; const currentState = update.state; // Always emit progress update state.emitter.emit('progress', update); // State transition events if (prevState !== currentState) { switch (currentState) { case 'queued': state.emitter.emit('queued', { buildId: update.buildId, queuePosition: update.queuePosition, estimatedStartTime: update.estimatedStartTime, }); break; case 'running': if (prevState === 'queued') { state.emitter.emit('started', { buildId: update.buildId, startDate: update.startDate, }); } break; case 'finished': if (update.status === 'SUCCESS') { state.emitter.emit('completed', { buildId: update.buildId, status: update.status, elapsedSeconds: update.elapsedSeconds, finishDate: update.finishDate, }); } else { state.emitter.emit('failed', { buildId: update.buildId, status: update.status, statusText: update.statusText, failureReason: update.failureReason, }); } this.stopTracking(update.buildId); break; case 'canceled': state.emitter.emit('canceled', { buildId: update.buildId, canceledBy: update.canceledBy, canceledDate: update.canceledDate, }); this.stopTracking(update.buildId); break; } } // Check for stalled build if ( state.options.stallThreshold && currentState === 'running' && state.lastProgress === update.percentageComplete && state.lastProgressTime ) { const timeSinceProgress = new Date().getTime() - state.lastProgressTime.getTime(); if (timeSinceProgress > state.options.stallThreshold) { state.emitter.emit('stalled', { buildId: update.buildId, percentageComplete: update.percentageComplete, timeSinceProgress, }); } } } /** * Handle polling errors */ private handlePollError(buildId: string, error: Error): void { const state = this.tracking.get(buildId); if (!state) { return; } state.errorCount++; state.emitter.emit('error', error); if (state.errorCount >= (state.options.maxRetries ?? 3)) { state.emitter.emit('stopped', 'maxRetriesExceeded'); this.stopTracking(buildId); } else { // Schedule retry this.schedulePoll(buildId); } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Daghis/teamcity-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server