UNPKG

claude-flow

Version:

Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration

847 lines (723 loc) • 20.7 kB
/** * Convoy Tracker * * Manages convoy lifecycle including creation, modification, progress * tracking, and completion. Convoys are work-order groups that track * related beads (issues) through their lifecycle. * * Features: * - Create and manage convoy groups * - Add/remove beads to convoys * - Track progress and status changes * - Event emission for status transitions * - Integration with bd-bridge for bead operations * * @module gastown-bridge/convoy/tracker */ import { EventEmitter } from 'events'; import { z } from 'zod'; import { v4 as uuidv4 } from 'uuid'; import type { Convoy, ConvoyStatus, ConvoyProgress, CreateConvoyOptions, Bead, BeadStatus, } from '../types.js'; import { ConvoySchema, CreateConvoyOptionsSchema, } from '../types.js'; import { BdBridge, type BeadQuery } from '../bridges/bd-bridge.js'; import { ConvoyError, GasTownErrorCode, wrapError } from '../errors.js'; // ============================================================================ // Types // ============================================================================ /** * Convoy event types */ export type ConvoyEventType = | 'convoy:created' | 'convoy:started' | 'convoy:progressed' | 'convoy:completed' | 'convoy:cancelled' | 'convoy:paused' | 'convoy:resumed' | 'convoy:issue:added' | 'convoy:issue:removed' | 'convoy:issue:updated'; /** * Convoy event payload */ export interface ConvoyEvent { /** Event type */ type: ConvoyEventType; /** Convoy ID */ convoyId: string; /** Convoy name */ convoyName: string; /** Event timestamp */ timestamp: Date; /** Previous status (for status change events) */ previousStatus?: ConvoyStatus; /** Current status */ status: ConvoyStatus; /** Progress at time of event */ progress: ConvoyProgress; /** Issue IDs affected (for issue events) */ issues?: string[]; /** Cancellation reason (for cancelled events) */ reason?: string; /** Additional metadata */ metadata?: Record<string, unknown>; } /** * Convoy tracker configuration */ export interface ConvoyTrackerConfig { /** BD bridge instance for bead operations */ bdBridge: BdBridge; /** Auto-update progress on issue changes */ autoUpdateProgress?: boolean; /** Progress update interval in milliseconds */ progressUpdateInterval?: number; /** Enable persistent storage */ persistConvoys?: boolean; /** Storage path for convoy data */ storagePath?: string; } /** * Internal convoy storage */ interface ConvoyStore { convoy: Convoy; createdAt: Date; updatedAt: Date; metadata: Record<string, unknown>; } /** * Logger interface */ export interface ConvoyLogger { debug: (msg: string, meta?: Record<string, unknown>) => void; info: (msg: string, meta?: Record<string, unknown>) => void; warn: (msg: string, meta?: Record<string, unknown>) => void; error: (msg: string, meta?: Record<string, unknown>) => void; } // ============================================================================ // Default Logger // ============================================================================ const defaultLogger: ConvoyLogger = { debug: (msg, meta) => console.debug(`[convoy-tracker] ${msg}`, meta ?? ''), info: (msg, meta) => console.info(`[convoy-tracker] ${msg}`, meta ?? ''), warn: (msg, meta) => console.warn(`[convoy-tracker] ${msg}`, meta ?? ''), error: (msg, meta) => console.error(`[convoy-tracker] ${msg}`, meta ?? ''), }; // ============================================================================ // Validation Schemas // ============================================================================ /** * Convoy ID schema */ const ConvoyIdSchema = z.string() .uuid('Invalid convoy ID format'); /** * Issue ID array schema */ const IssueIdsSchema = z.array(z.string().min(1)) .min(1, 'At least one issue ID required'); // ============================================================================ // Convoy Tracker Implementation // ============================================================================ /** * Convoy Tracker * * Manages convoy lifecycle and tracks progress of grouped work. * * @example * ```typescript * const tracker = new ConvoyTracker({ * bdBridge: await createBdBridge().initialize(), * }); * * // Create a convoy * const convoy = await tracker.create( * 'Sprint 1', * ['gt-abc12', 'gt-def34', 'gt-ghi56'], * 'First sprint tasks' * ); * * // Monitor progress * tracker.on('convoy:progressed', (event) => { * console.log(`Progress: ${event.progress.closed}/${event.progress.total}`); * }); * * // Check status * const status = await tracker.getStatus(convoy.id); * ``` */ export class ConvoyTracker extends EventEmitter { private bdBridge: BdBridge; private convoys: Map<string, ConvoyStore> = new Map(); private logger: ConvoyLogger; private config: Required<Omit<ConvoyTrackerConfig, 'bdBridge'>>; private progressTimers: Map<string, NodeJS.Timeout> = new Map(); constructor(config: ConvoyTrackerConfig, logger?: ConvoyLogger) { super(); this.bdBridge = config.bdBridge; this.logger = logger ?? defaultLogger; this.config = { autoUpdateProgress: config.autoUpdateProgress ?? true, progressUpdateInterval: config.progressUpdateInterval ?? 30000, persistConvoys: config.persistConvoys ?? false, storagePath: config.storagePath ?? './data/convoys', }; } /** * Create a new convoy * * @param name - Convoy name * @param issues - Issue IDs to include * @param description - Optional description * @returns Created convoy */ async create( name: string, issues: string[], description?: string ): Promise<Convoy> { // Validate inputs const validatedOptions = CreateConvoyOptionsSchema.parse({ name, issues, description, }); // Verify issues exist const verifiedIssues = await this.verifyIssues(validatedOptions.issues); if (verifiedIssues.length === 0) { throw ConvoyError.createFailed('No valid issues found'); } // Calculate initial progress const progress = await this.calculateProgress(verifiedIssues); // Create convoy const convoyId = uuidv4(); const now = new Date(); const convoy: Convoy = { id: convoyId, name: validatedOptions.name, trackedIssues: verifiedIssues, status: 'active', startedAt: now, progress, description: validatedOptions.description, }; // Store convoy this.convoys.set(convoyId, { convoy, createdAt: now, updatedAt: now, metadata: {}, }); // Emit creation event this.emitConvoyEvent('convoy:created', convoy); // Start progress tracking if enabled if (this.config.autoUpdateProgress) { this.startProgressTracking(convoyId); } this.logger.info('Convoy created', { convoyId, name, issueCount: verifiedIssues.length, }); return convoy; } /** * Add issues to an existing convoy * * @param convoyId - Convoy ID * @param issues - Issue IDs to add * @returns Updated convoy */ async addIssues(convoyId: string, issues: string[]): Promise<Convoy> { // Validate inputs ConvoyIdSchema.parse(convoyId); IssueIdsSchema.parse(issues); const store = this.convoys.get(convoyId); if (!store) { throw ConvoyError.notFound(convoyId); } // Verify issues const verifiedIssues = await this.verifyIssues(issues); if (verifiedIssues.length === 0) { throw new ConvoyError( 'No valid issues to add', GasTownErrorCode.CONVOY_CREATE_FAILED, { convoyId, issues } ); } // Add new issues (avoid duplicates) const existingSet = new Set(store.convoy.trackedIssues); const newIssues = verifiedIssues.filter(id => !existingSet.has(id)); if (newIssues.length === 0) { return store.convoy; } // Update convoy const updatedIssues = [...store.convoy.trackedIssues, ...newIssues]; const progress = await this.calculateProgress(updatedIssues); const updatedConvoy: Convoy = { ...store.convoy, trackedIssues: updatedIssues, progress, }; store.convoy = updatedConvoy; store.updatedAt = new Date(); // Emit event this.emitConvoyEvent('convoy:issue:added', updatedConvoy, { issues: newIssues, }); this.logger.info('Issues added to convoy', { convoyId, addedCount: newIssues.length, totalCount: updatedIssues.length, }); return updatedConvoy; } /** * Remove issues from a convoy * * @param convoyId - Convoy ID * @param issues - Issue IDs to remove * @returns Updated convoy */ async removeIssues(convoyId: string, issues: string[]): Promise<Convoy> { // Validate inputs ConvoyIdSchema.parse(convoyId); IssueIdsSchema.parse(issues); const store = this.convoys.get(convoyId); if (!store) { throw ConvoyError.notFound(convoyId); } // Remove issues const removeSet = new Set(issues); const remainingIssues = store.convoy.trackedIssues.filter( id => !removeSet.has(id) ); if (remainingIssues.length === store.convoy.trackedIssues.length) { return store.convoy; } // Recalculate progress const progress = remainingIssues.length > 0 ? await this.calculateProgress(remainingIssues) : { total: 0, closed: 0, inProgress: 0, blocked: 0 }; const updatedConvoy: Convoy = { ...store.convoy, trackedIssues: remainingIssues, progress, }; store.convoy = updatedConvoy; store.updatedAt = new Date(); // Emit event const removedIssues = issues.filter(id => store.convoy.trackedIssues.includes(id) && !remainingIssues.includes(id) ); this.emitConvoyEvent('convoy:issue:removed', updatedConvoy, { issues: removedIssues, }); this.logger.info('Issues removed from convoy', { convoyId, removedCount: removedIssues.length, remainingCount: remainingIssues.length, }); return updatedConvoy; } /** * Get convoy status * * @param convoyId - Convoy ID * @returns Convoy with updated progress */ async getStatus(convoyId: string): Promise<Convoy> { ConvoyIdSchema.parse(convoyId); const store = this.convoys.get(convoyId); if (!store) { throw ConvoyError.notFound(convoyId); } // Refresh progress const progress = await this.calculateProgress(store.convoy.trackedIssues); const previousProgress = store.convoy.progress; // Check if progress changed if ( progress.closed !== previousProgress.closed || progress.inProgress !== previousProgress.inProgress || progress.blocked !== previousProgress.blocked ) { const updatedConvoy: Convoy = { ...store.convoy, progress, }; store.convoy = updatedConvoy; store.updatedAt = new Date(); this.emitConvoyEvent('convoy:progressed', updatedConvoy); } return store.convoy; } /** * Mark convoy as complete * * @param convoyId - Convoy ID * @returns Completed convoy */ async complete(convoyId: string): Promise<Convoy> { ConvoyIdSchema.parse(convoyId); const store = this.convoys.get(convoyId); if (!store) { throw ConvoyError.notFound(convoyId); } if (store.convoy.status === 'landed') { return store.convoy; } const previousStatus = store.convoy.status; const now = new Date(); const completedConvoy: Convoy = { ...store.convoy, status: 'landed', completedAt: now, }; store.convoy = completedConvoy; store.updatedAt = now; // Stop progress tracking this.stopProgressTracking(convoyId); // Emit event this.emitConvoyEvent('convoy:completed', completedConvoy, { previousStatus, }); this.logger.info('Convoy completed', { convoyId, name: completedConvoy.name, duration: now.getTime() - completedConvoy.startedAt.getTime(), }); return completedConvoy; } /** * Cancel a convoy * * @param convoyId - Convoy ID * @param reason - Cancellation reason * @returns Cancelled convoy */ async cancel(convoyId: string, reason?: string): Promise<Convoy> { ConvoyIdSchema.parse(convoyId); const store = this.convoys.get(convoyId); if (!store) { throw ConvoyError.notFound(convoyId); } if (store.convoy.status === 'failed' || store.convoy.status === 'landed') { return store.convoy; } const previousStatus = store.convoy.status; const now = new Date(); const cancelledConvoy: Convoy = { ...store.convoy, status: 'failed', completedAt: now, }; store.convoy = cancelledConvoy; store.updatedAt = now; store.metadata.cancellationReason = reason; // Stop progress tracking this.stopProgressTracking(convoyId); // Emit event this.emitConvoyEvent('convoy:cancelled', cancelledConvoy, { previousStatus, reason, }); this.logger.info('Convoy cancelled', { convoyId, name: cancelledConvoy.name, reason, }); return cancelledConvoy; } /** * Pause a convoy * * @param convoyId - Convoy ID * @returns Paused convoy */ async pause(convoyId: string): Promise<Convoy> { ConvoyIdSchema.parse(convoyId); const store = this.convoys.get(convoyId); if (!store) { throw ConvoyError.notFound(convoyId); } if (store.convoy.status !== 'active') { return store.convoy; } const previousStatus = store.convoy.status; const pausedConvoy: Convoy = { ...store.convoy, status: 'paused', }; store.convoy = pausedConvoy; store.updatedAt = new Date(); // Stop progress tracking while paused this.stopProgressTracking(convoyId); // Emit event this.emitConvoyEvent('convoy:paused', pausedConvoy, { previousStatus, }); this.logger.info('Convoy paused', { convoyId, name: pausedConvoy.name, }); return pausedConvoy; } /** * Resume a paused convoy * * @param convoyId - Convoy ID * @returns Resumed convoy */ async resume(convoyId: string): Promise<Convoy> { ConvoyIdSchema.parse(convoyId); const store = this.convoys.get(convoyId); if (!store) { throw ConvoyError.notFound(convoyId); } if (store.convoy.status !== 'paused') { return store.convoy; } const previousStatus = store.convoy.status; const resumedConvoy: Convoy = { ...store.convoy, status: 'active', }; store.convoy = resumedConvoy; store.updatedAt = new Date(); // Resume progress tracking if (this.config.autoUpdateProgress) { this.startProgressTracking(convoyId); } // Emit event this.emitConvoyEvent('convoy:resumed', resumedConvoy, { previousStatus, }); this.logger.info('Convoy resumed', { convoyId, name: resumedConvoy.name, }); return resumedConvoy; } /** * List all convoys * * @param status - Optional status filter * @returns Array of convoys */ listConvoys(status?: ConvoyStatus): Convoy[] { const convoys = Array.from(this.convoys.values()) .map(store => store.convoy); if (status) { return convoys.filter(convoy => convoy.status === status); } return convoys; } /** * Get convoy by ID * * @param convoyId - Convoy ID * @returns Convoy or undefined */ getConvoy(convoyId: string): Convoy | undefined { return this.convoys.get(convoyId)?.convoy; } /** * Delete a convoy * * @param convoyId - Convoy ID * @returns True if deleted */ deleteConvoy(convoyId: string): boolean { this.stopProgressTracking(convoyId); return this.convoys.delete(convoyId); } /** * Calculate progress for a set of issues */ private async calculateProgress(issueIds: string[]): Promise<ConvoyProgress> { if (issueIds.length === 0) { return { total: 0, closed: 0, inProgress: 0, blocked: 0 }; } try { // Fetch beads const beads = await this.fetchBeads(issueIds); const beadMap = new Map(beads.map(b => [b.id, b])); let closed = 0; let inProgress = 0; let blocked = 0; for (const issueId of issueIds) { const bead = beadMap.get(issueId); if (!bead) continue; if (bead.status === 'closed') { closed++; } else if (bead.status === 'in_progress') { inProgress++; } // Check if blocked if (bead.blockedBy && bead.blockedBy.length > 0) { const hasOpenBlocker = bead.blockedBy.some(blockerId => { const blocker = beadMap.get(blockerId); return blocker && blocker.status !== 'closed'; }); if (hasOpenBlocker) { blocked++; } } } return { total: issueIds.length, closed, inProgress, blocked, }; } catch (error) { this.logger.warn('Failed to calculate progress', { issueIds, error: error instanceof Error ? error.message : String(error), }); return { total: issueIds.length, closed: 0, inProgress: 0, blocked: 0, }; } } /** * Verify issues exist */ private async verifyIssues(issueIds: string[]): Promise<string[]> { const verified: string[] = []; for (const id of issueIds) { try { await this.bdBridge.getBead(id); verified.push(id); } catch { this.logger.warn('Issue not found', { issueId: id }); } } return verified; } /** * Fetch beads by IDs */ private async fetchBeads(issueIds: string[]): Promise<Bead[]> { const beads: Bead[] = []; for (const id of issueIds) { try { const cliBead = await this.bdBridge.getBead(id); // Convert CLI bead to Gas Town bead beads.push({ id: cliBead.id, title: cliBead.content.slice(0, 100), description: cliBead.content, status: this.mapBeadStatus(cliBead.type), priority: 0, labels: cliBead.tags ?? [], createdAt: cliBead.timestamp ? new Date(cliBead.timestamp) : new Date(), updatedAt: new Date(), blockedBy: cliBead.parentId ? [cliBead.parentId] : [], }); } catch { // Skip invalid beads } } return beads; } /** * Map CLI bead type to Gas Town status */ private mapBeadStatus(type: string): BeadStatus { switch (type) { case 'closed': return 'closed'; case 'in_progress': case 'response': case 'code': return 'in_progress'; default: return 'open'; } } /** * Start progress tracking timer */ private startProgressTracking(convoyId: string): void { if (this.progressTimers.has(convoyId)) { return; } const timer = setInterval(async () => { try { await this.getStatus(convoyId); } catch (error) { this.logger.warn('Progress tracking error', { convoyId, error: error instanceof Error ? error.message : String(error), }); } }, this.config.progressUpdateInterval); this.progressTimers.set(convoyId, timer); } /** * Stop progress tracking timer */ private stopProgressTracking(convoyId: string): void { const timer = this.progressTimers.get(convoyId); if (timer) { clearInterval(timer); this.progressTimers.delete(convoyId); } } /** * Emit convoy event */ private emitConvoyEvent( type: ConvoyEventType, convoy: Convoy, extra?: Partial<ConvoyEvent> ): void { const event: ConvoyEvent = { type, convoyId: convoy.id, convoyName: convoy.name, timestamp: new Date(), status: convoy.status, progress: convoy.progress, ...extra, }; this.emit(type, event); this.emit('convoy:*', event); } /** * Clean up resources */ dispose(): void { for (const [convoyId, timer] of this.progressTimers) { clearInterval(timer); } this.progressTimers.clear(); this.removeAllListeners(); } } /** * Create a new convoy tracker instance */ export function createConvoyTracker( config: ConvoyTrackerConfig, logger?: ConvoyLogger ): ConvoyTracker { return new ConvoyTracker(config, logger); } export default ConvoyTracker;