diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/PendingChange.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/PendingChange.cs new file mode 100644 index 0000000..61a00d6 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/PendingChange.cs @@ -0,0 +1,260 @@ +using ColaFlow.Shared.Kernel.Common; +using ColaFlow.Modules.Mcp.Domain.Events; +using ColaFlow.Modules.Mcp.Domain.ValueObjects; + +namespace ColaFlow.Modules.Mcp.Domain.Entities; + +/// +/// PendingChange aggregate root - represents a change proposed by an AI agent +/// that requires human approval before being applied to the system +/// +public sealed class PendingChange : AggregateRoot +{ + // Multi-tenant isolation + public Guid TenantId { get; private set; } + + // API Key that created this change + public Guid ApiKeyId { get; private set; } + + // MCP Tool information + public string ToolName { get; private set; } = null!; + + // The diff preview containing the proposed changes + public DiffPreview Diff { get; private set; } = null!; + + // Status and lifecycle + public PendingChangeStatus Status { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime ExpiresAt { get; private set; } + + // Approval tracking + public Guid? ApprovedBy { get; private set; } + public DateTime? ApprovedAt { get; private set; } + + // Rejection tracking + public Guid? RejectedBy { get; private set; } + public DateTime? RejectedAt { get; private set; } + public string? RejectionReason { get; private set; } + + // Application tracking + public DateTime? AppliedAt { get; private set; } + public string? ApplicationResult { get; private set; } + + /// + /// Private constructor for EF Core + /// + private PendingChange() : base() + { + } + + /// + /// Factory method to create a new pending change + /// + /// The MCP tool name that created this change + /// The diff preview showing the proposed changes + /// Tenant ID for multi-tenant isolation + /// API Key ID that authorized this change + /// Hours until the change expires (default: 24) + /// A new PendingChange entity + public static PendingChange Create( + string toolName, + DiffPreview diff, + Guid tenantId, + Guid apiKeyId, + int expirationHours = 24) + { + // Validation + if (string.IsNullOrWhiteSpace(toolName)) + throw new ArgumentException("Tool name cannot be empty", nameof(toolName)); + + if (diff == null) + throw new ArgumentNullException(nameof(diff)); + + if (tenantId == Guid.Empty) + throw new ArgumentException("Tenant ID cannot be empty", nameof(tenantId)); + + if (apiKeyId == Guid.Empty) + throw new ArgumentException("API Key ID cannot be empty", nameof(apiKeyId)); + + if (expirationHours <= 0 || expirationHours > 168) // Max 7 days + throw new ArgumentException( + "Expiration hours must be between 1 and 168 (7 days)", + nameof(expirationHours)); + + var pendingChange = new PendingChange + { + Id = Guid.NewGuid(), + TenantId = tenantId, + ApiKeyId = apiKeyId, + ToolName = toolName, + Diff = diff, + Status = PendingChangeStatus.PendingApproval, + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddHours(expirationHours) + }; + + // Raise domain event + pendingChange.AddDomainEvent(new PendingChangeCreatedEvent( + pendingChange.Id, + toolName, + diff.EntityType, + diff.Operation, + tenantId + )); + + return pendingChange; + } + + /// + /// Approve the pending change + /// + /// User ID who approved the change + public void Approve(Guid approvedBy) + { + // Business rule: Can only approve changes that are pending + if (Status != PendingChangeStatus.PendingApproval) + throw new InvalidOperationException( + $"Cannot approve change with status {Status}. Only PendingApproval changes can be approved."); + + // Business rule: Cannot approve expired changes + if (IsExpired()) + throw new InvalidOperationException( + "Cannot approve an expired change. The change has exceeded its expiration time."); + + if (approvedBy == Guid.Empty) + throw new ArgumentException("Approved by user ID cannot be empty", nameof(approvedBy)); + + Status = PendingChangeStatus.Approved; + ApprovedBy = approvedBy; + ApprovedAt = DateTime.UtcNow; + + // Raise domain event + AddDomainEvent(new PendingChangeApprovedEvent( + Id, + ToolName, + Diff, + approvedBy, + TenantId + )); + } + + /// + /// Reject the pending change + /// + /// User ID who rejected the change + /// Reason for rejection + public void Reject(Guid rejectedBy, string reason) + { + // Business rule: Can only reject changes that are pending + if (Status != PendingChangeStatus.PendingApproval) + throw new InvalidOperationException( + $"Cannot reject change with status {Status}. Only PendingApproval changes can be rejected."); + + if (rejectedBy == Guid.Empty) + throw new ArgumentException("Rejected by user ID cannot be empty", nameof(rejectedBy)); + + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException("Rejection reason cannot be empty", nameof(reason)); + + Status = PendingChangeStatus.Rejected; + RejectedBy = rejectedBy; + RejectedAt = DateTime.UtcNow; + RejectionReason = reason; + + // Raise domain event + AddDomainEvent(new PendingChangeRejectedEvent( + Id, + ToolName, + reason, + rejectedBy, + TenantId + )); + } + + /// + /// Mark the change as expired + /// This is typically called by a background job that checks for expired changes + /// + public void Expire() + { + // Business rule: Can only expire changes that are pending + if (Status != PendingChangeStatus.PendingApproval) + return; // Already processed, nothing to do + + // Business rule: Cannot expire before expiration time + if (!IsExpired()) + throw new InvalidOperationException( + "Cannot expire a change before its expiration time. " + + $"Expiration time: {ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC"); + + Status = PendingChangeStatus.Expired; + + // Raise domain event + AddDomainEvent(new PendingChangeExpiredEvent( + Id, + ToolName, + TenantId + )); + } + + /// + /// Mark the change as applied after successful execution + /// + /// Description of the application result + public void MarkAsApplied(string result) + { + // Business rule: Can only apply approved changes + if (Status != PendingChangeStatus.Approved) + throw new InvalidOperationException( + $"Cannot apply change with status {Status}. Only Approved changes can be applied."); + + if (string.IsNullOrWhiteSpace(result)) + throw new ArgumentException("Application result cannot be empty", nameof(result)); + + Status = PendingChangeStatus.Applied; + AppliedAt = DateTime.UtcNow; + ApplicationResult = result; + + // Raise domain event + AddDomainEvent(new PendingChangeAppliedEvent( + Id, + ToolName, + Diff.EntityType, + Diff.EntityId, + result, + TenantId + )); + } + + /// + /// Check if the change has expired + /// + public bool IsExpired() + { + return DateTime.UtcNow > ExpiresAt; + } + + /// + /// Check if the change can be approved + /// + public bool CanBeApproved() + { + return Status == PendingChangeStatus.PendingApproval && !IsExpired(); + } + + /// + /// Check if the change can be rejected + /// + public bool CanBeRejected() + { + return Status == PendingChangeStatus.PendingApproval; + } + + /// + /// Get a human-readable summary of the change + /// + public string GetSummary() + { + return $"{ToolName}: {Diff.GetSummary()} - {Status}"; + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/TaskLock.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/TaskLock.cs new file mode 100644 index 0000000..715d0f7 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/TaskLock.cs @@ -0,0 +1,261 @@ +using ColaFlow.Shared.Kernel.Common; +using ColaFlow.Modules.Mcp.Domain.Events; +using ColaFlow.Modules.Mcp.Domain.ValueObjects; + +namespace ColaFlow.Modules.Mcp.Domain.Entities; + +/// +/// TaskLock aggregate root - prevents concurrent modifications to the same resource +/// Used to ensure AI agents don't conflict when making changes +/// +public sealed class TaskLock : AggregateRoot +{ + // Multi-tenant isolation + public Guid TenantId { get; private set; } + + // Resource being locked + public string ResourceType { get; private set; } = null!; + public Guid ResourceId { get; private set; } + + // Lock holder information + public string LockHolderType { get; private set; } = null!; // "AI_AGENT" or "USER" + public Guid LockHolderId { get; private set; } // ApiKeyId for AI agents, UserId for users + public string? LockHolderName { get; private set; } // Friendly name for display + + // Lock lifecycle + public TaskLockStatus Status { get; private set; } + public DateTime AcquiredAt { get; private set; } + public DateTime ExpiresAt { get; private set; } + public DateTime? ReleasedAt { get; private set; } + + // Additional context + public string? Purpose { get; private set; } // Optional: why is the lock held? + + /// + /// Private constructor for EF Core + /// + private TaskLock() : base() + { + } + + /// + /// Factory method to acquire a new task lock + /// + /// Type of resource being locked (e.g., "Epic", "Story", "Task") + /// ID of the specific resource + /// Type of lock holder: AI_AGENT or USER + /// ID of the lock holder (ApiKeyId or UserId) + /// Tenant ID for multi-tenant isolation + /// Friendly name of the lock holder + /// Optional purpose description + /// Minutes until lock expires (default: 5) + /// A new TaskLock entity + public static TaskLock Acquire( + string resourceType, + Guid resourceId, + string lockHolderType, + Guid lockHolderId, + Guid tenantId, + string? lockHolderName = null, + string? purpose = null, + int expirationMinutes = 5) + { + // Validation + if (string.IsNullOrWhiteSpace(resourceType)) + throw new ArgumentException("Resource type cannot be empty", nameof(resourceType)); + + if (resourceId == Guid.Empty) + throw new ArgumentException("Resource ID cannot be empty", nameof(resourceId)); + + if (string.IsNullOrWhiteSpace(lockHolderType)) + throw new ArgumentException("Lock holder type cannot be empty", nameof(lockHolderType)); + + lockHolderType = lockHolderType.ToUpperInvariant(); + if (lockHolderType != "AI_AGENT" && lockHolderType != "USER") + throw new ArgumentException( + "Lock holder type must be AI_AGENT or USER", + nameof(lockHolderType)); + + if (lockHolderId == Guid.Empty) + throw new ArgumentException("Lock holder ID cannot be empty", nameof(lockHolderId)); + + if (tenantId == Guid.Empty) + throw new ArgumentException("Tenant ID cannot be empty", nameof(tenantId)); + + if (expirationMinutes <= 0 || expirationMinutes > 60) // Max 1 hour + throw new ArgumentException( + "Expiration minutes must be between 1 and 60", + nameof(expirationMinutes)); + + var taskLock = new TaskLock + { + Id = Guid.NewGuid(), + TenantId = tenantId, + ResourceType = resourceType, + ResourceId = resourceId, + LockHolderType = lockHolderType, + LockHolderId = lockHolderId, + LockHolderName = lockHolderName, + Purpose = purpose, + Status = TaskLockStatus.Active, + AcquiredAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddMinutes(expirationMinutes) + }; + + // Raise domain event + taskLock.AddDomainEvent(new TaskLockAcquiredEvent( + taskLock.Id, + resourceType, + resourceId, + lockHolderType, + lockHolderId, + tenantId + )); + + return taskLock; + } + + /// + /// Release the lock explicitly + /// + public void Release() + { + // Business rule: Can only release active locks + if (Status != TaskLockStatus.Active) + throw new InvalidOperationException( + $"Cannot release lock with status {Status}. Only Active locks can be released."); + + Status = TaskLockStatus.Released; + ReleasedAt = DateTime.UtcNow; + + // Raise domain event + AddDomainEvent(new TaskLockReleasedEvent( + Id, + ResourceType, + ResourceId, + LockHolderType, + LockHolderId, + TenantId + )); + } + + /// + /// Mark the lock as expired + /// This is typically called by a background job or when checking lock validity + /// + public void MarkAsExpired() + { + // Business rule: Can only expire active locks + if (Status != TaskLockStatus.Active) + return; // Already processed, nothing to do + + // Business rule: Cannot expire before expiration time + if (!IsExpired()) + throw new InvalidOperationException( + "Cannot mark lock as expired before its expiration time. " + + $"Expiration time: {ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC"); + + Status = TaskLockStatus.Expired; + + // Raise domain event + AddDomainEvent(new TaskLockExpiredEvent( + Id, + ResourceType, + ResourceId, + LockHolderId, + TenantId + )); + } + + /// + /// Extend the lock expiration time + /// Useful when an operation is taking longer than expected + /// + /// Additional minutes to add to expiration (max 60) + public void ExtendExpiration(int additionalMinutes) + { + // Business rule: Can only extend active locks + if (Status != TaskLockStatus.Active) + throw new InvalidOperationException( + $"Cannot extend lock with status {Status}. Only Active locks can be extended."); + + if (additionalMinutes <= 0 || additionalMinutes > 60) + throw new ArgumentException( + "Additional minutes must be between 1 and 60", + nameof(additionalMinutes)); + + // Don't allow extending beyond 2 hours from acquisition + var maxExpiration = AcquiredAt.AddHours(2); + var newExpiration = ExpiresAt.AddMinutes(additionalMinutes); + + if (newExpiration > maxExpiration) + throw new InvalidOperationException( + "Cannot extend lock beyond 2 hours from acquisition time. " + + "Please release and re-acquire if needed."); + + ExpiresAt = newExpiration; + } + + /// + /// Check if the lock has expired + /// + public bool IsExpired() + { + return DateTime.UtcNow > ExpiresAt; + } + + /// + /// Check if the lock is currently valid (active and not expired) + /// + public bool IsValid() + { + return Status == TaskLockStatus.Active && !IsExpired(); + } + + /// + /// Check if the lock is held by the specified holder + /// + public bool IsHeldBy(Guid holderId) + { + return LockHolderId == holderId && IsValid(); + } + + /// + /// Check if the lock is held by an AI agent + /// + public bool IsHeldByAiAgent() + { + return LockHolderType == "AI_AGENT" && IsValid(); + } + + /// + /// Check if the lock is held by a user + /// + public bool IsHeldByUser() + { + return LockHolderType == "USER" && IsValid(); + } + + /// + /// Get remaining time before lock expiration + /// + public TimeSpan GetRemainingTime() + { + if (!IsValid()) + return TimeSpan.Zero; + + var remaining = ExpiresAt - DateTime.UtcNow; + return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; + } + + /// + /// Get a human-readable summary of the lock + /// + public string GetSummary() + { + var holderName = LockHolderName ?? LockHolderId.ToString(); + var remaining = GetRemainingTime(); + return $"{ResourceType} {ResourceId} locked by {holderName} ({LockHolderType}) - " + + $"{Status} - Remaining: {remaining.TotalMinutes:F1}m"; + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeAppliedEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeAppliedEvent.cs new file mode 100644 index 0000000..345151e --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeAppliedEvent.cs @@ -0,0 +1,15 @@ +using ColaFlow.Shared.Kernel.Events; + +namespace ColaFlow.Modules.Mcp.Domain.Events; + +/// +/// Domain event raised when a pending change is successfully applied +/// +public sealed record PendingChangeAppliedEvent( + Guid PendingChangeId, + string ToolName, + string EntityType, + Guid? EntityId, + string Result, + Guid TenantId +) : DomainEvent; diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeApprovedEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeApprovedEvent.cs new file mode 100644 index 0000000..c90ad81 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeApprovedEvent.cs @@ -0,0 +1,15 @@ +using ColaFlow.Shared.Kernel.Events; +using ColaFlow.Modules.Mcp.Domain.ValueObjects; + +namespace ColaFlow.Modules.Mcp.Domain.Events; + +/// +/// Domain event raised when a pending change is approved +/// +public sealed record PendingChangeApprovedEvent( + Guid PendingChangeId, + string ToolName, + DiffPreview Diff, + Guid ApprovedBy, + Guid TenantId +) : DomainEvent; diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeCreatedEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeCreatedEvent.cs new file mode 100644 index 0000000..596b909 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeCreatedEvent.cs @@ -0,0 +1,14 @@ +using ColaFlow.Shared.Kernel.Events; + +namespace ColaFlow.Modules.Mcp.Domain.Events; + +/// +/// Domain event raised when a pending change is created +/// +public sealed record PendingChangeCreatedEvent( + Guid PendingChangeId, + string ToolName, + string EntityType, + string Operation, + Guid TenantId +) : DomainEvent; diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeExpiredEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeExpiredEvent.cs new file mode 100644 index 0000000..a8e37e7 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeExpiredEvent.cs @@ -0,0 +1,12 @@ +using ColaFlow.Shared.Kernel.Events; + +namespace ColaFlow.Modules.Mcp.Domain.Events; + +/// +/// Domain event raised when a pending change expires +/// +public sealed record PendingChangeExpiredEvent( + Guid PendingChangeId, + string ToolName, + Guid TenantId +) : DomainEvent; diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeRejectedEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeRejectedEvent.cs new file mode 100644 index 0000000..c0e6b56 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeRejectedEvent.cs @@ -0,0 +1,14 @@ +using ColaFlow.Shared.Kernel.Events; + +namespace ColaFlow.Modules.Mcp.Domain.Events; + +/// +/// Domain event raised when a pending change is rejected +/// +public sealed record PendingChangeRejectedEvent( + Guid PendingChangeId, + string ToolName, + string Reason, + Guid RejectedBy, + Guid TenantId +) : DomainEvent; diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockAcquiredEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockAcquiredEvent.cs new file mode 100644 index 0000000..0c02b5d --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockAcquiredEvent.cs @@ -0,0 +1,15 @@ +using ColaFlow.Shared.Kernel.Events; + +namespace ColaFlow.Modules.Mcp.Domain.Events; + +/// +/// Domain event raised when a task lock is acquired +/// +public sealed record TaskLockAcquiredEvent( + Guid LockId, + string ResourceType, + Guid ResourceId, + string LockHolderType, + Guid LockHolderId, + Guid TenantId +) : DomainEvent; diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockExpiredEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockExpiredEvent.cs new file mode 100644 index 0000000..e23adfd --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockExpiredEvent.cs @@ -0,0 +1,14 @@ +using ColaFlow.Shared.Kernel.Events; + +namespace ColaFlow.Modules.Mcp.Domain.Events; + +/// +/// Domain event raised when a task lock expires +/// +public sealed record TaskLockExpiredEvent( + Guid LockId, + string ResourceType, + Guid ResourceId, + Guid LockHolderId, + Guid TenantId +) : DomainEvent; diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockReleasedEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockReleasedEvent.cs new file mode 100644 index 0000000..8a463e2 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockReleasedEvent.cs @@ -0,0 +1,15 @@ +using ColaFlow.Shared.Kernel.Events; + +namespace ColaFlow.Modules.Mcp.Domain.Events; + +/// +/// Domain event raised when a task lock is released +/// +public sealed record TaskLockReleasedEvent( + Guid LockId, + string ResourceType, + Guid ResourceId, + string LockHolderType, + Guid LockHolderId, + Guid TenantId +) : DomainEvent; diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/IPendingChangeRepository.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/IPendingChangeRepository.cs new file mode 100644 index 0000000..6801f8a --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/IPendingChangeRepository.cs @@ -0,0 +1,81 @@ +using ColaFlow.Modules.Mcp.Domain.Entities; +using ColaFlow.Modules.Mcp.Domain.ValueObjects; + +namespace ColaFlow.Modules.Mcp.Domain.Repositories; + +/// +/// Repository interface for PendingChange aggregate root +/// +public interface IPendingChangeRepository +{ + /// + /// Get a pending change by ID + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Get all pending changes for a tenant + /// + Task> GetByTenantAsync( + Guid tenantId, + CancellationToken cancellationToken = default); + + /// + /// Get pending changes by status + /// + Task> GetByStatusAsync( + Guid tenantId, + PendingChangeStatus status, + CancellationToken cancellationToken = default); + + /// + /// Get expired pending changes (still in PendingApproval status but past expiration time) + /// + Task> GetExpiredAsync( + CancellationToken cancellationToken = default); + + /// + /// Get pending changes by API key + /// + Task> GetByApiKeyAsync( + Guid apiKeyId, + CancellationToken cancellationToken = default); + + /// + /// Get pending changes for a specific entity + /// + Task> GetByEntityAsync( + Guid tenantId, + string entityType, + Guid entityId, + CancellationToken cancellationToken = default); + + /// + /// Check if there are any pending changes for a specific entity + /// + Task HasPendingChangesForEntityAsync( + Guid tenantId, + string entityType, + Guid entityId, + CancellationToken cancellationToken = default); + + /// + /// Add a new pending change + /// + Task AddAsync(PendingChange pendingChange, CancellationToken cancellationToken = default); + + /// + /// Update an existing pending change + /// + Task UpdateAsync(PendingChange pendingChange, CancellationToken cancellationToken = default); + + /// + /// Delete a pending change + /// + Task DeleteAsync(PendingChange pendingChange, CancellationToken cancellationToken = default); + + /// + /// Save changes to the database + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/ITaskLockRepository.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/ITaskLockRepository.cs new file mode 100644 index 0000000..a815415 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/ITaskLockRepository.cs @@ -0,0 +1,92 @@ +using ColaFlow.Modules.Mcp.Domain.Entities; +using ColaFlow.Modules.Mcp.Domain.ValueObjects; + +namespace ColaFlow.Modules.Mcp.Domain.Repositories; + +/// +/// Repository interface for TaskLock aggregate root +/// +public interface ITaskLockRepository +{ + /// + /// Get a task lock by ID + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Get all task locks for a tenant + /// + Task> GetByTenantAsync( + Guid tenantId, + CancellationToken cancellationToken = default); + + /// + /// Get active lock for a specific resource (if any) + /// + Task GetActiveLockForResourceAsync( + Guid tenantId, + string resourceType, + Guid resourceId, + CancellationToken cancellationToken = default); + + /// + /// Get all locks held by a specific holder + /// + Task> GetByLockHolderAsync( + Guid tenantId, + Guid lockHolderId, + CancellationToken cancellationToken = default); + + /// + /// Get locks by status + /// + Task> GetByStatusAsync( + Guid tenantId, + TaskLockStatus status, + CancellationToken cancellationToken = default); + + /// + /// Get expired locks (still in Active status but past expiration time) + /// + Task> GetExpiredAsync( + CancellationToken cancellationToken = default); + + /// + /// Check if a resource is currently locked + /// + Task IsResourceLockedAsync( + Guid tenantId, + string resourceType, + Guid resourceId, + CancellationToken cancellationToken = default); + + /// + /// Check if a resource is locked by a specific holder + /// + Task IsResourceLockedByAsync( + Guid tenantId, + string resourceType, + Guid resourceId, + Guid lockHolderId, + CancellationToken cancellationToken = default); + + /// + /// Add a new task lock + /// + Task AddAsync(TaskLock taskLock, CancellationToken cancellationToken = default); + + /// + /// Update an existing task lock + /// + Task UpdateAsync(TaskLock taskLock, CancellationToken cancellationToken = default); + + /// + /// Delete a task lock + /// + Task DeleteAsync(TaskLock taskLock, CancellationToken cancellationToken = default); + + /// + /// Save changes to the database + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/DiffPreviewService.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/DiffPreviewService.cs new file mode 100644 index 0000000..fa7c097 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/DiffPreviewService.cs @@ -0,0 +1,211 @@ +using System.Text.Json; +using ColaFlow.Modules.Mcp.Domain.ValueObjects; + +namespace ColaFlow.Modules.Mcp.Domain.Services; + +/// +/// Domain service for creating and comparing diff previews +/// +public sealed class DiffPreviewService +{ + /// + /// Generate a diff preview for a CREATE operation + /// + public DiffPreview GenerateCreateDiff( + string entityType, + T afterEntity, + string? entityKey = null) where T : class + { + if (afterEntity == null) + throw new ArgumentNullException(nameof(afterEntity)); + + var afterData = JsonSerializer.Serialize(afterEntity, new JsonSerializerOptions + { + WriteIndented = true + }); + + return DiffPreview.ForCreate( + entityType: entityType, + afterData: afterData, + entityKey: entityKey + ); + } + + /// + /// Generate a diff preview for a DELETE operation + /// + public DiffPreview GenerateDeleteDiff( + string entityType, + Guid entityId, + T beforeEntity, + string? entityKey = null) where T : class + { + if (beforeEntity == null) + throw new ArgumentNullException(nameof(beforeEntity)); + + var beforeData = JsonSerializer.Serialize(beforeEntity, new JsonSerializerOptions + { + WriteIndented = true + }); + + return DiffPreview.ForDelete( + entityType: entityType, + entityId: entityId, + beforeData: beforeData, + entityKey: entityKey + ); + } + + /// + /// Generate a diff preview for an UPDATE operation by comparing two objects + /// + public DiffPreview GenerateUpdateDiff( + string entityType, + Guid entityId, + T beforeEntity, + T afterEntity, + string? entityKey = null) where T : class + { + if (beforeEntity == null) + throw new ArgumentNullException(nameof(beforeEntity)); + + if (afterEntity == null) + throw new ArgumentNullException(nameof(afterEntity)); + + // Serialize both entities + var beforeData = JsonSerializer.Serialize(beforeEntity, new JsonSerializerOptions + { + WriteIndented = true + }); + + var afterData = JsonSerializer.Serialize(afterEntity, new JsonSerializerOptions + { + WriteIndented = true + }); + + // Compare and find changed fields + var changedFields = CompareObjects(beforeEntity, afterEntity); + + if (changedFields.Count == 0) + throw new InvalidOperationException( + "No fields have changed. UPDATE operation requires at least one changed field."); + + return DiffPreview.ForUpdate( + entityType: entityType, + entityId: entityId, + beforeData: beforeData, + afterData: afterData, + changedFields: changedFields.AsReadOnly(), + entityKey: entityKey + ); + } + + /// + /// Compare two objects and return list of changed fields + /// Uses reflection to compare public properties + /// + private List CompareObjects(T before, T after) where T : class + { + var changedFields = new List(); + var type = typeof(T); + var properties = type.GetProperties(); + + foreach (var property in properties) + { + // Skip non-readable properties + if (!property.CanRead) + continue; + + // Skip indexed properties + if (property.GetIndexParameters().Length > 0) + continue; + + var oldValue = property.GetValue(before); + var newValue = property.GetValue(after); + + // Check if values are different + if (!AreValuesEqual(oldValue, newValue)) + { + changedFields.Add(new DiffField( + fieldName: property.Name, + displayName: FormatDisplayName(property.Name), + oldValue: oldValue, + newValue: newValue + )); + } + } + + return changedFields; + } + + /// + /// Compare two values for equality + /// Handles nulls and uses Equals method + /// + private bool AreValuesEqual(object? oldValue, object? newValue) + { + if (oldValue == null && newValue == null) + return true; + + if (oldValue == null || newValue == null) + return false; + + return oldValue.Equals(newValue); + } + + /// + /// Format property name to display name + /// Example: "FirstName" -> "First Name" + /// + private string FormatDisplayName(string propertyName) + { + if (string.IsNullOrWhiteSpace(propertyName)) + return propertyName; + + // Insert space before uppercase letters (except first letter) + var result = new System.Text.StringBuilder(); + for (int i = 0; i < propertyName.Length; i++) + { + if (i > 0 && char.IsUpper(propertyName[i])) + result.Append(' '); + + result.Append(propertyName[i]); + } + + return result.ToString(); + } + + /// + /// Validate that a diff preview is valid for the operation + /// + public bool ValidateDiff(DiffPreview diff) + { + if (diff == null) + return false; + + try + { + // Operation-specific validation + if (diff.IsCreate() && string.IsNullOrWhiteSpace(diff.AfterData)) + return false; + + if (diff.IsUpdate() && diff.ChangedFields.Count == 0) + return false; + + if (diff.IsUpdate() && diff.EntityId == null) + return false; + + if (diff.IsDelete() && diff.EntityId == null) + return false; + + if (diff.IsDelete() && string.IsNullOrWhiteSpace(diff.BeforeData)) + return false; + + return true; + } + catch + { + return false; + } + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/TaskLockService.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/TaskLockService.cs new file mode 100644 index 0000000..f9195d4 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/TaskLockService.cs @@ -0,0 +1,302 @@ +using ColaFlow.Modules.Mcp.Domain.Entities; +using ColaFlow.Modules.Mcp.Domain.Repositories; + +namespace ColaFlow.Modules.Mcp.Domain.Services; + +/// +/// Domain service for managing task locks and concurrency control +/// +public sealed class TaskLockService +{ + private readonly ITaskLockRepository _taskLockRepository; + + public TaskLockService(ITaskLockRepository taskLockRepository) + { + _taskLockRepository = taskLockRepository ?? throw new ArgumentNullException(nameof(taskLockRepository)); + } + + /// + /// Try to acquire a lock for a resource + /// Returns the lock if successful, or null if the resource is already locked + /// + public async Task TryAcquireLockAsync( + string resourceType, + Guid resourceId, + string lockHolderType, + Guid lockHolderId, + Guid tenantId, + string? lockHolderName = null, + string? purpose = null, + int expirationMinutes = 5, + CancellationToken cancellationToken = default) + { + // Check if resource is already locked + var existingLock = await _taskLockRepository.GetActiveLockForResourceAsync( + tenantId, + resourceType, + resourceId, + cancellationToken); + + if (existingLock != null) + { + // Check if the lock has expired + if (existingLock.IsExpired()) + { + // Mark as expired and allow new lock + existingLock.MarkAsExpired(); + await _taskLockRepository.UpdateAsync(existingLock, cancellationToken); + await _taskLockRepository.SaveChangesAsync(cancellationToken); + } + else + { + // Resource is locked by someone else + return null; + } + } + + // Acquire new lock + var newLock = TaskLock.Acquire( + resourceType: resourceType, + resourceId: resourceId, + lockHolderType: lockHolderType, + lockHolderId: lockHolderId, + tenantId: tenantId, + lockHolderName: lockHolderName, + purpose: purpose, + expirationMinutes: expirationMinutes + ); + + await _taskLockRepository.AddAsync(newLock, cancellationToken); + await _taskLockRepository.SaveChangesAsync(cancellationToken); + + return newLock; + } + + /// + /// Release a lock by ID + /// + public async Task ReleaseLockAsync( + Guid lockId, + Guid lockHolderId, + CancellationToken cancellationToken = default) + { + var taskLock = await _taskLockRepository.GetByIdAsync(lockId, cancellationToken); + + if (taskLock == null) + return false; + + // Verify that the caller is the lock holder + if (taskLock.LockHolderId != lockHolderId) + throw new InvalidOperationException( + "Cannot release lock held by another user/agent"); + + if (!taskLock.IsValid()) + return false; // Lock already released or expired + + taskLock.Release(); + await _taskLockRepository.UpdateAsync(taskLock, cancellationToken); + await _taskLockRepository.SaveChangesAsync(cancellationToken); + + return true; + } + + /// + /// Release a lock for a specific resource + /// + public async Task ReleaseLockForResourceAsync( + string resourceType, + Guid resourceId, + Guid lockHolderId, + Guid tenantId, + CancellationToken cancellationToken = default) + { + var taskLock = await _taskLockRepository.GetActiveLockForResourceAsync( + tenantId, + resourceType, + resourceId, + cancellationToken); + + if (taskLock == null) + return false; + + // Verify that the caller is the lock holder + if (taskLock.LockHolderId != lockHolderId) + throw new InvalidOperationException( + "Cannot release lock held by another user/agent"); + + if (!taskLock.IsValid()) + return false; // Lock already released or expired + + taskLock.Release(); + await _taskLockRepository.UpdateAsync(taskLock, cancellationToken); + await _taskLockRepository.SaveChangesAsync(cancellationToken); + + return true; + } + + /// + /// Check if a resource is currently locked + /// + public async Task IsResourceLockedAsync( + Guid tenantId, + string resourceType, + Guid resourceId, + CancellationToken cancellationToken = default) + { + var activeLock = await _taskLockRepository.GetActiveLockForResourceAsync( + tenantId, + resourceType, + resourceId, + cancellationToken); + + if (activeLock == null) + return false; + + // Check if lock has expired + if (activeLock.IsExpired()) + { + // Mark as expired + activeLock.MarkAsExpired(); + await _taskLockRepository.UpdateAsync(activeLock, cancellationToken); + await _taskLockRepository.SaveChangesAsync(cancellationToken); + return false; + } + + return activeLock.IsValid(); + } + + /// + /// Check if a resource is locked by a specific holder + /// + public async Task IsResourceLockedByAsync( + Guid tenantId, + string resourceType, + Guid resourceId, + Guid lockHolderId, + CancellationToken cancellationToken = default) + { + var activeLock = await _taskLockRepository.GetActiveLockForResourceAsync( + tenantId, + resourceType, + resourceId, + cancellationToken); + + if (activeLock == null) + return false; + + return activeLock.IsHeldBy(lockHolderId); + } + + /// + /// Get the current lock for a resource (if any) + /// + public async Task GetActiveLockAsync( + Guid tenantId, + string resourceType, + Guid resourceId, + CancellationToken cancellationToken = default) + { + var activeLock = await _taskLockRepository.GetActiveLockForResourceAsync( + tenantId, + resourceType, + resourceId, + cancellationToken); + + if (activeLock == null) + return null; + + // Check if lock has expired + if (activeLock.IsExpired()) + { + activeLock.MarkAsExpired(); + await _taskLockRepository.UpdateAsync(activeLock, cancellationToken); + await _taskLockRepository.SaveChangesAsync(cancellationToken); + return null; + } + + return activeLock.IsValid() ? activeLock : null; + } + + /// + /// Extend the expiration time of a lock + /// + public async Task ExtendLockAsync( + Guid lockId, + Guid lockHolderId, + int additionalMinutes, + CancellationToken cancellationToken = default) + { + var taskLock = await _taskLockRepository.GetByIdAsync(lockId, cancellationToken); + + if (taskLock == null) + return false; + + // Verify that the caller is the lock holder + if (taskLock.LockHolderId != lockHolderId) + throw new InvalidOperationException( + "Cannot extend lock held by another user/agent"); + + if (!taskLock.IsValid()) + return false; + + taskLock.ExtendExpiration(additionalMinutes); + await _taskLockRepository.UpdateAsync(taskLock, cancellationToken); + await _taskLockRepository.SaveChangesAsync(cancellationToken); + + return true; + } + + /// + /// Process expired locks - marks them as expired + /// This should be called by a background job periodically + /// + public async Task ProcessExpiredLocksAsync(CancellationToken cancellationToken = default) + { + var expiredLocks = await _taskLockRepository.GetExpiredAsync(cancellationToken); + + var count = 0; + foreach (var taskLock in expiredLocks) + { + taskLock.MarkAsExpired(); + await _taskLockRepository.UpdateAsync(taskLock, cancellationToken); + count++; + } + + if (count > 0) + { + await _taskLockRepository.SaveChangesAsync(cancellationToken); + } + + return count; + } + + /// + /// Release all locks held by a specific holder + /// Useful when an AI agent disconnects or a user logs out + /// + public async Task ReleaseAllLocksForHolderAsync( + Guid tenantId, + Guid lockHolderId, + CancellationToken cancellationToken = default) + { + var locks = await _taskLockRepository.GetByLockHolderAsync( + tenantId, + lockHolderId, + cancellationToken); + + var count = 0; + foreach (var taskLock in locks.Where(l => l.IsValid())) + { + taskLock.Release(); + await _taskLockRepository.UpdateAsync(taskLock, cancellationToken); + count++; + } + + if (count > 0) + { + await _taskLockRepository.SaveChangesAsync(cancellationToken); + } + + return count; + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffField.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffField.cs new file mode 100644 index 0000000..146419c --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffField.cs @@ -0,0 +1,109 @@ +using ColaFlow.Shared.Kernel.Common; + +namespace ColaFlow.Modules.Mcp.Domain.ValueObjects; + +/// +/// Value object representing a single field difference in a change preview +/// +public sealed class DiffField : ValueObject +{ + /// + /// The name of the field that changed (e.g., "Title", "Status") + /// + public string FieldName { get; private set; } + + /// + /// Human-readable display name for the field + /// + public string DisplayName { get; private set; } + + /// + /// The old value before the change + /// + public object? OldValue { get; private set; } + + /// + /// The new value after the change + /// + public object? NewValue { get; private set; } + + /// + /// Optional HTML diff markup for rich text fields + /// + public string? DiffHtml { get; private set; } + + /// + /// Private constructor for EF Core + /// + private DiffField() + { + FieldName = string.Empty; + DisplayName = string.Empty; + } + + /// + /// Create a new DiffField + /// + public DiffField( + string fieldName, + string displayName, + object? oldValue, + object? newValue, + string? diffHtml = null) + { + if (string.IsNullOrWhiteSpace(fieldName)) + throw new ArgumentException("Field name cannot be empty", nameof(fieldName)); + + if (string.IsNullOrWhiteSpace(displayName)) + throw new ArgumentException("Display name cannot be empty", nameof(displayName)); + + FieldName = fieldName; + DisplayName = displayName; + OldValue = oldValue; + NewValue = newValue; + DiffHtml = diffHtml; + } + + /// + /// Value object equality - compare all atomic values + /// + protected override IEnumerable GetAtomicValues() + { + yield return FieldName; + yield return DisplayName; + yield return OldValue ?? string.Empty; + yield return NewValue ?? string.Empty; + yield return DiffHtml ?? string.Empty; + } + + /// + /// Check if the field value actually changed + /// + public bool HasChanged() + { + if (OldValue == null && NewValue == null) + return false; + + if (OldValue == null || NewValue == null) + return true; + + return !OldValue.Equals(NewValue); + } + + /// + /// Get a formatted string representation of the change + /// + public string GetChangeDescription() + { + if (OldValue == null && NewValue == null) + return $"{DisplayName}: (no change)"; + + if (OldValue == null) + return $"{DisplayName}: → {NewValue}"; + + if (NewValue == null) + return $"{DisplayName}: {OldValue} → (removed)"; + + return $"{DisplayName}: {OldValue} → {NewValue}"; + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffPreview.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffPreview.cs new file mode 100644 index 0000000..f7569b9 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffPreview.cs @@ -0,0 +1,218 @@ +using ColaFlow.Shared.Kernel.Common; + +namespace ColaFlow.Modules.Mcp.Domain.ValueObjects; + +/// +/// Value object representing a preview of changes proposed by an AI agent +/// Immutable - once created, cannot be modified +/// +public sealed class DiffPreview : ValueObject +{ + /// + /// The type of operation: CREATE, UPDATE, DELETE + /// + public string Operation { get; private set; } + + /// + /// The type of entity being changed (e.g., "Epic", "Story", "Task") + /// + public string EntityType { get; private set; } + + /// + /// The ID of the entity being changed (null for CREATE operations) + /// + public Guid? EntityId { get; private set; } + + /// + /// Human-readable key for the entity (e.g., "COLA-146") + /// + public string? EntityKey { get; private set; } + + /// + /// Snapshot of the entity state before the change (null for CREATE) + /// Stored as JSON for flexibility + /// + public string? BeforeData { get; private set; } + + /// + /// Snapshot of the entity state after the change (null for DELETE) + /// Stored as JSON for flexibility + /// + public string? AfterData { get; private set; } + + /// + /// List of individual field changes (for UPDATE operations) + /// + public IReadOnlyList ChangedFields { get; private set; } + + /// + /// Private constructor for EF Core + /// + private DiffPreview() + { + Operation = string.Empty; + EntityType = string.Empty; + ChangedFields = new List().AsReadOnly(); + } + + /// + /// Create a new DiffPreview + /// + public DiffPreview( + string operation, + string entityType, + Guid? entityId, + string? entityKey, + string? beforeData, + string? afterData, + IReadOnlyList? changedFields = null) + { + // Validation + if (string.IsNullOrWhiteSpace(operation)) + throw new ArgumentException("Operation cannot be empty", nameof(operation)); + + if (string.IsNullOrWhiteSpace(entityType)) + throw new ArgumentException("EntityType cannot be empty", nameof(entityType)); + + // Normalize operation to uppercase + operation = operation.ToUpperInvariant(); + + // Validate operation type + if (operation != "CREATE" && operation != "UPDATE" && operation != "DELETE") + throw new ArgumentException( + "Operation must be CREATE, UPDATE, or DELETE", + nameof(operation)); + + // Validate operation-specific requirements + if (operation == "UPDATE" && entityId == null) + throw new ArgumentException( + "UPDATE operation requires EntityId", + nameof(entityId)); + + if (operation == "UPDATE" && (changedFields == null || changedFields.Count == 0)) + throw new ArgumentException( + "UPDATE operation must have at least one changed field", + nameof(changedFields)); + + if (operation == "DELETE" && entityId == null) + throw new ArgumentException( + "DELETE operation requires EntityId", + nameof(entityId)); + + if (operation == "CREATE" && string.IsNullOrWhiteSpace(afterData)) + throw new ArgumentException( + "CREATE operation requires AfterData", + nameof(afterData)); + + Operation = operation; + EntityType = entityType; + EntityId = entityId; + EntityKey = entityKey; + BeforeData = beforeData; + AfterData = afterData; + ChangedFields = changedFields ?? new List().AsReadOnly(); + } + + /// + /// Factory method to create a CREATE operation diff + /// + public static DiffPreview ForCreate( + string entityType, + string afterData, + string? entityKey = null) + { + return new DiffPreview( + operation: "CREATE", + entityType: entityType, + entityId: null, + entityKey: entityKey, + beforeData: null, + afterData: afterData, + changedFields: null); + } + + /// + /// Factory method to create an UPDATE operation diff + /// + public static DiffPreview ForUpdate( + string entityType, + Guid entityId, + string beforeData, + string afterData, + IReadOnlyList changedFields, + string? entityKey = null) + { + return new DiffPreview( + operation: "UPDATE", + entityType: entityType, + entityId: entityId, + entityKey: entityKey, + beforeData: beforeData, + afterData: afterData, + changedFields: changedFields); + } + + /// + /// Factory method to create a DELETE operation diff + /// + public static DiffPreview ForDelete( + string entityType, + Guid entityId, + string beforeData, + string? entityKey = null) + { + return new DiffPreview( + operation: "DELETE", + entityType: entityType, + entityId: entityId, + entityKey: entityKey, + beforeData: beforeData, + afterData: null, + changedFields: null); + } + + /// + /// Value object equality - compare all atomic values + /// + protected override IEnumerable GetAtomicValues() + { + yield return Operation; + yield return EntityType; + yield return EntityId ?? Guid.Empty; + yield return EntityKey ?? string.Empty; + yield return BeforeData ?? string.Empty; + yield return AfterData ?? string.Empty; + + foreach (var field in ChangedFields) + yield return field; + } + + /// + /// Check if this is a CREATE operation + /// + public bool IsCreate() => Operation == "CREATE"; + + /// + /// Check if this is an UPDATE operation + /// + public bool IsUpdate() => Operation == "UPDATE"; + + /// + /// Check if this is a DELETE operation + /// + public bool IsDelete() => Operation == "DELETE"; + + /// + /// Get a human-readable summary of the change + /// + public string GetSummary() + { + var identifier = EntityKey ?? EntityId?.ToString() ?? "new entity"; + return $"{Operation} {EntityType} ({identifier})"; + } + + /// + /// Get the count of changed fields + /// + public int GetChangedFieldCount() => ChangedFields.Count; +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/PendingChangeStatus.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/PendingChangeStatus.cs new file mode 100644 index 0000000..be30b85 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/PendingChangeStatus.cs @@ -0,0 +1,32 @@ +namespace ColaFlow.Modules.Mcp.Domain.ValueObjects; + +/// +/// Status of a pending change in the approval workflow +/// +public enum PendingChangeStatus +{ + /// + /// The change is pending approval from a human user + /// + PendingApproval = 0, + + /// + /// The change has been approved and is ready to be applied + /// + Approved = 1, + + /// + /// The change has been rejected by a human user + /// + Rejected = 2, + + /// + /// The change has expired (24 hours passed without approval) + /// + Expired = 3, + + /// + /// The change has been successfully applied to the system + /// + Applied = 4 +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/TaskLockStatus.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/TaskLockStatus.cs new file mode 100644 index 0000000..6ba76b6 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/TaskLockStatus.cs @@ -0,0 +1,22 @@ +namespace ColaFlow.Modules.Mcp.Domain.ValueObjects; + +/// +/// Status of a task lock for concurrency control +/// +public enum TaskLockStatus +{ + /// + /// The lock is currently active and held by an agent or user + /// + Active = 0, + + /// + /// The lock has been explicitly released + /// + Released = 1, + + /// + /// The lock has expired (5 minutes passed without activity) + /// + Expired = 2 +} diff --git a/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/DiffFieldTests.cs b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/DiffFieldTests.cs new file mode 100644 index 0000000..1ce70b5 --- /dev/null +++ b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/DiffFieldTests.cs @@ -0,0 +1,194 @@ +using ColaFlow.Modules.Mcp.Domain.ValueObjects; +using Xunit; + +namespace ColaFlow.Modules.Mcp.Tests.Domain; + +public class DiffFieldTests +{ + [Fact] + public void Create_ValidInput_Success() + { + // Arrange & Act + var diffField = new DiffField( + fieldName: "Title", + displayName: "Title", + oldValue: "Old Title", + newValue: "New Title" + ); + + // Assert + Assert.Equal("Title", diffField.FieldName); + Assert.Equal("Title", diffField.DisplayName); + Assert.Equal("Old Title", diffField.OldValue); + Assert.Equal("New Title", diffField.NewValue); + Assert.Null(diffField.DiffHtml); + } + + [Fact] + public void Create_WithDiffHtml_Success() + { + // Arrange & Act + var diffField = new DiffField( + fieldName: "Description", + displayName: "Description", + oldValue: "Old description", + newValue: "New description", + diffHtml: "OldNew description" + ); + + // Assert + Assert.Equal("OldNew description", diffField.DiffHtml); + } + + [Fact] + public void Create_EmptyFieldName_ThrowsException() + { + // Act & Assert + Assert.Throws(() => new DiffField( + fieldName: "", + displayName: "Title", + oldValue: "Old", + newValue: "New" + )); + } + + [Fact] + public void Create_EmptyDisplayName_ThrowsException() + { + // Act & Assert + Assert.Throws(() => new DiffField( + fieldName: "Title", + displayName: "", + oldValue: "Old", + newValue: "New" + )); + } + + [Fact] + public void Equality_SameValues_AreEqual() + { + // Arrange + var field1 = new DiffField("Title", "Title", "Old", "New"); + var field2 = new DiffField("Title", "Title", "Old", "New"); + + // Act & Assert + Assert.Equal(field1, field2); + Assert.True(field1 == field2); + Assert.False(field1 != field2); + } + + [Fact] + public void Equality_DifferentValues_AreNotEqual() + { + // Arrange + var field1 = new DiffField("Title", "Title", "Old", "New"); + var field2 = new DiffField("Title", "Title", "Old", "Different"); + + // Act & Assert + Assert.NotEqual(field1, field2); + Assert.False(field1 == field2); + Assert.True(field1 != field2); + } + + [Fact] + public void HasChanged_DifferentValues_ReturnsTrue() + { + // Arrange + var diffField = new DiffField("Title", "Title", "Old", "New"); + + // Act & Assert + Assert.True(diffField.HasChanged()); + } + + [Fact] + public void HasChanged_SameValues_ReturnsFalse() + { + // Arrange + var diffField = new DiffField("Title", "Title", "Same", "Same"); + + // Act & Assert + Assert.False(diffField.HasChanged()); + } + + [Fact] + public void HasChanged_BothNull_ReturnsFalse() + { + // Arrange + var diffField = new DiffField("Title", "Title", null, null); + + // Act & Assert + Assert.False(diffField.HasChanged()); + } + + [Fact] + public void HasChanged_OldNull_ReturnsTrue() + { + // Arrange + var diffField = new DiffField("Title", "Title", null, "New"); + + // Act & Assert + Assert.True(diffField.HasChanged()); + } + + [Fact] + public void HasChanged_NewNull_ReturnsTrue() + { + // Arrange + var diffField = new DiffField("Title", "Title", "Old", null); + + // Act & Assert + Assert.True(diffField.HasChanged()); + } + + [Fact] + public void GetChangeDescription_NormalChange_ReturnsFormattedString() + { + // Arrange + var diffField = new DiffField("Title", "Title", "Old Title", "New Title"); + + // Act + var description = diffField.GetChangeDescription(); + + // Assert + Assert.Equal("Title: Old Title → New Title", description); + } + + [Fact] + public void GetChangeDescription_OldNull_ReturnsFormattedString() + { + // Arrange + var diffField = new DiffField("Title", "Title", null, "New Title"); + + // Act + var description = diffField.GetChangeDescription(); + + // Assert + Assert.Equal("Title: → New Title", description); + } + + [Fact] + public void GetChangeDescription_NewNull_ReturnsFormattedString() + { + // Arrange + var diffField = new DiffField("Title", "Title", "Old Title", null); + + // Act + var description = diffField.GetChangeDescription(); + + // Assert + Assert.Equal("Title: Old Title → (removed)", description); + } + + [Fact] + public void GetChangeDescription_BothNull_ReturnsFormattedString() + { + // Arrange + var diffField = new DiffField("Title", "Title", null, null); + + // Act + var description = diffField.GetChangeDescription(); + + // Assert + Assert.Equal("Title: (no change)", description); + } +} diff --git a/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/DiffPreviewTests.cs b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/DiffPreviewTests.cs new file mode 100644 index 0000000..ab20239 --- /dev/null +++ b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/DiffPreviewTests.cs @@ -0,0 +1,330 @@ +using ColaFlow.Modules.Mcp.Domain.ValueObjects; +using Xunit; + +namespace ColaFlow.Modules.Mcp.Tests.Domain; + +public class DiffPreviewTests +{ + [Fact] + public void ForCreate_ValidInput_Success() + { + // Arrange & Act + var diff = DiffPreview.ForCreate( + entityType: "Epic", + afterData: "{\"title\": \"New Epic\"}", + entityKey: "COLA-1" + ); + + // Assert + Assert.Equal("CREATE", diff.Operation); + Assert.Equal("Epic", diff.EntityType); + Assert.Null(diff.EntityId); + Assert.Equal("COLA-1", diff.EntityKey); + Assert.Null(diff.BeforeData); + Assert.Equal("{\"title\": \"New Epic\"}", diff.AfterData); + Assert.Empty(diff.ChangedFields); + Assert.True(diff.IsCreate()); + Assert.False(diff.IsUpdate()); + Assert.False(diff.IsDelete()); + } + + [Fact] + public void ForUpdate_ValidInput_Success() + { + // Arrange + var entityId = Guid.NewGuid(); + var changedFields = new List + { + new DiffField("Title", "Title", "Old", "New") + }.AsReadOnly(); + + // Act + var diff = DiffPreview.ForUpdate( + entityType: "Story", + entityId: entityId, + beforeData: "{\"title\": \"Old\"}", + afterData: "{\"title\": \"New\"}", + changedFields: changedFields, + entityKey: "COLA-2" + ); + + // Assert + Assert.Equal("UPDATE", diff.Operation); + Assert.Equal("Story", diff.EntityType); + Assert.Equal(entityId, diff.EntityId); + Assert.Equal("COLA-2", diff.EntityKey); + Assert.Equal("{\"title\": \"Old\"}", diff.BeforeData); + Assert.Equal("{\"title\": \"New\"}", diff.AfterData); + Assert.Single(diff.ChangedFields); + Assert.False(diff.IsCreate()); + Assert.True(diff.IsUpdate()); + Assert.False(diff.IsDelete()); + } + + [Fact] + public void ForDelete_ValidInput_Success() + { + // Arrange + var entityId = Guid.NewGuid(); + + // Act + var diff = DiffPreview.ForDelete( + entityType: "Task", + entityId: entityId, + beforeData: "{\"title\": \"Task to delete\"}", + entityKey: "COLA-3" + ); + + // Assert + Assert.Equal("DELETE", diff.Operation); + Assert.Equal("Task", diff.EntityType); + Assert.Equal(entityId, diff.EntityId); + Assert.Equal("COLA-3", diff.EntityKey); + Assert.Equal("{\"title\": \"Task to delete\"}", diff.BeforeData); + Assert.Null(diff.AfterData); + Assert.Empty(diff.ChangedFields); + Assert.False(diff.IsCreate()); + Assert.False(diff.IsUpdate()); + Assert.True(diff.IsDelete()); + } + + [Fact] + public void Create_InvalidOperation_ThrowsException() + { + // Act & Assert + Assert.Throws(() => new DiffPreview( + operation: "INVALID", + entityType: "Epic", + entityId: null, + entityKey: null, + beforeData: null, + afterData: "{}" + )); + } + + [Fact] + public void Create_EmptyOperation_ThrowsException() + { + // Act & Assert + Assert.Throws(() => new DiffPreview( + operation: "", + entityType: "Epic", + entityId: null, + entityKey: null, + beforeData: null, + afterData: "{}" + )); + } + + [Fact] + public void Create_EmptyEntityType_ThrowsException() + { + // Act & Assert + Assert.Throws(() => new DiffPreview( + operation: "CREATE", + entityType: "", + entityId: null, + entityKey: null, + beforeData: null, + afterData: "{}" + )); + } + + [Fact] + public void Create_UpdateWithoutEntityId_ThrowsException() + { + // Arrange + var changedFields = new List + { + new DiffField("Title", "Title", "Old", "New") + }.AsReadOnly(); + + // Act & Assert + Assert.Throws(() => new DiffPreview( + operation: "UPDATE", + entityType: "Epic", + entityId: null, + entityKey: null, + beforeData: "{}", + afterData: "{}", + changedFields: changedFields + )); + } + + [Fact] + public void Create_UpdateWithoutChangedFields_ThrowsException() + { + // Act & Assert + Assert.Throws(() => new DiffPreview( + operation: "UPDATE", + entityType: "Epic", + entityId: Guid.NewGuid(), + entityKey: null, + beforeData: "{}", + afterData: "{}", + changedFields: null + )); + } + + [Fact] + public void Create_UpdateWithEmptyChangedFields_ThrowsException() + { + // Act & Assert + Assert.Throws(() => new DiffPreview( + operation: "UPDATE", + entityType: "Epic", + entityId: Guid.NewGuid(), + entityKey: null, + beforeData: "{}", + afterData: "{}", + changedFields: new List().AsReadOnly() + )); + } + + [Fact] + public void Create_DeleteWithoutEntityId_ThrowsException() + { + // Act & Assert + Assert.Throws(() => new DiffPreview( + operation: "DELETE", + entityType: "Epic", + entityId: null, + entityKey: null, + beforeData: "{}", + afterData: null + )); + } + + [Fact] + public void Create_CreateWithoutAfterData_ThrowsException() + { + // Act & Assert + Assert.Throws(() => new DiffPreview( + operation: "CREATE", + entityType: "Epic", + entityId: null, + entityKey: null, + beforeData: null, + afterData: null + )); + } + + [Fact] + public void Equality_SameValues_AreEqual() + { + // Arrange + var diff1 = DiffPreview.ForCreate("Epic", "{\"title\": \"New Epic\"}", "COLA-1"); + var diff2 = DiffPreview.ForCreate("Epic", "{\"title\": \"New Epic\"}", "COLA-1"); + + // Act & Assert + Assert.Equal(diff1, diff2); + Assert.True(diff1 == diff2); + Assert.False(diff1 != diff2); + } + + [Fact] + public void Equality_DifferentValues_AreNotEqual() + { + // Arrange + var diff1 = DiffPreview.ForCreate("Epic", "{\"title\": \"Epic 1\"}", "COLA-1"); + var diff2 = DiffPreview.ForCreate("Epic", "{\"title\": \"Epic 2\"}", "COLA-2"); + + // Act & Assert + Assert.NotEqual(diff1, diff2); + Assert.False(diff1 == diff2); + Assert.True(diff1 != diff2); + } + + [Fact] + public void GetSummary_Create_ReturnsFormattedString() + { + // Arrange + var diff = DiffPreview.ForCreate("Epic", "{}", "COLA-1"); + + // Act + var summary = diff.GetSummary(); + + // Assert + Assert.Equal("CREATE Epic (COLA-1)", summary); + } + + [Fact] + public void GetSummary_Update_ReturnsFormattedString() + { + // Arrange + var entityId = Guid.NewGuid(); + var changedFields = new List + { + new DiffField("Title", "Title", "Old", "New") + }.AsReadOnly(); + var diff = DiffPreview.ForUpdate("Story", entityId, "{}", "{}", changedFields, "COLA-2"); + + // Act + var summary = diff.GetSummary(); + + // Assert + Assert.Equal("UPDATE Story (COLA-2)", summary); + } + + [Fact] + public void GetSummary_Delete_ReturnsFormattedString() + { + // Arrange + var entityId = Guid.NewGuid(); + var diff = DiffPreview.ForDelete("Task", entityId, "{}", "COLA-3"); + + // Act + var summary = diff.GetSummary(); + + // Assert + Assert.Equal("DELETE Task (COLA-3)", summary); + } + + [Fact] + public void GetChangedFieldCount_Create_ReturnsZero() + { + // Arrange + var diff = DiffPreview.ForCreate("Epic", "{}"); + + // Act + var count = diff.GetChangedFieldCount(); + + // Assert + Assert.Equal(0, count); + } + + [Fact] + public void GetChangedFieldCount_Update_ReturnsCorrectCount() + { + // Arrange + var changedFields = new List + { + new DiffField("Title", "Title", "Old", "New"), + new DiffField("Status", "Status", "Todo", "Done") + }.AsReadOnly(); + var diff = DiffPreview.ForUpdate("Story", Guid.NewGuid(), "{}", "{}", changedFields); + + // Act + var count = diff.GetChangedFieldCount(); + + // Assert + Assert.Equal(2, count); + } + + [Fact] + public void Operation_LowercaseInput_NormalizedToUppercase() + { + // Arrange & Act + var diff = new DiffPreview( + operation: "create", + entityType: "Epic", + entityId: null, + entityKey: null, + beforeData: null, + afterData: "{}" + ); + + // Assert + Assert.Equal("CREATE", diff.Operation); + } +} diff --git a/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/PendingChangeTests.cs b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/PendingChangeTests.cs new file mode 100644 index 0000000..37693b9 --- /dev/null +++ b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/PendingChangeTests.cs @@ -0,0 +1,562 @@ +using ColaFlow.Modules.Mcp.Domain.Entities; +using ColaFlow.Modules.Mcp.Domain.Events; +using ColaFlow.Modules.Mcp.Domain.ValueObjects; +using Xunit; + +namespace ColaFlow.Modules.Mcp.Tests.Domain; + +public class PendingChangeTests +{ + private static DiffPreview CreateValidDiff() + { + return DiffPreview.ForCreate( + entityType: "Epic", + afterData: "{\"title\": \"New Epic\"}" + ); + } + + [Fact] + public void Create_ValidInput_Success() + { + // Arrange + var diff = CreateValidDiff(); + var tenantId = Guid.NewGuid(); + var apiKeyId = Guid.NewGuid(); + + // Act + var pendingChange = PendingChange.Create( + toolName: "create_epic", + diff: diff, + tenantId: tenantId, + apiKeyId: apiKeyId + ); + + // Assert + Assert.NotEqual(Guid.Empty, pendingChange.Id); + Assert.Equal("create_epic", pendingChange.ToolName); + Assert.Equal(diff, pendingChange.Diff); + Assert.Equal(tenantId, pendingChange.TenantId); + Assert.Equal(apiKeyId, pendingChange.ApiKeyId); + Assert.Equal(PendingChangeStatus.PendingApproval, pendingChange.Status); + Assert.True(pendingChange.ExpiresAt > DateTime.UtcNow); + Assert.Null(pendingChange.ApprovedBy); + Assert.Null(pendingChange.ApprovedAt); + Assert.Null(pendingChange.RejectedBy); + Assert.Null(pendingChange.RejectedAt); + } + + [Fact] + public void Create_RaisesPendingChangeCreatedEvent() + { + // Arrange + var diff = CreateValidDiff(); + var tenantId = Guid.NewGuid(); + var apiKeyId = Guid.NewGuid(); + + // Act + var pendingChange = PendingChange.Create("create_epic", diff, tenantId, apiKeyId); + + // Assert + Assert.Single(pendingChange.DomainEvents); + var domainEvent = pendingChange.DomainEvents.First(); + Assert.IsType(domainEvent); + var createdEvent = (PendingChangeCreatedEvent)domainEvent; + Assert.Equal(pendingChange.Id, createdEvent.PendingChangeId); + Assert.Equal("create_epic", createdEvent.ToolName); + Assert.Equal("Epic", createdEvent.EntityType); + Assert.Equal("CREATE", createdEvent.Operation); + } + + [Fact] + public void Create_EmptyToolName_ThrowsException() + { + // Arrange + var diff = CreateValidDiff(); + + // Act & Assert + Assert.Throws(() => PendingChange.Create( + toolName: "", + diff: diff, + tenantId: Guid.NewGuid(), + apiKeyId: Guid.NewGuid() + )); + } + + [Fact] + public void Create_NullDiff_ThrowsException() + { + // Act & Assert + Assert.Throws(() => PendingChange.Create( + toolName: "create_epic", + diff: null!, + tenantId: Guid.NewGuid(), + apiKeyId: Guid.NewGuid() + )); + } + + [Fact] + public void Create_EmptyTenantId_ThrowsException() + { + // Arrange + var diff = CreateValidDiff(); + + // Act & Assert + Assert.Throws(() => PendingChange.Create( + toolName: "create_epic", + diff: diff, + tenantId: Guid.Empty, + apiKeyId: Guid.NewGuid() + )); + } + + [Fact] + public void Create_CustomExpirationHours_SetsCorrectExpiration() + { + // Arrange + var diff = CreateValidDiff(); + var before = DateTime.UtcNow; + + // Act + var pendingChange = PendingChange.Create( + toolName: "create_epic", + diff: diff, + tenantId: Guid.NewGuid(), + apiKeyId: Guid.NewGuid(), + expirationHours: 48 + ); + + // Assert + var after = DateTime.UtcNow; + Assert.True(pendingChange.ExpiresAt >= before.AddHours(48)); + Assert.True(pendingChange.ExpiresAt <= after.AddHours(48)); + } + + [Fact] + public void Approve_PendingChange_Success() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid() + ); + var approverId = Guid.NewGuid(); + pendingChange.ClearDomainEvents(); // Clear creation event + + // Act + pendingChange.Approve(approverId); + + // Assert + Assert.Equal(PendingChangeStatus.Approved, pendingChange.Status); + Assert.Equal(approverId, pendingChange.ApprovedBy); + Assert.NotNull(pendingChange.ApprovedAt); + Assert.True(pendingChange.ApprovedAt <= DateTime.UtcNow); + } + + [Fact] + public void Approve_RaisesPendingChangeApprovedEvent() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid() + ); + var approverId = Guid.NewGuid(); + pendingChange.ClearDomainEvents(); + + // Act + pendingChange.Approve(approverId); + + // Assert + Assert.Single(pendingChange.DomainEvents); + var domainEvent = pendingChange.DomainEvents.First(); + Assert.IsType(domainEvent); + var approvedEvent = (PendingChangeApprovedEvent)domainEvent; + Assert.Equal(pendingChange.Id, approvedEvent.PendingChangeId); + Assert.Equal(approverId, approvedEvent.ApprovedBy); + } + + [Fact] + public void Approve_AlreadyApproved_ThrowsException() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid() + ); + pendingChange.Approve(Guid.NewGuid()); + + // Act & Assert + Assert.Throws( + () => pendingChange.Approve(Guid.NewGuid())); + } + + [Fact] + public void Approve_Rejected_ThrowsException() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid() + ); + pendingChange.Reject(Guid.NewGuid(), "Not valid"); + + // Act & Assert + Assert.Throws( + () => pendingChange.Approve(Guid.NewGuid())); + } + + [Fact] + public void Reject_PendingChange_Success() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid() + ); + var rejectorId = Guid.NewGuid(); + var reason = "Invalid data"; + pendingChange.ClearDomainEvents(); + + // Act + pendingChange.Reject(rejectorId, reason); + + // Assert + Assert.Equal(PendingChangeStatus.Rejected, pendingChange.Status); + Assert.Equal(rejectorId, pendingChange.RejectedBy); + Assert.Equal(reason, pendingChange.RejectionReason); + Assert.NotNull(pendingChange.RejectedAt); + Assert.True(pendingChange.RejectedAt <= DateTime.UtcNow); + } + + [Fact] + public void Reject_RaisesPendingChangeRejectedEvent() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid() + ); + var rejectorId = Guid.NewGuid(); + pendingChange.ClearDomainEvents(); + + // Act + pendingChange.Reject(rejectorId, "Invalid"); + + // Assert + Assert.Single(pendingChange.DomainEvents); + var domainEvent = pendingChange.DomainEvents.First(); + Assert.IsType(domainEvent); + var rejectedEvent = (PendingChangeRejectedEvent)domainEvent; + Assert.Equal(pendingChange.Id, rejectedEvent.PendingChangeId); + Assert.Equal(rejectorId, rejectedEvent.RejectedBy); + Assert.Equal("Invalid", rejectedEvent.Reason); + } + + [Fact] + public void Reject_EmptyReason_ThrowsException() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid() + ); + + // Act & Assert + Assert.Throws( + () => pendingChange.Reject(Guid.NewGuid(), "")); + } + + [Fact] + public void Reject_AlreadyApproved_ThrowsException() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid() + ); + pendingChange.Approve(Guid.NewGuid()); + + // Act & Assert + Assert.Throws( + () => pendingChange.Reject(Guid.NewGuid(), "Too late")); + } + + [Fact] + public void Expire_PendingChange_Success() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid(), + expirationHours: 1 + ); + + // Use reflection to set ExpiresAt to the past (simulating expiration) + var expiresAtProperty = typeof(PendingChange).GetProperty("ExpiresAt"); + expiresAtProperty!.SetValue(pendingChange, DateTime.UtcNow.AddHours(-1)); + + pendingChange.ClearDomainEvents(); + + // Act + pendingChange.Expire(); + + // Assert + Assert.Equal(PendingChangeStatus.Expired, pendingChange.Status); + } + + [Fact] + public void Expire_RaisesPendingChangeExpiredEvent() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid(), + expirationHours: 1 + ); + + // Use reflection to set ExpiresAt to the past + var expiresAtProperty = typeof(PendingChange).GetProperty("ExpiresAt"); + expiresAtProperty!.SetValue(pendingChange, DateTime.UtcNow.AddHours(-1)); + + pendingChange.ClearDomainEvents(); + + // Act + pendingChange.Expire(); + + // Assert + Assert.Single(pendingChange.DomainEvents); + Assert.IsType(pendingChange.DomainEvents.First()); + } + + [Fact] + public void Expire_NotYetExpired_ThrowsException() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid(), + expirationHours: 24 + ); + + // Act & Assert + Assert.Throws(() => pendingChange.Expire()); + } + + [Fact] + public void Expire_AlreadyApproved_DoesNothing() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid(), + expirationHours: 1 + ); + pendingChange.Approve(Guid.NewGuid()); + var statusBefore = pendingChange.Status; + + // Use reflection to set ExpiresAt to the past + var expiresAtProperty = typeof(PendingChange).GetProperty("ExpiresAt"); + expiresAtProperty!.SetValue(pendingChange, DateTime.UtcNow.AddHours(-1)); + + pendingChange.ClearDomainEvents(); + + // Act + pendingChange.Expire(); + + // Assert + Assert.Equal(statusBefore, pendingChange.Status); // Still Approved + Assert.Empty(pendingChange.DomainEvents); // No event raised + } + + [Fact] + public void MarkAsApplied_ApprovedChange_Success() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid() + ); + pendingChange.Approve(Guid.NewGuid()); + pendingChange.ClearDomainEvents(); + + // Act + pendingChange.MarkAsApplied("Successfully created Epic COLA-1"); + + // Assert + Assert.Equal(PendingChangeStatus.Applied, pendingChange.Status); + Assert.NotNull(pendingChange.AppliedAt); + Assert.Equal("Successfully created Epic COLA-1", pendingChange.ApplicationResult); + } + + [Fact] + public void MarkAsApplied_RaisesPendingChangeAppliedEvent() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid() + ); + pendingChange.Approve(Guid.NewGuid()); + pendingChange.ClearDomainEvents(); + + // Act + pendingChange.MarkAsApplied("Success"); + + // Assert + Assert.Single(pendingChange.DomainEvents); + Assert.IsType(pendingChange.DomainEvents.First()); + } + + [Fact] + public void MarkAsApplied_NotApproved_ThrowsException() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid() + ); + + // Act & Assert + Assert.Throws( + () => pendingChange.MarkAsApplied("Success")); + } + + [Fact] + public void IsExpired_ExpiredChange_ReturnsTrue() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid(), + expirationHours: 1 + ); + + // Use reflection to set ExpiresAt to the past + var expiresAtProperty = typeof(PendingChange).GetProperty("ExpiresAt"); + expiresAtProperty!.SetValue(pendingChange, DateTime.UtcNow.AddHours(-1)); + + // Act & Assert + Assert.True(pendingChange.IsExpired()); + } + + [Fact] + public void IsExpired_NotExpiredChange_ReturnsFalse() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid(), + expirationHours: 24 + ); + + // Act & Assert + Assert.False(pendingChange.IsExpired()); + } + + [Fact] + public void CanBeApproved_PendingAndNotExpired_ReturnsTrue() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid(), + expirationHours: 24 + ); + + // Act & Assert + Assert.True(pendingChange.CanBeApproved()); + } + + [Fact] + public void CanBeApproved_Expired_ReturnsFalse() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid(), + expirationHours: 1 + ); + + // Use reflection to set ExpiresAt to the past + var expiresAtProperty = typeof(PendingChange).GetProperty("ExpiresAt"); + expiresAtProperty!.SetValue(pendingChange, DateTime.UtcNow.AddHours(-1)); + + // Act & Assert + Assert.False(pendingChange.CanBeApproved()); + } + + [Fact] + public void CanBeApproved_AlreadyApproved_ReturnsFalse() + { + // Arrange + var pendingChange = PendingChange.Create( + "create_epic", + CreateValidDiff(), + Guid.NewGuid(), + Guid.NewGuid() + ); + pendingChange.Approve(Guid.NewGuid()); + + // Act & Assert + Assert.False(pendingChange.CanBeApproved()); + } + + [Fact] + public void GetSummary_ReturnsFormattedString() + { + // Arrange + var diff = DiffPreview.ForCreate("Epic", "{}", "COLA-1"); + var pendingChange = PendingChange.Create( + "create_epic", + diff, + Guid.NewGuid(), + Guid.NewGuid() + ); + + // Act + var summary = pendingChange.GetSummary(); + + // Assert + Assert.Contains("create_epic", summary); + Assert.Contains("CREATE Epic (COLA-1)", summary); + Assert.Contains("PendingApproval", summary); + } +} diff --git a/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/TaskLockTests.cs b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/TaskLockTests.cs new file mode 100644 index 0000000..ea5706a --- /dev/null +++ b/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Domain/TaskLockTests.cs @@ -0,0 +1,613 @@ +using ColaFlow.Modules.Mcp.Domain.Entities; +using ColaFlow.Modules.Mcp.Domain.Events; +using ColaFlow.Modules.Mcp.Domain.ValueObjects; +using Xunit; + +namespace ColaFlow.Modules.Mcp.Tests.Domain; + +public class TaskLockTests +{ + [Fact] + public void Acquire_ValidInput_Success() + { + // Arrange + var resourceType = "Epic"; + var resourceId = Guid.NewGuid(); + var lockHolderId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + + // Act + var taskLock = TaskLock.Acquire( + resourceType: resourceType, + resourceId: resourceId, + lockHolderType: "AI_AGENT", + lockHolderId: lockHolderId, + tenantId: tenantId, + lockHolderName: "Claude", + purpose: "Creating new epic" + ); + + // Assert + Assert.NotEqual(Guid.Empty, taskLock.Id); + Assert.Equal(resourceType, taskLock.ResourceType); + Assert.Equal(resourceId, taskLock.ResourceId); + Assert.Equal("AI_AGENT", taskLock.LockHolderType); + Assert.Equal(lockHolderId, taskLock.LockHolderId); + Assert.Equal(tenantId, taskLock.TenantId); + Assert.Equal("Claude", taskLock.LockHolderName); + Assert.Equal("Creating new epic", taskLock.Purpose); + Assert.Equal(TaskLockStatus.Active, taskLock.Status); + Assert.True(taskLock.ExpiresAt > DateTime.UtcNow); + Assert.Null(taskLock.ReleasedAt); + } + + [Fact] + public void Acquire_RaisesTaskLockAcquiredEvent() + { + // Arrange & Act + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid() + ); + + // Assert + Assert.Single(taskLock.DomainEvents); + var domainEvent = taskLock.DomainEvents.First(); + Assert.IsType(domainEvent); + var acquiredEvent = (TaskLockAcquiredEvent)domainEvent; + Assert.Equal(taskLock.Id, acquiredEvent.LockId); + Assert.Equal("Epic", acquiredEvent.ResourceType); + } + + [Fact] + public void Acquire_EmptyResourceType_ThrowsException() + { + // Act & Assert + Assert.Throws(() => TaskLock.Acquire( + resourceType: "", + resourceId: Guid.NewGuid(), + lockHolderType: "AI_AGENT", + lockHolderId: Guid.NewGuid(), + tenantId: Guid.NewGuid() + )); + } + + [Fact] + public void Acquire_InvalidLockHolderType_ThrowsException() + { + // Act & Assert + Assert.Throws(() => TaskLock.Acquire( + resourceType: "Epic", + resourceId: Guid.NewGuid(), + lockHolderType: "INVALID", + lockHolderId: Guid.NewGuid(), + tenantId: Guid.NewGuid() + )); + } + + [Fact] + public void Acquire_LowercaseLockHolderType_NormalizedToUppercase() + { + // Act + var taskLock = TaskLock.Acquire( + resourceType: "Epic", + resourceId: Guid.NewGuid(), + lockHolderType: "ai_agent", + lockHolderId: Guid.NewGuid(), + tenantId: Guid.NewGuid() + ); + + // Assert + Assert.Equal("AI_AGENT", taskLock.LockHolderType); + } + + [Fact] + public void Acquire_CustomExpirationMinutes_SetsCorrectExpiration() + { + // Arrange + var before = DateTime.UtcNow; + + // Act + var taskLock = TaskLock.Acquire( + resourceType: "Epic", + resourceId: Guid.NewGuid(), + lockHolderType: "AI_AGENT", + lockHolderId: Guid.NewGuid(), + tenantId: Guid.NewGuid(), + expirationMinutes: 10 + ); + + // Assert + var after = DateTime.UtcNow; + Assert.True(taskLock.ExpiresAt >= before.AddMinutes(10)); + Assert.True(taskLock.ExpiresAt <= after.AddMinutes(10)); + } + + [Fact] + public void Release_ActiveLock_Success() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid() + ); + taskLock.ClearDomainEvents(); + + // Act + taskLock.Release(); + + // Assert + Assert.Equal(TaskLockStatus.Released, taskLock.Status); + Assert.NotNull(taskLock.ReleasedAt); + Assert.True(taskLock.ReleasedAt <= DateTime.UtcNow); + } + + [Fact] + public void Release_RaisesTaskLockReleasedEvent() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid() + ); + taskLock.ClearDomainEvents(); + + // Act + taskLock.Release(); + + // Assert + Assert.Single(taskLock.DomainEvents); + Assert.IsType(taskLock.DomainEvents.First()); + } + + [Fact] + public void Release_AlreadyReleased_ThrowsException() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid() + ); + taskLock.Release(); + + // Act & Assert + Assert.Throws(() => taskLock.Release()); + } + + [Fact] + public void MarkAsExpired_ExpiredActiveLock_Success() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid(), + expirationMinutes: 5 + ); + + // Use reflection to set ExpiresAt to the past + var expiresAtProperty = typeof(TaskLock).GetProperty("ExpiresAt"); + expiresAtProperty!.SetValue(taskLock, DateTime.UtcNow.AddMinutes(-1)); + + taskLock.ClearDomainEvents(); + + // Act + taskLock.MarkAsExpired(); + + // Assert + Assert.Equal(TaskLockStatus.Expired, taskLock.Status); + } + + [Fact] + public void MarkAsExpired_RaisesTaskLockExpiredEvent() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid(), + expirationMinutes: 5 + ); + + // Use reflection to set ExpiresAt to the past + var expiresAtProperty = typeof(TaskLock).GetProperty("ExpiresAt"); + expiresAtProperty!.SetValue(taskLock, DateTime.UtcNow.AddMinutes(-1)); + + taskLock.ClearDomainEvents(); + + // Act + taskLock.MarkAsExpired(); + + // Assert + Assert.Single(taskLock.DomainEvents); + Assert.IsType(taskLock.DomainEvents.First()); + } + + [Fact] + public void MarkAsExpired_NotYetExpired_ThrowsException() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid(), + expirationMinutes: 5 + ); + + // Act & Assert + Assert.Throws(() => taskLock.MarkAsExpired()); + } + + [Fact] + public void MarkAsExpired_AlreadyReleased_DoesNothing() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid(), + expirationMinutes: 5 + ); + + // Use reflection to set ExpiresAt to the past + var expiresAtProperty = typeof(TaskLock).GetProperty("ExpiresAt"); + expiresAtProperty!.SetValue(taskLock, DateTime.UtcNow.AddMinutes(-1)); + + taskLock.Release(); + var statusBefore = taskLock.Status; + taskLock.ClearDomainEvents(); + + // Act + taskLock.MarkAsExpired(); + + // Assert + Assert.Equal(statusBefore, taskLock.Status); + Assert.Empty(taskLock.DomainEvents); + } + + [Fact] + public void ExtendExpiration_ActiveLock_Success() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid(), + expirationMinutes: 5 + ); + var expirationBefore = taskLock.ExpiresAt; + + // Act + taskLock.ExtendExpiration(10); + + // Assert + Assert.True(taskLock.ExpiresAt > expirationBefore); + Assert.True(taskLock.ExpiresAt >= expirationBefore.AddMinutes(10)); + } + + [Fact] + public void ExtendExpiration_ReleasedLock_ThrowsException() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid() + ); + taskLock.Release(); + + // Act & Assert + Assert.Throws(() => taskLock.ExtendExpiration(10)); + } + + [Fact] + public void ExtendExpiration_BeyondMaxDuration_ThrowsException() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid(), + expirationMinutes: 60 + ); + + // Use reflection to set AcquiredAt to 1.5 hours ago + // Current expiration would be at 30 minutes from now (1.5 hours + 60 minutes = 2.5 hours, then - 30min = 2 hours) + // Extending by more than 1 minute will exceed the 2-hour max + var acquiredAtProperty = typeof(TaskLock).GetProperty("AcquiredAt"); + var expiresAtProperty = typeof(TaskLock).GetProperty("ExpiresAt"); + + var pastTime = DateTime.UtcNow.AddHours(-1.5); + acquiredAtProperty!.SetValue(taskLock, pastTime); + // Set expires to 1 hour 55 minutes from now (2 hours total - 5 minutes buffer) + expiresAtProperty!.SetValue(taskLock, DateTime.UtcNow.AddMinutes(115)); + + // Try to extend by 10 minutes - this would push expiration to 2 hours 5 minutes total, exceeding limit + // Act & Assert + Assert.Throws(() => taskLock.ExtendExpiration(10)); + } + + [Fact] + public void IsExpired_ExpiredLock_ReturnsTrue() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid(), + expirationMinutes: 5 + ); + + // Use reflection to set ExpiresAt to the past + var expiresAtProperty = typeof(TaskLock).GetProperty("ExpiresAt"); + expiresAtProperty!.SetValue(taskLock, DateTime.UtcNow.AddMinutes(-1)); + + // Act & Assert + Assert.True(taskLock.IsExpired()); + } + + [Fact] + public void IsExpired_NotExpiredLock_ReturnsFalse() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid(), + expirationMinutes: 5 + ); + + // Act & Assert + Assert.False(taskLock.IsExpired()); + } + + [Fact] + public void IsValid_ActiveNotExpired_ReturnsTrue() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid(), + expirationMinutes: 5 + ); + + // Act & Assert + Assert.True(taskLock.IsValid()); + } + + [Fact] + public void IsValid_Released_ReturnsFalse() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid() + ); + taskLock.Release(); + + // Act & Assert + Assert.False(taskLock.IsValid()); + } + + [Fact] + public void IsValid_Expired_ReturnsFalse() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid(), + expirationMinutes: 5 + ); + + // Use reflection to set ExpiresAt to the past + var expiresAtProperty = typeof(TaskLock).GetProperty("ExpiresAt"); + expiresAtProperty!.SetValue(taskLock, DateTime.UtcNow.AddMinutes(-1)); + + // Act & Assert + Assert.False(taskLock.IsValid()); + } + + [Fact] + public void IsHeldBy_CorrectHolder_ReturnsTrue() + { + // Arrange + var holderId = Guid.NewGuid(); + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + holderId, + Guid.NewGuid() + ); + + // Act & Assert + Assert.True(taskLock.IsHeldBy(holderId)); + } + + [Fact] + public void IsHeldBy_DifferentHolder_ReturnsFalse() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid() + ); + + // Act & Assert + Assert.False(taskLock.IsHeldBy(Guid.NewGuid())); + } + + [Fact] + public void IsHeldByAiAgent_AiAgentLock_ReturnsTrue() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid() + ); + + // Act & Assert + Assert.True(taskLock.IsHeldByAiAgent()); + } + + [Fact] + public void IsHeldByAiAgent_UserLock_ReturnsFalse() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "USER", + Guid.NewGuid(), + Guid.NewGuid() + ); + + // Act & Assert + Assert.False(taskLock.IsHeldByAiAgent()); + } + + [Fact] + public void IsHeldByUser_UserLock_ReturnsTrue() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "USER", + Guid.NewGuid(), + Guid.NewGuid() + ); + + // Act & Assert + Assert.True(taskLock.IsHeldByUser()); + } + + [Fact] + public void IsHeldByUser_AiAgentLock_ReturnsFalse() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid() + ); + + // Act & Assert + Assert.False(taskLock.IsHeldByUser()); + } + + [Fact] + public void GetRemainingTime_ActiveLock_ReturnsPositiveTimeSpan() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid(), + expirationMinutes: 5 + ); + + // Act + var remaining = taskLock.GetRemainingTime(); + + // Assert + Assert.True(remaining.TotalMinutes > 0); + Assert.True(remaining.TotalMinutes <= 5); + } + + [Fact] + public void GetRemainingTime_ExpiredLock_ReturnsZero() + { + // Arrange + var taskLock = TaskLock.Acquire( + "Epic", + Guid.NewGuid(), + "AI_AGENT", + Guid.NewGuid(), + Guid.NewGuid(), + expirationMinutes: 5 + ); + + // Use reflection to set ExpiresAt to the past + var expiresAtProperty = typeof(TaskLock).GetProperty("ExpiresAt"); + expiresAtProperty!.SetValue(taskLock, DateTime.UtcNow.AddMinutes(-1)); + + // Act + var remaining = taskLock.GetRemainingTime(); + + // Assert + Assert.Equal(TimeSpan.Zero, remaining); + } + + [Fact] + public void GetSummary_ReturnsFormattedString() + { + // Arrange + var taskLock = TaskLock.Acquire( + resourceType: "Epic", + resourceId: Guid.NewGuid(), + lockHolderType: "AI_AGENT", + lockHolderId: Guid.NewGuid(), + tenantId: Guid.NewGuid(), + lockHolderName: "Claude" + ); + + // Act + var summary = taskLock.GetSummary(); + + // Assert + Assert.Contains("Epic", summary); + Assert.Contains("Claude", summary); + Assert.Contains("AI_AGENT", summary); + Assert.Contains("Active", summary); + } +}