Kosten je Einheit aufrechnen und Abweichungen melden (#699)

- 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>
This commit is contained in:
Richard
2026-10-01 09:09:42 +02:00
co-authored by Claude Opus 5.5
parent 7eb211fd06
commit da34b215f7
15 changed files with 667 additions and 2 deletions
@@ -0,0 +1,134 @@
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();
}
}