using ClaudeDo.Data; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Usage.Interfaces; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Usage; /// /// Builds the Hub-facing usage snapshot DTO from UsageState + IUsageGate + AppSettings /// thresholds. Shared by WorkerHub.GetUsageSnapshot and UsageMonitorService's post-poll /// broadcast so both surfaces agree on staleness/threshold logic. /// public sealed class UsageSnapshotBuilder { private readonly UsageState _state; private readonly IUsageGate _gate; private readonly IDbContextFactory _dbFactory; private readonly WorkerConfig _cfg; public UsageSnapshotBuilder( UsageState state, IUsageGate gate, IDbContextFactory dbFactory, WorkerConfig cfg) { _state = state; _gate = gate; _dbFactory = dbFactory; _cfg = cfg; } public async Task BuildAsync(CancellationToken ct = default) { var snapshot = _state.Snapshot; var lastError = _state.LastError; Data.Models.AppSettingsEntity settings; using (var context = _dbFactory.CreateDbContext()) settings = await new AppSettingsRepository(context).GetAsync(ct); var decision = await _gate.EvaluateAsync(ct); // Measured against the *slowest* cadence — the idle interval — so a genuinely idle // worker on its 15-minute heartbeat isn't reported stale just for not polling. var maxAge = TimeSpan.FromSeconds( Math.Max(_cfg.UsagePollIntervalActiveSeconds, _cfg.UsagePollIntervalIdleSeconds) * 3); var isStale = snapshot is null || lastError is not null || (DateTime.UtcNow - snapshot.FetchedAtUtc) > maxAge; var limits = (snapshot?.Limits ?? Array.Empty()) .Select(l => new UsageLimitDto(l.Kind, l.Group, l.Percent, l.Severity, l.ResetsAt, l.ScopeModelDisplayName, l.IsActive)) .ToList(); var fiveHourThresholds = new UsageThresholds( settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct, settings.UsageGateFiveHourPct); var sevenDayThresholds = new UsageThresholds( settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct, settings.UsageGateSevenDayPct); var configuredSlots = Math.Max(1, settings.MaxParallelExecutions); var effectiveSlots = snapshot is null || lastError is not null ? configuredSlots : UsageThrottle.EffectiveSlots( configuredSlots, snapshot.FiveHour?.Utilization, fiveHourThresholds, snapshot.SevenDay?.Utilization, sevenDayThresholds); var throttleBucket = effectiveSlots < configuredSlots ? DecisiveBucket(snapshot?.FiveHour?.Utilization, snapshot?.SevenDay?.Utilization) : null; return new UsageSnapshotDto( snapshot?.FiveHour?.Utilization, snapshot?.FiveHour?.ResetsAt, snapshot?.SevenDay?.Utilization, snapshot?.SevenDay?.ResetsAt, limits, settings.UsageGateFiveHourPct, settings.UsageGateSevenDayPct, decision.IsBlocked, decision.Reason, snapshot?.FetchedAtUtc, isStale, lastError, configuredSlots, effectiveSlots, throttleBucket, fiveHourThresholds.SoftPct, fiveHourThresholds.HardPct, sevenDayThresholds.SoftPct, sevenDayThresholds.HardPct); } private static string? DecisiveBucket(double? fiveHourPct, double? sevenDayPct) { if (fiveHourPct is null && sevenDayPct is null) return null; return (fiveHourPct ?? 0) >= (sevenDayPct ?? 0) ? "five_hour" : "seven_day"; } }