Skip to main content
Glama
build-queue-manager.ts20.9 kB
import { EventEmitter } from 'events'; import type { Build } from '@/teamcity-client/models/build'; import type { ResolvedBuildConfiguration } from './build-configuration-resolver'; import type { ParameterSet } from './build-parameters-manager'; import type { TeamCityClientAdapter } from './client-adapter'; // Extended Build type for queue-specific properties interface QueuedBuildData extends Build { estimatedStartTime?: string; estimatedDuration?: number; properties?: { property?: Array<{ name: string; value: string }> }; } type QueueEntry = Partial<QueuedBuildData>; interface QueueResponse { build?: QueueEntry[]; } export interface QueueBuildOptions { buildConfiguration: ResolvedBuildConfiguration; parameters?: ParameterSet; branch?: string; personal?: boolean; moveToTop?: boolean; comment?: string; dependencies?: { buildId: string; waitForFinish?: boolean; }[]; } export interface QueuedBuild { buildId: string; buildTypeId: string; branchName?: string; queuePosition: number; queuedDate: Date; estimatedStartTime?: Date; estimatedDuration?: number; webUrl: string; personal: boolean; triggeredBy: string; parameters: Record<string, string>; } export interface BuildStatus { buildId: string; state: 'queued' | 'running' | 'finished' | 'failed' | 'canceled'; status?: 'SUCCESS' | 'FAILURE' | 'ERROR' | 'UNKNOWN'; statusText?: string; percentageComplete?: number; currentStageText?: string; elapsedTime?: number; estimatedTotalTime?: number; webUrl: string; startDate?: Date; finishDate?: Date; artifacts?: { count: number; href: string; }; tests?: { count: number; passed: number; failed: number; ignored: number; }; } export interface QueuePosition { buildId: string; position: number; estimatedStartTime?: Date; estimatedWaitTime?: number; canMoveToTop: boolean; blockedBy?: string[]; } export interface QueueLimitations { maxConcurrentBuilds?: number; currentlyRunning: number; queuedBuilds: number; availableAgents: number; personalBuildLimit?: number; userPersonalBuilds?: number; } export class BuildQueueManager extends EventEmitter { private client: TeamCityClientAdapter; private maxRetries: number; private retryDelay: number; private pollingInterval: number; private activeMonitors: Map<string, NodeJS.Timeout>; constructor( client: TeamCityClientAdapter, options?: { maxRetries?: number; retryDelay?: number; pollingInterval?: number; } ) { super(); this.client = client; this.maxRetries = options?.maxRetries ?? 3; this.retryDelay = options?.retryDelay ?? 1000; this.pollingInterval = options?.pollingInterval ?? 5000; this.activeMonitors = new Map(); } async queueBuild(options: QueueBuildOptions): Promise<QueuedBuild> { try { // Validate dependencies for circular references if (options.dependencies?.length) { this.validateDependencies(options.dependencies); } // Check queue limitations const limitations = await this.getQueueLimitations(options.buildConfiguration.id); if ( limitations.maxConcurrentBuilds && limitations.currentlyRunning >= limitations.maxConcurrentBuilds ) { throw new Error(`Maximum concurrent builds (${limitations.maxConcurrentBuilds}) reached`); } if ( options.personal && limitations.personalBuildLimit && limitations.userPersonalBuilds && limitations.userPersonalBuilds >= limitations.personalBuildLimit ) { throw new Error(`Personal build limit (${limitations.personalBuildLimit}) reached`); } // Prepare build request as a partial Build type const buildRequest: Partial<Build> & { buildType: { id: string }; comment?: { text?: string }; properties?: { property: Array<{ name: string; value: string }> }; 'snapshot-dependencies'?: { build: Array<{ id: string }> }; } = { buildType: { id: options.buildConfiguration.id }, branchName: options.branch, personal: options.personal, comment: { text: options.comment }, }; // Add parameters if (options.parameters) { buildRequest.properties = { property: options.parameters.parameters.map((p) => ({ name: p.name, value: p.value, })), }; } // Add dependencies if (options.dependencies?.length) { (buildRequest as Record<string, unknown>)['snapshot-dependencies'] = { build: options.dependencies.map((dep) => ({ id: dep.buildId })), }; } // Queue the build with retry logic const response = await this.retryOperation(async () => { return await this.client.modules.buildQueue.addBuildToQueue( options.moveToTop, buildRequest as Build ); }); const queuedBuild = this.mapToQueuedBuild(response.data as Build); // Move to top if requested if (options.moveToTop) { await this.moveToTop(queuedBuild.buildId); } this.emit('build:queued', queuedBuild); return queuedBuild; } catch (error: unknown) { this.emit('build:error', { error: error instanceof Error ? error.message : 'Unknown error', buildConfiguration: options.buildConfiguration.id, }); throw error; } } async queueBuilds(builds: QueueBuildOptions[]): Promise<QueuedBuild[]> { const results: QueuedBuild[] = []; const errors: Array<{ index: number; error: unknown }> = []; // Queue builds in parallel with concurrency limit const concurrencyLimit = 5; /* eslint-disable no-await-in-loop */ for (let i = 0; i < builds.length; i += concurrencyLimit) { const batch = builds.slice(i, i + concurrencyLimit); const batchPromises = batch.map(async (build, batchIndex) => { const actualIndex = i + batchIndex; try { const result = await this.queueBuild(build); results[actualIndex] = result; } catch (error) { errors.push({ index: actualIndex, error }); } }); await Promise.all(batchPromises); } /* eslint-enable no-await-in-loop */ const successfulResults = results.filter( (result): result is QueuedBuild => result !== undefined ); if (errors.length > 0) { this.emit('batch:partial', { successful: successfulResults, failed: errors, }); } return successfulResults; } async getQueuePosition(buildId: string): Promise<QueuePosition> { try { const response = await this.client.modules.buildQueue.getAllQueuedBuilds(); const queueResponse = (response.data ?? {}) as QueueResponse; const queue = queueResponse.build ?? []; const buildIndex = queue.findIndex((entry) => String(entry?.id ?? '') === buildId); if (buildIndex === -1) { // Check if build is already running const buildResponse = await this.client.builds.getBuild(buildId); const build = (buildResponse.data ?? {}) as Partial<Build>; if (build.state === 'running' || build.state === 'finished') { return { buildId, position: 0, canMoveToTop: false, }; } throw new Error(`Build ${buildId} not found in queue`); } const position = buildIndex + 1; const blockedBy = this.findBlockingBuilds(queue, buildId); return { buildId, position, estimatedStartTime: (() => { const raw = queue[buildIndex]?.estimatedStartTime; return raw ? new Date(raw) : undefined; })(), estimatedWaitTime: queue[buildIndex]?.waitReason?.includes('agent') === true ? this.estimateWaitTime(queue, buildIndex) : undefined, canMoveToTop: position > 1 && blockedBy.length === 0, blockedBy: blockedBy.length > 0 ? blockedBy : undefined, }; } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to get queue position: ${message}`); } } async moveToTop(buildId: string): Promise<QueuePosition> { try { const position = await this.getQueuePosition(buildId); if (!position.canMoveToTop) { if (position.blockedBy?.length) { throw new Error(`Cannot move to top: blocked by builds ${position.blockedBy.join(', ')}`); } if (position.position === 1) { return position; // Already at top } throw new Error('Cannot move build to top of queue'); } // Move build to top via API await this.client.modules.buildQueue.setQueuedBuildsOrder(undefined, { build: [{ id: parseInt(buildId) }], }); // Return updated position return await this.getQueuePosition(buildId); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to move build to top: ${message}`); } } async reorderQueue(buildIds: string[]): Promise<QueuePosition[]> { try { // Validate all builds are in queue and can be reordered const positions = await Promise.all(buildIds.map((id) => this.getQueuePosition(id))); // Check for blocking dependencies // Intentional sequential check; need deterministic fail-fast and message aggregation for (const pos of positions) { if (pos.blockedBy?.length) { throw new Error(`Build ${pos.buildId} is blocked by ${pos.blockedBy.join(', ')}`); } } // Reorder queue await this.client.modules.buildQueue.setQueuedBuildsOrder(undefined, { build: buildIds.map((id) => ({ id: parseInt(id) })), }); // Return updated positions return await Promise.all(buildIds.map((id) => this.getQueuePosition(id))); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to reorder queue: ${message}`); } } async getBuildStatus(buildId: string): Promise<BuildStatus> { try { const response = await this.client.builds.getBuild(buildId); const build = (response.data ?? {}) as Partial<Build> & { artifacts?: { count?: number; href?: string }; testOccurrences?: { count?: number; passed?: number; failed?: number; ignored?: number; }; ['running-info']?: { currentStageText?: string; elapsedSeconds?: number; estimatedTotalSeconds?: number; }; }; const status: BuildStatus = { buildId: String(build.id ?? ''), state: this.mapBuildState(build.state ?? 'queued'), status: build.status as BuildStatus['status'], statusText: build.statusText, percentageComplete: build.percentageComplete, currentStageText: build['running-info']?.currentStageText, elapsedTime: build['running-info']?.elapsedSeconds != null ? build['running-info'].elapsedSeconds * 1000 : undefined, estimatedTotalTime: build['running-info']?.estimatedTotalSeconds != null ? build['running-info'].estimatedTotalSeconds * 1000 : undefined, webUrl: build.webUrl ?? '', startDate: build.startDate != null ? new Date(build.startDate) : undefined, finishDate: build.finishDate != null ? new Date(build.finishDate) : undefined, }; // Add artifacts info if available if (build.artifacts) { status.artifacts = { count: build.artifacts.count ?? 0, href: build.artifacts.href ?? '', }; } // Add test info if available if (build.testOccurrences) { status.tests = { count: build.testOccurrences.count ?? 0, passed: build.testOccurrences.passed ?? 0, failed: build.testOccurrences.failed ?? 0, ignored: build.testOccurrences.ignored ?? 0, }; } return status; } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to get build status: ${message}`); } } async monitorBuild( buildId: string, callback: (status: BuildStatus) => void, options?: { pollInterval?: number; timeout?: number; } ): Promise<void> { const pollInterval = options?.pollInterval ?? this.pollingInterval; const timeout = options?.timeout; const startTime = Date.now(); // Clear existing monitor if any this.stopMonitoring(buildId); const monitor = async () => { try { const status = await this.getBuildStatus(buildId); callback(status); if ( status.state === 'finished' || status.state === 'failed' || status.state === 'canceled' ) { this.stopMonitoring(buildId); this.emit('build:completed', status); return; } if (timeout && Date.now() - startTime > timeout) { this.stopMonitoring(buildId); this.emit('build:timeout', { buildId, timeout }); return; } // Schedule next poll const timeoutId = setTimeout(() => monitor(), pollInterval); this.activeMonitors.set(buildId, timeoutId); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; this.emit('monitor:error', { buildId, error: message }); // Continue monitoring unless explicitly stopped const timeoutId = setTimeout(() => monitor(), pollInterval); this.activeMonitors.set(buildId, timeoutId); } }; // Start monitoring await monitor(); } stopMonitoring(buildId: string): void { const timeoutId = this.activeMonitors.get(buildId); if (timeoutId) { clearTimeout(timeoutId); this.activeMonitors.delete(buildId); this.emit('monitor:stopped', { buildId }); } } stopAllMonitoring(): void { for (const [buildId, timeoutId] of this.activeMonitors) { clearTimeout(timeoutId); this.emit('monitor:stopped', { buildId }); } this.activeMonitors.clear(); } async cancelBuild(buildId: string, comment?: string): Promise<void> { try { await this.client.modules.buildQueue.cancelQueuedBuild(buildId); this.emit('build:canceled', { buildId, comment }); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to cancel build: ${message}`); } } async getQueueLimitations(buildTypeId: string): Promise<QueueLimitations> { try { // Get build type details const buildTypeResponse = await this.client.modules.buildTypes.getBuildType(buildTypeId); const buildType = (buildTypeResponse.data ?? {}) as { settings?: { property?: Array<{ name?: string; value?: string }>; }; }; // Get queue info const queueResponse = await this.client.modules.buildQueue.getAllQueuedBuilds(); const queueData = (queueResponse.data ?? {}) as QueueResponse; const queuedBuilds = (queueData.build ?? []).filter( (entry) => String(entry?.buildTypeId ?? '') === buildTypeId ).length; // Get running builds count const runningBuildsResponse = await this.client.builds.getAllBuilds( `buildType:${buildTypeId},state:running`, 'count' ); const runningBuilds = (runningBuildsResponse.data ?? {}) as { count?: number }; // Get agent pool info const agentsResponse = await this.client.modules.agents.getAllAgents( `compatible:(buildType:${buildTypeId}),enabled:true`, 'count' ); const agents = (agentsResponse.data ?? {}) as { count?: number }; return { maxConcurrentBuilds: (() => { const prop = buildType.settings?.property?.find( (p) => p?.name === 'maximumConcurrentBuilds' ); return prop?.value ? parseInt(prop.value, 10) : undefined; })(), currentlyRunning: runningBuilds.count ?? 0, queuedBuilds, availableAgents: agents.count ?? 0, }; } catch (error: unknown) { // Return basic info if detailed info fails return { currentlyRunning: 0, queuedBuilds: 0, availableAgents: 1, }; } } private validateDependencies(dependencies: Array<{ buildId: string; waitForFinish?: boolean }>) { const seen = new Set<string>(); // Validate linearly; tiny array and readability preferred for (const dep of dependencies) { if (seen.has(dep.buildId)) { throw new Error(`Circular dependency detected: ${dep.buildId} appears multiple times`); } seen.add(dep.buildId); } } private async retryOperation<T>( operation: () => Promise<T>, retries: number = this.maxRetries ): Promise<T> { let lastError: unknown; // Intentional retries loop; each attempt awaits the operation /* eslint-disable no-await-in-loop */ for (let i = 0; i <= retries; i++) { try { return await operation(); } catch (error: unknown) { lastError = error; // Don't retry on client errors (4xx) if (error != null && typeof error === 'object' && 'response' in error) { const httpError = error as { response?: { status?: number } }; if ( httpError.response?.status && httpError.response.status >= 400 && httpError.response.status < 500 ) { throw error; } } if (i < retries) { const delay = this.retryDelay * Math.pow(2, i); // Exponential backoff await new Promise((resolve) => setTimeout(resolve, delay)); const message = error instanceof Error ? error.message : 'Unknown error'; this.emit('retry', { attempt: i + 1, maxRetries: retries, error: message }); } } } /* eslint-enable no-await-in-loop */ throw lastError; } private mapToQueuedBuild(build: Build): QueuedBuild { const queuedBuild = build as QueuedBuildData; return { buildId: String(build.id ?? ''), buildTypeId: build.buildTypeId ?? '', branchName: build.branchName, queuePosition: build.queuePosition ?? 0, queuedDate: new Date(build.queuedDate ?? new Date()), estimatedStartTime: queuedBuild.estimatedStartTime != null ? new Date(queuedBuild.estimatedStartTime) : undefined, estimatedDuration: queuedBuild.estimatedDuration, webUrl: build.webUrl ?? '', personal: build.personal ?? false, triggeredBy: build.triggered?.user?.username ?? 'system', parameters: this.extractParameters(queuedBuild), }; } private extractParameters(build: { properties?: { property?: Array<{ name: string; value: string }> }; }): Record<string, string> { const params: Record<string, string> = {}; if (build.properties?.property) { for (const prop of build.properties.property) { params[prop.name] = prop.value; } } return params; } private mapBuildState(state: string): BuildStatus['state'] { switch (state.toLowerCase()) { case 'queued': return 'queued'; case 'running': return 'running'; case 'finished': return 'finished'; case 'failed': return 'failed'; case 'canceled': return 'canceled'; default: return 'queued'; } } private findBlockingBuilds(queue: QueueEntry[], buildId: string): string[] { const blocking: string[] = []; const build = queue.find((entry) => String(entry?.id ?? '') === buildId); if (!build) { return blocking; } // Check for snapshot dependencies const deps = ( build as { 'snapshot-dependencies'?: { build?: Array<{ id?: string }> }; } )['snapshot-dependencies']; if (deps?.build != null) { for (const dep of deps.build) { if (dep?.id != null && queue.some((entry) => String(entry?.id ?? '') === dep.id)) { blocking.push(dep.id); } } } return blocking; } private estimateWaitTime(_queue: QueueEntry[], buildIndex: number): number { // Simple estimation based on position and average build time // In reality, this would need more sophisticated calculation const averageBuildTime = 5 * 60 * 1000; // 5 minutes default return buildIndex * averageBuildTime; } }

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