using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tasks; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Core.Accounting; /// #699: An alert as it was raised — for the usage view. public sealed record RaisedCostAlert(string Period, string Scope, CostAlertKind Kind, string Message, DateTime RaisedAt); /// /// #699: Looks at the costs of the units every few minutes and raises what changed — once per /// day and unit (a monthly threshold once per month). An alert goes to the human as a task and /// stays in the usage view; it switches nothing off. /// public sealed class CostWatch( SqliteStorage storage, IUsageRepository usage, Func organisation, Func settings, TaskboardService? board, ILogger logger, TimeProvider? clock = null) : IAsyncDisposable { public static readonly TimeSpan Interval = TimeSpan.FromMinutes(15); private readonly TimeProvider _clock = clock ?? TimeProvider.System; private readonly CancellationTokenSource _cts = new(); private Task? _loop; /// The day the budget counts — the same local day as the BudgetGuard's. private DateOnly Today => DateOnly.FromDateTime(_clock.GetLocalNow().DateTime); public async Task> ReportAsync(CancellationToken ct) { var today = Today; var rows = await usage.GetDailyByAgentAsync(new DateOnly(today.Year, today.Month, 1).AddMonths(-1).AddDays(-UnitCosts.AverageDays), today, ct); return UnitCosts.Build(organisation(), rows, today); } /// Raises what is new today. Returns the alerts raised in this pass. public async Task> CheckAsync(CancellationToken ct) { if (!settings().Enabled) return []; var raised = new List(); foreach (var alert in UnitCosts.Alerts(await ReportAsync(ct), settings(), Today)) { var period = alert.Kind == CostAlertKind.MonthlyThreshold ? alert.Date.ToString("yyyy-MM") : alert.Date.ToString("yyyy-MM-dd"); if (!await TryRecordAsync(period, alert, ct)) continue; raised.Add(alert); logger.LogWarning("Cost alert ({Kind}, {Scope}): {Message}", alert.Kind, alert.Scope, alert.Message); if (board is not null) await board.CreateAsync(new TaskItem { Title = alert.Kind switch { CostAlertKind.Deviation => $"Cost alert: unusual day for {alert.Scope}", CostAlertKind.NearLimit => $"Cost alert: {alert.Scope} near its limits", _ => "Cost alert: monthly total crossed" }, Body = alert.Message + "\n\nLook at the usage view (costs by unit). Nothing was switched off.", Assignee = TaskAssignee.Human, Priority = alert.Kind == CostAlertKind.NearLimit ? 4 : 3 }, ct); } return raised; } public async Task> RecentAsync(int days, CancellationToken ct) { await using var conn = await storage.OpenConnectionAsync(ct); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT Period, Scope, Kind, Message, RaisedAt FROM CostAlerts WHERE RaisedAt >= @since ORDER BY RaisedAt DESC LIMIT 200"; cmd.Parameters.AddWithValue("@since", _clock.GetUtcNow().UtcDateTime.AddDays(-Math.Max(1, days)).ToString("O")); var result = new List(); await using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) result.Add(new RaisedCostAlert(reader.GetString(0), reader.GetString(1), Enum.TryParse(reader.GetString(2), out var k) ? k : CostAlertKind.Deviation, reader.GetString(3), DateTime.Parse(reader.GetString(4), null, System.Globalization.DateTimeStyles.RoundtripKind))); return result; } private Task TryRecordAsync(string period, CostAlert alert, CancellationToken ct) => storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = """ INSERT OR IGNORE INTO CostAlerts (Period, Scope, Kind, Message, RaisedAt) VALUES (@period, @scope, @kind, @message, @at) """; cmd.Parameters.AddWithValue("@period", period); cmd.Parameters.AddWithValue("@scope", alert.Scope); cmd.Parameters.AddWithValue("@kind", alert.Kind.ToString()); cmd.Parameters.AddWithValue("@message", alert.Message); cmd.Parameters.AddWithValue("@at", _clock.GetUtcNow().UtcDateTime.ToString("O")); return await cmd.ExecuteNonQueryAsync(ct) == 1; }, ct); public void Start() => _loop ??= RunAsync(_cts.Token); private async Task RunAsync(CancellationToken ct) { try { await Task.Delay(TimeSpan.FromMinutes(1), ct); } catch (OperationCanceledException) { return; } while (!ct.IsCancellationRequested) { try { await CheckAsync(ct); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogWarning(ex, "Cost watch failed — next attempt in {Minutes} min", Interval.TotalMinutes); } try { await Task.Delay(Interval, ct); } catch (OperationCanceledException) { return; } } } public async ValueTask DisposeAsync() { await _cts.CancelAsync(); if (_loop is not null) { try { await _loop; } catch (OperationCanceledException) { } } _cts.Dispose(); } }