- IUsageRepository.GetDailyByAgentAsync: Tagessummen je Agent, Abo-Tokens getrennt - UnitCosts: je Einheit samt Untereinheiten heute/7/30 Tage, Monat, Vormonat, Durchschnittstag (28 Tage, ab 7 Tagen Verlauf), Maximum = Summe der Agentengrenzen - Meldungen: Abweichung vom eigenen Verlauf, Naehe zur Grenzsumme, Monatsschwellen der Instanz; CostWatch alle 15 min, je Einheit/Tag einmal (Tabelle CostAlerts), als Aufgabe an den Menschen; schaltet nichts ab - API /usage/units, /usage/cost-alerts; Zeitreihe gruppiert nach Einheit - WebUI: Karte "Kosten je Einheit" mit Meldungen in der Verbrauchsansicht; i18n en/de - Einstellungen instance.json costWatch - Tests: UnitCostsTests (8) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
5.8 KiB
C#
135 lines
5.8 KiB
C#
using ClawdDotNet.Core.Organisation;
|
|
using ClawdDotNet.Core.Storage;
|
|
using ClawdDotNet.Core.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ClawdDotNet.Core.Accounting;
|
|
|
|
/// <summary>#699: An alert as it was raised — for the usage view.</summary>
|
|
public sealed record RaisedCostAlert(string Period, string Scope, CostAlertKind Kind, string Message, DateTime RaisedAt);
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
public sealed class CostWatch(
|
|
SqliteStorage storage,
|
|
IUsageRepository usage,
|
|
Func<OrgChart> organisation,
|
|
Func<CostWatchSettings> 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;
|
|
|
|
/// <summary>The day the budget counts — the same local day as the BudgetGuard's.</summary>
|
|
private DateOnly Today => DateOnly.FromDateTime(_clock.GetLocalNow().DateTime);
|
|
|
|
public async Task<IReadOnlyList<UnitCostLine>> 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);
|
|
}
|
|
|
|
/// <summary>Raises what is new today. Returns the alerts raised in this pass.</summary>
|
|
public async Task<IReadOnlyList<CostAlert>> CheckAsync(CancellationToken ct)
|
|
{
|
|
if (!settings().Enabled)
|
|
return [];
|
|
|
|
var raised = new List<CostAlert>();
|
|
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<IReadOnlyList<RaisedCostAlert>> 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<RaisedCostAlert>();
|
|
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<CostAlertKind>(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<bool> 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();
|
|
}
|
|
}
|