using AxCopilot.Models; namespace AxCopilot.Services; /// /// 드래프트 큐 항목의 정렬과 상태 전이를 담당한다. /// 이후 command queue 실행기와 직접 연결될 수 있도록 queue policy를 분리한다. /// public sealed class DraftQueueService { public sealed class DraftQueueSummary { public int TotalCount { get; init; } public int QueuedCount { get; init; } public int RunningCount { get; init; } public int BlockedCount { get; init; } public int FailedCount { get; init; } public int CompletedCount { get; init; } public DraftQueueItem? NextItem { get; init; } public DateTime? NextReadyAt { get; init; } } public DraftQueueItem CreateItem(string text, string priority = "next", string kind = "message") { return new DraftQueueItem { Text = text.Trim(), Priority = NormalizePriority(priority), Kind = string.IsNullOrWhiteSpace(kind) ? "message" : kind.Trim().ToLowerInvariant(), State = "queued", CreatedAt = DateTime.Now, }; } public DraftQueueItem? GetNextQueuedItem(IEnumerable? items, DateTime? now = null) { var at = now ?? DateTime.Now; return items? .Where(x => CanRunNow(x, at)) .OrderBy(GetPriorityRank) .ThenBy(x => x.CreatedAt) .FirstOrDefault(); } public DraftQueueSummary GetSummary(IEnumerable? items, DateTime? now = null) { var at = now ?? DateTime.Now; var snapshot = items?.ToList() ?? []; var nextReadyAt = snapshot .Where(IsBlocked) .Select(x => x.NextRetryAt) .Where(x => x.HasValue) .OrderBy(x => x) .FirstOrDefault(); return new DraftQueueSummary { TotalCount = snapshot.Count, QueuedCount = snapshot.Count(x => CanRunNow(x, at)), RunningCount = snapshot.Count(x => string.Equals(x.State, "running", StringComparison.OrdinalIgnoreCase)), BlockedCount = snapshot.Count(IsBlocked), FailedCount = snapshot.Count(x => string.Equals(x.State, "failed", StringComparison.OrdinalIgnoreCase)), CompletedCount = snapshot.Count(x => string.Equals(x.State, "completed", StringComparison.OrdinalIgnoreCase)), NextItem = GetNextQueuedItem(snapshot, at), NextReadyAt = nextReadyAt, }; } public bool MarkRunning(DraftQueueItem? item) { if (item == null) return false; item.State = "running"; item.LastError = null; item.NextRetryAt = null; item.AttemptCount++; return true; } public bool MarkCompleted(DraftQueueItem? item) { if (item == null) return false; item.State = "completed"; item.LastError = null; item.NextRetryAt = null; return true; } public bool MarkFailed(DraftQueueItem? item, string? error) { if (item == null) return false; item.State = "failed"; item.LastError = string.IsNullOrWhiteSpace(error) ? null : error.Trim(); item.NextRetryAt = null; return true; } public bool ScheduleRetry(DraftQueueItem? item, string? error, TimeSpan? delay = null) { if (item == null) return false; item.State = "queued"; item.LastError = string.IsNullOrWhiteSpace(error) ? null : error.Trim(); item.NextRetryAt = DateTime.Now.Add(delay ?? GetRetryDelay(item)); return true; } public bool ResetToQueued(DraftQueueItem? item) { if (item == null) return false; item.State = "queued"; item.LastError = null; item.NextRetryAt = null; return true; } public TimeSpan GetRetryDelay(DraftQueueItem item) { var seconds = Math.Min(300, 15 * Math.Max(1, (int)Math.Pow(2, Math.Max(0, item.AttemptCount - 1)))); return TimeSpan.FromSeconds(seconds); } public bool CanRunNow(DraftQueueItem item, DateTime? now = null) { var at = now ?? DateTime.Now; return IsQueued(item) && (!item.NextRetryAt.HasValue || item.NextRetryAt.Value <= at); } private static bool IsQueued(DraftQueueItem item) => string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase); private static bool IsBlocked(DraftQueueItem item) => IsQueued(item) && item.NextRetryAt.HasValue && item.NextRetryAt.Value > DateTime.Now; private static string NormalizePriority(string? priority) => priority switch { "now" => "now", "later" => "later", _ => "next", }; private static int GetPriorityRank(DraftQueueItem item) => NormalizePriority(item.Priority) switch { "now" => 0, "next" => 1, _ => 2, }; }