diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 7f4c091d..40eb1b2c 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -21,6 +21,7 @@ Worker/ Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/ Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster) Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (OIDC discovery + refresh-token flow), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider) + Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message); interface in Usage/Interfaces/ (IUsageClient) ``` Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace. @@ -180,6 +181,7 @@ Loaded from `~/.todo-app/worker.config.json`: - `poll_interval_seconds` (int, default 60) - `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` — used by `ZitadelAuthProvider` (OIDC discovery + refresh-token flow) - The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token` +- `usage_poll_interval_seconds` (default 60, clamped to a minimum of 15 on load) — poll interval for `UsageMonitorService` Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order). diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs index afcdff9e..114fe08f 100644 --- a/src/ClaudeDo.Worker/Config/WorkerConfig.cs +++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs @@ -44,6 +44,10 @@ public sealed class WorkerConfig [JsonPropertyName("online_inbox")] public OnlineInboxConfig OnlineInbox { get; set; } = new(); + /// Poll interval for the OAuth usage monitor. Clamped to a minimum of 15s on load. + [JsonPropertyName("usage_poll_interval_seconds")] + public int UsagePollIntervalSeconds { get; set; } = 60; + public static string DefaultConfigPath => Path.Combine(Paths.AppDataRoot(), "worker.config.json"); @@ -71,6 +75,7 @@ public sealed class WorkerConfig cfg.SandboxRoot = Paths.Expand(cfg.SandboxRoot); cfg.LogRoot = Paths.Expand(cfg.LogRoot); cfg.CentralWorktreeRoot = Paths.Expand(cfg.CentralWorktreeRoot); + cfg.UsagePollIntervalSeconds = Math.Max(15, cfg.UsagePollIntervalSeconds); return cfg; } diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 2cb4f463..83ac6b6f 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -19,6 +19,8 @@ using ClaudeDo.Worker.Refine; using ClaudeDo.Worker.Report; using ClaudeDo.Worker.Report.Interfaces; using ClaudeDo.Worker.Skills; +using ClaudeDo.Worker.Usage; +using ClaudeDo.Worker.Usage.Interfaces; using ClaudeDo.Worker.Worktrees; using Microsoft.EntityFrameworkCore; using Serilog; @@ -193,6 +195,15 @@ if (cfg.OnlineInbox.Enabled) builder.Services.AddHostedService(); } +// OAuth usage monitor: reads the access token Claude Code keeps fresh in +// ~/.claude/.credentials.json and polls Anthropic's usage endpoint. +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(client => +{ + client.Timeout = TimeSpan.FromSeconds(5); +}); +builder.Services.AddHostedService(); + // Loopback-only bind. Firewall is irrelevant for 127.0.0.1. builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}"); diff --git a/src/ClaudeDo.Worker/Usage/ClaudeOAuthUsageClient.cs b/src/ClaudeDo.Worker/Usage/ClaudeOAuthUsageClient.cs new file mode 100644 index 00000000..1367fb62 --- /dev/null +++ b/src/ClaudeDo.Worker/Usage/ClaudeOAuthUsageClient.cs @@ -0,0 +1,164 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Nodes; +using ClaudeDo.Data; +using ClaudeDo.Worker.Usage.Interfaces; + +namespace ClaudeDo.Worker.Usage; + +/// +/// Fetches the current OAuth usage/limit snapshot from Anthropic using the access token +/// Claude Code keeps fresh in ~/.claude/.credentials.json. Never refreshes the token +/// itself, and never logs or surfaces the token value. +/// +public sealed class ClaudeOAuthUsageClient : IUsageClient +{ + private const string UsageUrl = "https://api.anthropic.com/api/oauth/usage"; + + private readonly HttpClient _http; + private readonly string _credentialsPath; + + public ClaudeOAuthUsageClient(HttpClient http) + : this(http, Path.Combine(Paths.Expand("~/.claude"), ".credentials.json")) { } + + internal ClaudeOAuthUsageClient(HttpClient http, string credentialsPath) + { + _http = http; + _credentialsPath = credentialsPath; + } + + public async Task GetUsageAsync(CancellationToken ct = default) + { + var token = ReadAccessToken(); + + using var request = new HttpRequestMessage(HttpMethod.Get, UsageUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.Add("anthropic-beta", "oauth-2025-04-20"); + + using var response = await _http.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException($"Usage request failed with status {(int)response.StatusCode}."); + + var body = await response.Content.ReadAsStringAsync(ct); + return Parse(body); + } + + private string ReadAccessToken() + { + if (!File.Exists(_credentialsPath)) + throw new InvalidOperationException($"Claude credentials file not found at '{_credentialsPath}'."); + + string json; + try + { + json = File.ReadAllText(_credentialsPath); + } + catch (IOException ex) + { + throw new InvalidOperationException($"Failed to read Claude credentials file: {ex.Message}", ex); + } + + JsonNode? root; + try + { + root = JsonNode.Parse(json); + } + catch (JsonException ex) + { + throw new InvalidOperationException($"Failed to parse Claude credentials file: {ex.Message}", ex); + } + + var token = root?["claudeAiOauth"]?["accessToken"]?.GetValue(); + if (string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException("Claude credentials file has no accessToken."); + + return token; + } + + /// + /// Defensive parse of the usage response: missing/null buckets become null, missing + /// limits becomes an empty list, and unknown fields are ignored. Only an + /// unparsable body throws. + /// + internal static UsageSnapshot Parse(string json) + { + JsonNode? root; + try + { + root = JsonNode.Parse(json); + } + catch (JsonException ex) + { + throw new InvalidOperationException($"Failed to parse usage response: {ex.Message}", ex); + } + + if (root is not JsonObject obj) + throw new InvalidOperationException("Usage response was not a JSON object."); + + try + { + var fiveHour = ParseBucket(obj["five_hour"]); + var sevenDay = ParseBucket(obj["seven_day"]); + var limits = ParseLimits(obj["limits"] as JsonArray); + + return new UsageSnapshot(fiveHour, sevenDay, limits, DateTime.UtcNow); + } + catch (InvalidOperationException) + { + throw; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to parse usage response: {ex.Message}", ex); + } + } + + private static UsageBucket? ParseBucket(JsonNode? node) + { + if (node is not JsonObject obj) + return null; + + var utilization = TryGetDouble(obj["utilization"]) ?? 0; + var resetsAt = TryGetDateTimeOffset(obj["resets_at"]); + return new UsageBucket(utilization, resetsAt); + } + + private static List ParseLimits(JsonArray? array) + { + var result = new List(); + if (array is null) + return result; + + foreach (var item in array) + { + if (item is not JsonObject row) + continue; + + var kind = TryGetString(row["kind"]) ?? ""; + var group = TryGetString(row["group"]) ?? ""; + var percent = TryGetDouble(row["percent"]) ?? 0; + var severity = TryGetString(row["severity"]) ?? ""; + var resetsAt = TryGetDateTimeOffset(row["resets_at"]); + var scopeModelDisplayName = TryGetString((row["scope"] as JsonObject)?["model"]?["display_name"]); + var isActive = TryGetBool(row["is_active"]) ?? false; + + result.Add(new UsageLimitRow(kind, group, percent, severity, resetsAt, scopeModelDisplayName, isActive)); + } + + return result; + } + + private static string? TryGetString(JsonNode? node) => + node is JsonValue value && value.TryGetValue(out string? s) ? s : null; + + private static double? TryGetDouble(JsonNode? node) => + node is JsonValue value && value.TryGetValue(out double d) ? d : null; + + private static bool? TryGetBool(JsonNode? node) => + node is JsonValue value && value.TryGetValue(out bool b) ? b : null; + + private static DateTimeOffset? TryGetDateTimeOffset(JsonNode? node) => + node is JsonValue value && value.TryGetValue(out string? s) && DateTimeOffset.TryParse(s, out var dto) + ? dto + : null; +} diff --git a/src/ClaudeDo.Worker/Usage/Interfaces/IUsageClient.cs b/src/ClaudeDo.Worker/Usage/Interfaces/IUsageClient.cs new file mode 100644 index 00000000..03e7ef04 --- /dev/null +++ b/src/ClaudeDo.Worker/Usage/Interfaces/IUsageClient.cs @@ -0,0 +1,6 @@ +namespace ClaudeDo.Worker.Usage.Interfaces; + +public interface IUsageClient +{ + Task GetUsageAsync(CancellationToken ct = default); +} diff --git a/src/ClaudeDo.Worker/Usage/UsageModels.cs b/src/ClaudeDo.Worker/Usage/UsageModels.cs new file mode 100644 index 00000000..dfd662b7 --- /dev/null +++ b/src/ClaudeDo.Worker/Usage/UsageModels.cs @@ -0,0 +1,18 @@ +namespace ClaudeDo.Worker.Usage; + +public sealed record UsageBucket(double Utilization, DateTimeOffset? ResetsAt); + +public sealed record UsageLimitRow( + string Kind, + string Group, + double Percent, + string Severity, + DateTimeOffset? ResetsAt, + string? ScopeModelDisplayName, + bool IsActive); + +public sealed record UsageSnapshot( + UsageBucket? FiveHour, + UsageBucket? SevenDay, + IReadOnlyList Limits, + DateTime FetchedAtUtc); diff --git a/src/ClaudeDo.Worker/Usage/UsageMonitorService.cs b/src/ClaudeDo.Worker/Usage/UsageMonitorService.cs new file mode 100644 index 00000000..43fe314b --- /dev/null +++ b/src/ClaudeDo.Worker/Usage/UsageMonitorService.cs @@ -0,0 +1,68 @@ +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Usage.Interfaces; + +namespace ClaudeDo.Worker.Usage; + +/// +/// Polls on and keeps +/// current. Polls once immediately at startup. A failure is logged as a +/// warning at most once per distinct error message, to avoid log spam on a persistent outage. +/// +public sealed class UsageMonitorService : BackgroundService +{ + private readonly IUsageClient _client; + private readonly UsageState _state; + private readonly WorkerConfig _config; + private readonly ILogger _logger; + private string? _lastLoggedError; + + public UsageMonitorService( + IUsageClient client, UsageState state, WorkerConfig config, ILogger logger) + { + _client = client; + _state = state; + _config = config; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + await TickAsync(stoppingToken); + + try + { + await Task.Delay(TimeSpan.FromSeconds(_config.UsagePollIntervalSeconds), stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + } + + internal async Task TickAsync(CancellationToken ct) + { + try + { + var snapshot = await _client.GetUsageAsync(ct); + _state.ReportSuccess(snapshot); + _lastLoggedError = null; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _state.ReportFailure(ex.Message, DateTime.UtcNow); + + if (_lastLoggedError != ex.Message) + { + _logger.LogWarning(ex, "UsageMonitorService: failed to fetch usage: {Message}", ex.Message); + _lastLoggedError = ex.Message; + } + } + } +} diff --git a/src/ClaudeDo.Worker/Usage/UsageState.cs b/src/ClaudeDo.Worker/Usage/UsageState.cs new file mode 100644 index 00000000..7b22e47c --- /dev/null +++ b/src/ClaudeDo.Worker/Usage/UsageState.cs @@ -0,0 +1,47 @@ +namespace ClaudeDo.Worker.Usage; + +/// +/// Threadsafe holder for the last successful usage snapshot. A failed poll never overwrites +/// a good snapshot — it only records , leaving the existing snapshot stale. +/// +public sealed class UsageState +{ + private readonly object _lock = new(); + private UsageSnapshot? _snapshot; + private DateTime? _lastAttemptUtc; + private string? _lastError; + + public UsageSnapshot? Snapshot + { + get { lock (_lock) return _snapshot; } + } + + public DateTime? LastAttemptUtc + { + get { lock (_lock) return _lastAttemptUtc; } + } + + public string? LastError + { + get { lock (_lock) return _lastError; } + } + + public void ReportSuccess(UsageSnapshot snapshot) + { + lock (_lock) + { + _snapshot = snapshot; + _lastAttemptUtc = snapshot.FetchedAtUtc; + _lastError = null; + } + } + + public void ReportFailure(string error, DateTime attemptedAtUtc) + { + lock (_lock) + { + _lastAttemptUtc = attemptedAtUtc; + _lastError = error; + } + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Usage/ClaudeOAuthUsageClientTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/ClaudeOAuthUsageClientTests.cs new file mode 100644 index 00000000..e2e0a721 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Usage/ClaudeOAuthUsageClientTests.cs @@ -0,0 +1,182 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using ClaudeDo.Worker.Usage; + +namespace ClaudeDo.Worker.Tests.Usage; + +/// +/// Tests for using a stubbed +/// and a temp credentials file. No real network call and no real credentials file are touched. +/// +public sealed class ClaudeOAuthUsageClientTests : IDisposable +{ + // Real fixture from the Anthropic OAuth usage endpoint, trimmed to the relevant fields. + private const string RealFixture = """ + { + "five_hour": { "utilization": 30.0, "resets_at": "2026-08-05T12:59:59.62334+02:00", "limit_dollars": null }, + "seven_day": { "utilization": 57.0, "resets_at": "2026-08-06T10:59:59.62336+02:00" }, + "seven_day_opus": null, "seven_day_sonnet": null, "seven_day_oauth_apps": null, + "extra_usage": { "is_enabled": false }, + "limits": [ + { "kind": "session", "group": "session", "percent": 30, "severity": "normal", "resets_at": "2026-08-05T12:59:59.62334+02:00", "scope": null, "is_active": false }, + { "kind": "weekly_all", "group": "weekly", "percent": 57, "severity": "normal", "resets_at": "2026-08-06T10:59:59.62336+02:00", "scope": null, "is_active": true }, + { "kind": "weekly_scoped", "group": "weekly", "percent": 14, "severity": "normal", "resets_at": "2026-08-06T10:59:59.623573+02:00", "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, "is_active": false } + ], + "spend": { "percent": 0, "enabled": false }, + "member_dashboard_available": false + } + """; + + private readonly string _credentialsPath = Path.Combine(Path.GetTempPath(), $"claudedo-usage-test-{Guid.NewGuid():N}.json"); + + public void Dispose() + { + if (File.Exists(_credentialsPath)) + File.Delete(_credentialsPath); + } + + private void WriteCredentials(string accessToken = "test-access-token") => + File.WriteAllText(_credentialsPath, $$"""{ "claudeAiOauth": { "accessToken": "{{accessToken}}" } }"""); + + private sealed class StubHandler : HttpMessageHandler + { + public List Requests { get; } = new(); + public HttpStatusCode ResponseStatus { get; set; } = HttpStatusCode.OK; + public string ResponseBody { get; set; } = "{}"; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Requests.Add(request); + var resp = new HttpResponseMessage(ResponseStatus) + { + Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json"), + }; + return Task.FromResult(resp); + } + } + + private (ClaudeOAuthUsageClient Client, StubHandler Handler) Build() + { + var handler = new StubHandler(); + var http = new HttpClient(handler); + return (new ClaudeOAuthUsageClient(http, _credentialsPath), handler); + } + + [Fact] + public async Task GetUsageAsync_ParsesRealFixture() + { + WriteCredentials(); + var (client, handler) = Build(); + handler.ResponseBody = RealFixture; + + var snapshot = await client.GetUsageAsync(); + + Assert.NotNull(snapshot.FiveHour); + Assert.Equal(30, snapshot.FiveHour!.Utilization); + Assert.NotNull(snapshot.SevenDay); + Assert.Equal(57, snapshot.SevenDay!.Utilization); + Assert.Equal(3, snapshot.Limits.Count); + + var scoped = snapshot.Limits[2]; + Assert.Equal("weekly_scoped", scoped.Kind); + Assert.Equal("Fable", scoped.ScopeModelDisplayName); + } + + [Fact] + public async Task GetUsageAsync_SendsBearerTokenAndBetaHeader() + { + WriteCredentials("my-secret-token"); + var (client, handler) = Build(); + handler.ResponseBody = RealFixture; + + await client.GetUsageAsync(); + + var request = handler.Requests[0]; + Assert.Equal("Bearer", request.Headers.Authorization!.Scheme); + Assert.Equal("my-secret-token", request.Headers.Authorization!.Parameter); + Assert.Contains("oauth-2025-04-20", request.Headers.GetValues("anthropic-beta")); + Assert.Equal("https://api.anthropic.com/api/oauth/usage", request.RequestUri!.ToString()); + } + + [Fact] + public async Task GetUsageAsync_MissingFiveHour_ReturnsNull() + { + WriteCredentials(); + var (client, handler) = Build(); + handler.ResponseBody = """{ "seven_day": { "utilization": 12.0 } }"""; + + var snapshot = await client.GetUsageAsync(); + + Assert.Null(snapshot.FiveHour); + Assert.NotNull(snapshot.SevenDay); + Assert.Empty(snapshot.Limits); + } + + [Fact] + public async Task GetUsageAsync_NullFiveHour_ReturnsNull() + { + WriteCredentials(); + var (client, handler) = Build(); + handler.ResponseBody = """{ "five_hour": null, "seven_day": null }"""; + + var snapshot = await client.GetUsageAsync(); + + Assert.Null(snapshot.FiveHour); + Assert.Null(snapshot.SevenDay); + } + + [Fact] + public async Task GetUsageAsync_MissingLimits_ReturnsEmptyList() + { + WriteCredentials(); + var (client, handler) = Build(); + handler.ResponseBody = "{}"; + + var snapshot = await client.GetUsageAsync(); + + Assert.Empty(snapshot.Limits); + } + + [Fact] + public async Task GetUsageAsync_GarbageBody_ThrowsWithoutLeakingToken() + { + WriteCredentials("super-secret-token"); + var (client, handler) = Build(); + handler.ResponseBody = "not json at all {{{"; + + var ex = await Assert.ThrowsAsync(() => client.GetUsageAsync()); + + Assert.DoesNotContain("super-secret-token", ex.Message); + } + + [Fact] + public async Task GetUsageAsync_Http401_ThrowsWithoutLeakingToken() + { + WriteCredentials("super-secret-token"); + var (client, handler) = Build(); + handler.ResponseStatus = HttpStatusCode.Unauthorized; + handler.ResponseBody = "unauthorized"; + + var ex = await Assert.ThrowsAsync(() => client.GetUsageAsync()); + + Assert.DoesNotContain("super-secret-token", ex.Message); + } + + [Fact] + public async Task GetUsageAsync_MissingCredentialsFile_Throws() + { + var (client, _) = Build(); // credentials file was never written + + await Assert.ThrowsAsync(() => client.GetUsageAsync()); + } + + [Fact] + public async Task GetUsageAsync_NoAccessTokenInCredentials_Throws() + { + File.WriteAllText(_credentialsPath, """{ "claudeAiOauth": {} }"""); + var (client, _) = Build(); + + await Assert.ThrowsAsync(() => client.GetUsageAsync()); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Usage/UsageMonitorServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/UsageMonitorServiceTests.cs new file mode 100644 index 00000000..ebd06965 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Usage/UsageMonitorServiceTests.cs @@ -0,0 +1,68 @@ +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Usage; +using ClaudeDo.Worker.Usage.Interfaces; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ClaudeDo.Worker.Tests.Usage; + +public sealed class UsageMonitorServiceTests +{ + private sealed class FakeClient : IUsageClient + { + public Queue> Results { get; } = new(); + public int CallCount { get; private set; } + + public Task GetUsageAsync(CancellationToken ct = default) + { + CallCount++; + var next = Results.Count > 0 ? Results.Dequeue() : () => throw new InvalidOperationException("no result queued"); + return Task.FromResult(next()); + } + } + + private static UsageSnapshot MakeSnapshot() => new(new UsageBucket(1, null), null, [], DateTime.UtcNow); + + [Fact] + public async Task TickAsync_Success_UpdatesState() + { + var client = new FakeClient(); + client.Results.Enqueue(MakeSnapshot); + var state = new UsageState(); + var service = new UsageMonitorService(client, state, new WorkerConfig(), NullLogger.Instance); + + await service.TickAsync(CancellationToken.None); + + Assert.NotNull(state.Snapshot); + Assert.Null(state.LastError); + } + + [Fact] + public async Task TickAsync_Failure_SetsErrorButDoesNotThrow() + { + var client = new FakeClient(); + client.Results.Enqueue(() => throw new InvalidOperationException("network unreachable")); + var state = new UsageState(); + var service = new UsageMonitorService(client, state, new WorkerConfig(), NullLogger.Instance); + + await service.TickAsync(CancellationToken.None); + + Assert.Equal("network unreachable", state.LastError); + Assert.Null(state.Snapshot); + } + + [Fact] + public async Task TickAsync_FailureAfterSuccess_KeepsOldSnapshot() + { + var client = new FakeClient(); + client.Results.Enqueue(MakeSnapshot); + client.Results.Enqueue(() => throw new InvalidOperationException("down")); + var state = new UsageState(); + var service = new UsageMonitorService(client, state, new WorkerConfig(), NullLogger.Instance); + + await service.TickAsync(CancellationToken.None); + await service.TickAsync(CancellationToken.None); + + Assert.NotNull(state.Snapshot); + Assert.Equal("down", state.LastError); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Usage/UsageStateTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/UsageStateTests.cs new file mode 100644 index 00000000..09017b64 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Usage/UsageStateTests.cs @@ -0,0 +1,48 @@ +using ClaudeDo.Worker.Usage; + +namespace ClaudeDo.Worker.Tests.Usage; + +public sealed class UsageStateTests +{ + private static UsageSnapshot MakeSnapshot(double fiveHourUtilization) => new( + new UsageBucket(fiveHourUtilization, null), + null, + [], + DateTime.UtcNow); + + [Fact] + public void ReportSuccess_SetsSnapshotAndClearsError() + { + var state = new UsageState(); + state.ReportFailure("boom", DateTime.UtcNow); + + state.ReportSuccess(MakeSnapshot(42)); + + Assert.NotNull(state.Snapshot); + Assert.Equal(42, state.Snapshot!.FiveHour!.Utilization); + Assert.Null(state.LastError); + } + + [Fact] + public void ReportFailure_AfterSuccess_KeepsOldSnapshotButSetsError() + { + var state = new UsageState(); + state.ReportSuccess(MakeSnapshot(10)); + + state.ReportFailure("network down", DateTime.UtcNow); + + Assert.NotNull(state.Snapshot); + Assert.Equal(10, state.Snapshot!.FiveHour!.Utilization); + Assert.Equal("network down", state.LastError); + } + + [Fact] + public void NoAttemptYet_SnapshotAndErrorAreNull() + { + var state = new UsageState(); + + Assert.Null(state.Snapshot); + Assert.Null(state.LastError); + Assert.Null(state.LastAttemptUtc); + } +}