diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerState.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerState.cs new file mode 100644 index 00000000..fc2f095c --- /dev/null +++ b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerState.cs @@ -0,0 +1,55 @@ +namespace ClaudeDo.Worker.Usage.TokenTracker; + +/// +/// Threadsafe holder for the last successful export. Mirrors : a failed +/// fetch never overwrites a good export, it only records . +/// +public sealed class TokenTrackerState +{ + private readonly object _lock = new(); + private TokenTrackerExport? _export; + private DateTime? _lastAttemptUtc; + private string? _lastError; + + public TokenTrackerExport? Export + { + get { lock (_lock) return _export; } + } + + public DateTime? LastAttemptUtc + { + get { lock (_lock) return _lastAttemptUtc; } + } + + public string? LastError + { + get { lock (_lock) return _lastError; } + } + + public void ReportSuccess(TokenTrackerExport export) + { + lock (_lock) + { + _export = export; + _lastAttemptUtc = export.FetchedAtUtc; + _lastError = null; + } + } + + public void ReportFailure(string error, DateTime attemptedAtUtc) + { + lock (_lock) + { + _lastAttemptUtc = attemptedAtUtc; + _lastError = error; + } + } + + public bool IsOlderThan(TimeSpan maxAge, DateTime nowUtc) + { + lock (_lock) + { + return _export is null || nowUtc - _export.FetchedAtUtc > maxAge; + } + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerStateTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerStateTests.cs new file mode 100644 index 00000000..c0db99c9 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerStateTests.cs @@ -0,0 +1,66 @@ +using ClaudeDo.Worker.Usage.TokenTracker; + +namespace ClaudeDo.Worker.Tests.Usage.TokenTracker; + +public sealed class TokenTrackerStateTests +{ + private static TokenTrackerExport Export(DateTime fetchedAt) => + new(11, [], fetchedAt); + + [Fact] + public void FreshState_IsEmpty() + { + var state = new TokenTrackerState(); + + Assert.Null(state.Export); + Assert.Null(state.LastAttemptUtc); + Assert.Null(state.LastError); + } + + [Fact] + public void ReportSuccess_StoresExportAndClearsError() + { + var state = new TokenTrackerState(); + var at = new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc); + state.ReportFailure("boom", at.AddMinutes(-1)); + + state.ReportSuccess(Export(at)); + + Assert.NotNull(state.Export); + Assert.Equal(at, state.LastAttemptUtc); + Assert.Null(state.LastError); + } + + [Fact] + public void ReportFailure_KeepsPreviousExport() + { + var state = new TokenTrackerState(); + var good = new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc); + state.ReportSuccess(Export(good)); + + state.ReportFailure("tokentracker exited with code 1", good.AddMinutes(30)); + + Assert.NotNull(state.Export); + Assert.Equal(good, state.Export!.FetchedAtUtc); + Assert.Equal("tokentracker exited with code 1", state.LastError); + Assert.Equal(good.AddMinutes(30), state.LastAttemptUtc); + } + + [Fact] + public void IsOlderThan_NoExport_IsTrue() + { + Assert.True(new TokenTrackerState().IsOlderThan(TimeSpan.FromMinutes(15), + new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc))); + } + + [Fact] + public void IsOlderThan_ComparesAgainstFetchTime() + { + var state = new TokenTrackerState(); + var fetched = new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc); + state.ReportSuccess(Export(fetched)); + + Assert.False(state.IsOlderThan(TimeSpan.FromMinutes(15), fetched.AddMinutes(14))); + Assert.True(state.IsOlderThan(TimeSpan.FromMinutes(15), fetched.AddMinutes(16))); + } +}