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

818 lines 31.5 kB
/** * V3 Collaborative Issue Claims Service * * Implements ADR-016: Collaborative Issue Claims for Human-Agent Workflows * * Features: * - Issue claiming/releasing for humans and agents * - Handoff mechanisms between humans and agents * - Work stealing for idle agents * - Load balancing across swarm * - GitHub integration * * @see /v3/implementation/adrs/ADR-016-collaborative-issue-claims.md */ import { EventEmitter } from 'events'; import * as fs from 'fs'; import * as path from 'path'; import { execFileSync } from 'child_process'; // ============================================================================ // Default Configuration // ============================================================================ const DEFAULT_CONFIG = { staleThresholdMinutes: 30, blockedThresholdMinutes: 60, overloadThreshold: 5, gracePeriodMinutes: 10, minProgressToProtect: 75, contestWindowMinutes: 5, requireSameType: false, allowCrossTypeSteal: [ ['coder', 'debugger'], ['tester', 'reviewer'], ], }; // ============================================================================ // Claim Service Implementation // ============================================================================ export class ClaimService extends EventEmitter { claims = new Map(); stealableInfo = new Map(); storagePath; config; eventLog = []; constructor(projectRoot, config) { super(); this.storagePath = path.join(projectRoot, '.claude-flow', 'claims'); this.config = { ...DEFAULT_CONFIG, ...config }; } // ========================================================================== // Initialization // ========================================================================== async initialize() { // Ensure storage directory exists if (!fs.existsSync(this.storagePath)) { fs.mkdirSync(this.storagePath, { recursive: true }); } // Load existing claims await this.loadClaims(); } async loadClaims() { const claimsFile = path.join(this.storagePath, 'claims.json'); if (fs.existsSync(claimsFile)) { try { const data = JSON.parse(fs.readFileSync(claimsFile, 'utf-8')); for (const claim of data.claims || []) { claim.claimedAt = new Date(claim.claimedAt); claim.statusChangedAt = new Date(claim.statusChangedAt); if (claim.expiresAt) claim.expiresAt = new Date(claim.expiresAt); this.claims.set(claim.issueId, claim); } } catch { // Start fresh if file is corrupted } } } async saveClaims() { const claimsFile = path.join(this.storagePath, 'claims.json'); const data = { claims: Array.from(this.claims.values()), savedAt: new Date().toISOString(), }; fs.writeFileSync(claimsFile, JSON.stringify(data, null, 2)); } // ========================================================================== // Core Claiming // ========================================================================== async claim(issueId, claimant) { // Check if already claimed const existing = this.claims.get(issueId); if (existing && existing.status !== 'stealable') { return { success: false, error: `Issue ${issueId} is already claimed by ${this.formatClaimant(existing.claimant)}`, }; } const now = new Date(); const claim = { issueId, claimant, claimedAt: now, status: 'active', statusChangedAt: now, progress: 0, }; this.claims.set(issueId, claim); await this.saveClaims(); this.emitEvent({ type: 'issue:claimed', timestamp: now, issueId, claimant, previousClaimant: existing?.claimant, }); return { success: true, claim }; } async release(issueId, claimant) { const claim = this.claims.get(issueId); if (!claim) { throw new Error(`Issue ${issueId} is not claimed`); } if (!this.isSameClaimant(claim.claimant, claimant)) { throw new Error(`Issue ${issueId} is not claimed by ${this.formatClaimant(claimant)}`); } this.claims.delete(issueId); this.stealableInfo.delete(issueId); await this.saveClaims(); this.emitEvent({ type: 'issue:released', timestamp: new Date(), issueId, claimant, }); } // ========================================================================== // Handoffs // ========================================================================== async requestHandoff(issueId, from, to, reason) { const claim = this.claims.get(issueId); if (!claim) { throw new Error(`Issue ${issueId} is not claimed`); } if (!this.isSameClaimant(claim.claimant, from)) { throw new Error(`Issue ${issueId} is not claimed by ${this.formatClaimant(from)}`); } claim.status = 'handoff-pending'; claim.statusChangedAt = new Date(); claim.handoffTo = to; claim.handoffReason = reason; await this.saveClaims(); this.emitEvent({ type: 'issue:handoff:requested', timestamp: new Date(), issueId, claimant: from, data: { to, reason }, }); } async acceptHandoff(issueId, claimant) { const claim = this.claims.get(issueId); if (!claim || claim.status !== 'handoff-pending') { throw new Error(`No pending handoff for issue ${issueId}`); } if (!claim.handoffTo || !this.isSameClaimant(claim.handoffTo, claimant)) { throw new Error(`Handoff not addressed to ${this.formatClaimant(claimant)}`); } const previousClaimant = claim.claimant; claim.claimant = claimant; claim.status = 'active'; claim.statusChangedAt = new Date(); delete claim.handoffTo; delete claim.handoffReason; await this.saveClaims(); this.emitEvent({ type: 'issue:handoff:accepted', timestamp: new Date(), issueId, claimant, previousClaimant, }); } async rejectHandoff(issueId, claimant, reason) { const claim = this.claims.get(issueId); if (!claim || claim.status !== 'handoff-pending') { throw new Error(`No pending handoff for issue ${issueId}`); } if (!claim.handoffTo || !this.isSameClaimant(claim.handoffTo, claimant)) { throw new Error(`Handoff not addressed to ${this.formatClaimant(claimant)}`); } claim.status = 'active'; claim.statusChangedAt = new Date(); delete claim.handoffTo; delete claim.handoffReason; await this.saveClaims(); this.emitEvent({ type: 'issue:handoff:rejected', timestamp: new Date(), issueId, claimant, data: { reason }, }); } // ========================================================================== // Status Updates // ========================================================================== async updateStatus(issueId, status, note) { const claim = this.claims.get(issueId); if (!claim) { throw new Error(`Issue ${issueId} is not claimed`); } const previousStatus = claim.status; claim.status = status; claim.statusChangedAt = new Date(); if (status === 'blocked' && note) { claim.blockReason = note; } if (status === 'completed') { claim.progress = 100; } await this.saveClaims(); this.emitEvent({ type: 'issue:status:changed', timestamp: new Date(), issueId, data: { previousStatus, newStatus: status, note }, }); } async updateProgress(issueId, progress) { const claim = this.claims.get(issueId); if (!claim) { throw new Error(`Issue ${issueId} is not claimed`); } claim.progress = Math.min(100, Math.max(0, progress)); await this.saveClaims(); } async requestReview(issueId, reviewers) { const claim = this.claims.get(issueId); if (!claim) { throw new Error(`Issue ${issueId} is not claimed`); } claim.status = 'review-requested'; claim.statusChangedAt = new Date(); await this.saveClaims(); this.emitEvent({ type: 'issue:review:requested', timestamp: new Date(), issueId, claimant: claim.claimant, data: { reviewers }, }); } // ========================================================================== // Work Stealing // ========================================================================== async markStealable(issueId, info) { const claim = this.claims.get(issueId); if (!claim) { throw new Error(`Issue ${issueId} is not claimed`); } claim.status = 'stealable'; claim.statusChangedAt = new Date(); claim.context = info.context; claim.progress = info.progress; this.stealableInfo.set(issueId, info); await this.saveClaims(); this.emitEvent({ type: 'issue:stealable', timestamp: new Date(), issueId, claimant: claim.claimant, data: { info }, }); } async steal(issueId, stealer) { const claim = this.claims.get(issueId); if (!claim) { return { success: false, error: `Issue ${issueId} is not claimed` }; } if (claim.status !== 'stealable') { return { success: false, error: `Issue ${issueId} is not stealable` }; } const info = this.stealableInfo.get(issueId); const previousOwner = claim.claimant; // Check if steal is allowed if (this.config.requireSameType && stealer.type === 'agent' && previousOwner.type === 'agent') { if (stealer.agentType !== previousOwner.agentType) { const allowed = this.config.allowCrossTypeSteal.some(pair => pair.includes(stealer.agentType) && pair.includes(previousOwner.agentType)); if (!allowed) { return { success: false, error: `Cross-type steal not allowed` }; } } } // Execute steal claim.claimant = stealer; claim.status = 'active'; claim.statusChangedAt = new Date(); claim.claimedAt = new Date(); this.stealableInfo.delete(issueId); await this.saveClaims(); this.emitEvent({ type: 'issue:stolen', timestamp: new Date(), issueId, claimant: stealer, previousClaimant: previousOwner, data: { context: info }, }); return { success: true, claim, previousOwner, context: info }; } async getStealable(agentType) { const stealable = []; for (const claim of this.claims.values()) { if (claim.status !== 'stealable') continue; const info = this.stealableInfo.get(claim.issueId); if (agentType && info?.preferredTypes?.length) { if (!info.preferredTypes.includes(agentType)) continue; } stealable.push(claim); } return stealable; } async contestSteal(issueId, originalClaimant, reason) { const claim = this.claims.get(issueId); if (!claim) { throw new Error(`Issue ${issueId} is not claimed`); } this.emitEvent({ type: 'issue:steal:contested', timestamp: new Date(), issueId, claimant: originalClaimant, data: { reason, currentOwner: claim.claimant }, }); // Contest resolution would typically be handled by a coordinator or human } // ========================================================================== // Load Balancing // ========================================================================== async getAgentLoad(agentId) { const claims = []; let blockedCount = 0; for (const claim of this.claims.values()) { if (claim.claimant.type === 'agent' && claim.claimant.agentId === agentId) { claims.push(claim); if (claim.status === 'blocked') blockedCount++; } } const agentType = claims[0]?.claimant.type === 'agent' ? claims[0].claimant.agentType : 'unknown'; return { agentId, agentType, claimCount: claims.length, maxClaims: this.config.overloadThreshold, utilization: claims.length / this.config.overloadThreshold, claims, avgCompletionTime: 0, // Would need historical data currentBlockedCount: blockedCount, }; } async rebalance(swarmId) { const result = { moved: [], suggested: [] }; // Get all agent loads const agentLoads = new Map(); const agentTypes = new Set(); for (const claim of this.claims.values()) { if (claim.claimant.type !== 'agent') continue; const agentId = claim.claimant.agentId; if (!agentLoads.has(agentId)) { const load = await this.getAgentLoad(agentId); agentLoads.set(agentId, load); agentTypes.add(load.agentType); } } // For each agent type, calculate average load for (const agentType of agentTypes) { const typeLoads = Array.from(agentLoads.values()).filter(l => l.agentType === agentType); const avgLoad = typeLoads.reduce((sum, l) => sum + l.utilization, 0) / typeLoads.length; const overloaded = typeLoads.filter(l => l.utilization > avgLoad * 1.5); const underloaded = typeLoads.filter(l => l.utilization < avgLoad * 0.5); // Generate suggestions for (const over of overloaded) { const lowProgressClaims = over.claims .filter(c => c.progress < 25) .sort((a, b) => a.progress - b.progress); for (const claim of lowProgressClaims) { const target = underloaded.find(u => u.claimCount < u.maxClaims); if (target) { result.suggested.push({ issueId: claim.issueId, currentOwner: claim.claimant, suggestedOwner: { type: 'agent', agentId: target.agentId, agentType: target.agentType, }, reason: 'Load balancing: redistributing work across swarm', }); } } } } return result; } // ========================================================================== // Queries // ========================================================================== async getClaimedBy(claimant) { return Array.from(this.claims.values()).filter(c => this.isSameClaimant(c.claimant, claimant)); } async getAvailableIssues(_filters) { // This would integrate with GitHub API // For now, return issues that are not claimed return []; } async getIssueStatus(issueId) { return this.claims.get(issueId) || null; } async getAllClaims() { return Array.from(this.claims.values()); } async getByStatus(status) { return Array.from(this.claims.values()).filter(c => c.status === status); } // ========================================================================== // Auto-Management // ========================================================================== async expireStale(maxAgeMinutes) { const threshold = maxAgeMinutes ?? this.config.staleThresholdMinutes; const now = Date.now(); const expired = []; for (const claim of this.claims.values()) { if (claim.status === 'stealable' || claim.status === 'completed') continue; const age = (now - claim.statusChangedAt.getTime()) / 60000; if (age > threshold) { // Mark as stealable await this.markStealable(claim.issueId, { reason: 'stale', stealableAt: new Date(), progress: claim.progress, context: `Stale: No activity for ${Math.round(age)} minutes`, }); expired.push(claim); } } return expired; } // ========================================================================== // Helpers // ========================================================================== formatClaimant(claimant) { return claimant.type === 'human' ? `human:${claimant.name}` : `agent:${claimant.agentType}:${claimant.agentId}`; } isSameClaimant(a, b) { if (a.type !== b.type) return false; if (a.type === 'human' && b.type === 'human') { return a.userId === b.userId; } if (a.type === 'agent' && b.type === 'agent') { return a.agentId === b.agentId; } return false; } emitEvent(event) { this.eventLog.push(event); if (this.eventLog.length > 1000) { this.eventLog = this.eventLog.slice(-500); } this.emit(event.type, event); } getEventLog(limit = 100) { return this.eventLog.slice(-limit); } } // ============================================================================ // GitHub Sync Implementation // ============================================================================ const DEFAULT_GITHUB_CONFIG = { enabled: false, syncLabels: true, claimLabel: 'claimed', autoAssign: true, commentOnClaim: true, commentOnRelease: true, }; // ============================================================================ // Input Validation (Security) // ============================================================================ /** * Validate GitHub repository format (owner/repo) * Prevents command injection via malicious repo names */ function isValidRepo(repo) { // owner/repo format: alphanumeric, hyphens, underscores, dots return /^[\w.-]+\/[\w.-]+$/.test(repo) && repo.length <= 100; } /** * Validate issue number (positive integer) */ function isValidIssueNumber(num) { return Number.isInteger(num) && num > 0 && num < 1000000000; } /** * Validate claimant name (GitHub username format) * Prevents command injection via malicious usernames */ function isValidClaimantName(name) { // GitHub usernames: alphanumeric, hyphens, max 39 chars return /^[\w-]+$/.test(name) && name.length >= 1 && name.length <= 39; } /** * Validate label name * Prevents command injection via malicious label names */ function isValidLabel(label) { // Labels: alphanumeric, hyphens, underscores, spaces, max 50 chars return /^[\w\s-]+$/.test(label) && label.length >= 1 && label.length <= 50; } /** * Sanitize error messages to prevent information disclosure */ function sanitizeError(error) { const msg = error.message || 'Unknown error'; // Remove paths and sensitive details return msg.replace(/\/[\w./-]+/g, '[path]').substring(0, 200); } export class GitHubSync { config; claimService; constructor(claimService, config) { this.claimService = claimService; this.config = { ...DEFAULT_GITHUB_CONFIG, ...config }; } /** * Check if GitHub CLI is available */ isGhAvailable() { try { execFileSync('gh', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } } /** * Get the current repository from git remote */ getRepo() { if (this.config.repo) { return isValidRepo(this.config.repo) ? this.config.repo : null; } try { const remote = execFileSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' }).trim(); const match = remote.match(/github\.com[/:]([\w.-]+\/[\w.-]+)/); const repo = match ? match[1].replace('.git', '') : null; return repo && isValidRepo(repo) ? repo : null; } catch { return null; } } /** * Sync issues from GitHub */ async syncIssues(state = 'open') { const errors = []; const issues = []; if (!this.isGhAvailable()) { return { success: false, synced: 0, errors: ['GitHub CLI (gh) not installed'] }; } const repo = this.getRepo(); if (!repo) { return { success: false, synced: 0, errors: ['Could not determine GitHub repository'] }; } // Validate state parameter (whitelist) const validStates = ['open', 'closed', 'all']; if (!validStates.includes(state)) { return { success: false, synced: 0, errors: ['Invalid state parameter'] }; } try { const issuesJson = execFileSync('gh', [ 'issue', 'list', '--repo', repo, '--state', state, '--json', 'number,title,body,state,labels,assignees,url,createdAt,updatedAt', '--limit', '100' ], { encoding: 'utf-8' }); const rawIssues = JSON.parse(issuesJson); for (const raw of rawIssues) { issues.push({ number: raw.number, title: raw.title, body: raw.body || '', state: raw.state === 'OPEN' ? 'open' : 'closed', labels: raw.labels?.map((l) => l.name) || [], assignees: raw.assignees?.map((a) => a.login) || [], url: raw.url, createdAt: new Date(raw.createdAt), updatedAt: new Date(raw.updatedAt), }); } return { success: true, synced: issues.length, errors, issues }; } catch (error) { errors.push(`Failed to fetch issues: ${sanitizeError(error)}`); return { success: false, synced: 0, errors }; } } /** * Sync a local claim to GitHub (add label/assignee/comment) */ async claimOnGitHub(issueNumber, claimant) { const errors = []; if (!this.config.enabled) { return { success: true, synced: 0, errors: ['GitHub sync not enabled'] }; } if (!this.isGhAvailable()) { return { success: false, synced: 0, errors: ['GitHub CLI (gh) not installed'] }; } // Validate issue number if (!isValidIssueNumber(issueNumber)) { return { success: false, synced: 0, errors: ['Invalid issue number'] }; } const repo = this.getRepo(); if (!repo) { return { success: false, synced: 0, errors: ['Could not determine repository'] }; } // Validate claim label if (!isValidLabel(this.config.claimLabel)) { return { success: false, synced: 0, errors: ['Invalid claim label configuration'] }; } try { // Add claim label if (this.config.syncLabels) { try { execFileSync('gh', [ 'issue', 'edit', String(issueNumber), '--repo', repo, '--add-label', this.config.claimLabel ], { stdio: 'ignore' }); } catch { errors.push('Failed to add claim label (label may not exist)'); } } // Auto-assign if human claimant if (this.config.autoAssign && claimant.type === 'human') { if (!isValidClaimantName(claimant.name)) { errors.push('Invalid claimant name format'); } else { try { execFileSync('gh', [ 'issue', 'edit', String(issueNumber), '--repo', repo, '--add-assignee', claimant.name ], { stdio: 'ignore' }); } catch { errors.push('Failed to assign issue'); } } } // Add comment if (this.config.commentOnClaim) { const claimantStr = claimant.type === 'human' ? `@${claimant.name.replace(/[^a-zA-Z0-9_-]/g, '')}` : `Agent: ${(claimant.agentType || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '')}`; const comment = `🤖 **Issue claimed** by ${claimantStr}\n\n_Coordinated by RuFlo V3_`; try { execFileSync('gh', [ 'issue', 'comment', String(issueNumber), '--repo', repo, '--body', comment ], { stdio: 'ignore' }); } catch { errors.push('Failed to add comment'); } } return { success: errors.length === 0, synced: 1, errors }; } catch (error) { errors.push(`GitHub sync failed: ${sanitizeError(error)}`); return { success: false, synced: 0, errors }; } } /** * Release claim on GitHub (remove label/assignee/comment) */ async releaseOnGitHub(issueNumber, claimant) { const errors = []; if (!this.config.enabled) { return { success: true, synced: 0, errors: ['GitHub sync not enabled'] }; } if (!this.isGhAvailable()) { return { success: false, synced: 0, errors: ['GitHub CLI (gh) not installed'] }; } // Validate issue number if (!isValidIssueNumber(issueNumber)) { return { success: false, synced: 0, errors: ['Invalid issue number'] }; } const repo = this.getRepo(); if (!repo) { return { success: false, synced: 0, errors: ['Could not determine repository'] }; } // Validate claim label if (!isValidLabel(this.config.claimLabel)) { return { success: false, synced: 0, errors: ['Invalid claim label configuration'] }; } try { // Remove claim label if (this.config.syncLabels) { try { execFileSync('gh', [ 'issue', 'edit', String(issueNumber), '--repo', repo, '--remove-label', this.config.claimLabel ], { stdio: 'ignore' }); } catch { // Label might not exist } } // Remove assignee if human claimant if (this.config.autoAssign && claimant.type === 'human') { if (isValidClaimantName(claimant.name)) { try { execFileSync('gh', [ 'issue', 'edit', String(issueNumber), '--repo', repo, '--remove-assignee', claimant.name ], { stdio: 'ignore' }); } catch { errors.push('Failed to remove assignee'); } } } // Add release comment if (this.config.commentOnRelease) { const claimantStr = claimant.type === 'human' ? `@${claimant.name.replace(/[^a-zA-Z0-9_-]/g, '')}` : `Agent: ${(claimant.agentType || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '')}`; const comment = `🔓 **Issue released** by ${claimantStr}\n\n_This issue is now available for others to claim._`; try { execFileSync('gh', [ 'issue', 'comment', String(issueNumber), '--repo', repo, '--body', comment ], { stdio: 'ignore' }); } catch { errors.push('Failed to add release comment'); } } return { success: errors.length === 0, synced: 1, errors }; } catch (error) { errors.push(`GitHub release sync failed: ${sanitizeError(error)}`); return { success: false, synced: 0, errors }; } } /** * Bulk sync all local claims to GitHub */ async syncAllClaimsToGitHub() { const errors = []; let synced = 0; const claims = await this.claimService.getAllClaims(); for (const claim of claims) { // Extract issue number from issueId (assumes format like "123" or "issue-123") const issueMatch = claim.issueId.match(/(\d+)/); if (issueMatch) { const result = await this.claimOnGitHub(parseInt(issueMatch[1], 10), claim.claimant); if (result.success) synced++; else errors.push(...result.errors); } } return { success: errors.length === 0, synced, errors }; } /** * Get GitHub issues that are claimed locally */ async getClaimedGitHubIssues() { const syncResult = await this.syncIssues('open'); if (!syncResult.success || !syncResult.issues) return []; const localClaims = await this.claimService.getAllClaims(); const claimedIds = new Set(localClaims.map(c => { const match = c.issueId.match(/(\d+)/); return match ? parseInt(match[1], 10) : null; }).filter(Boolean)); return syncResult.issues.filter(issue => claimedIds.has(issue.number)); } } // ============================================================================ // Factory // ============================================================================ export function createClaimService(projectRoot, config) { return new ClaimService(projectRoot, config); } export function createGitHubSync(claimService, config) { return new GitHubSync(claimService, config); } export default ClaimService; //# sourceMappingURL=claim-service.js.map