diff --git a/.github/skills/add-extension-settings/SKILL.md b/.github/skills/add-extension-settings/SKILL.md index da23573..d933c6c 100644 --- a/.github/skills/add-extension-settings/SKILL.md +++ b/.github/skills/add-extension-settings/SKILL.md @@ -9,7 +9,7 @@ description: >- # Add Extension Settings -Add a settings page to your Command Palette extension using the built-in settings helpers. Settings are automatically persisted and restored by the extension host. +Add a settings page to your Command Palette extension using the built-in settings helpers. These helpers hold form values in memory; the extension must explicitly load and save them to persist choices across restarts. ## When to Use This Skill @@ -23,7 +23,9 @@ Add a settings page to your Command Palette extension using the built-in setting ### Step 1: Create a Settings Manager -Create a new file `SettingsManager.cs`: +The following example illustrates wiring settings only; it is not a complete persistent settings manager. In this repository, extend the existing [CodexUsageDockSettingsPage](../../../CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs) and use [LocalStorage](../../../CodexUsageDock/LocalStorage.cs). Load validated known keys after adding defaults but before subscribing to change events. Save on changes, report failures, and apply loaded choices before starting background work. Test a new instance against the same temporary file and a failed save followed by a retry. + +For another extension, a minimal in-memory `SettingsManager.cs` starts as follows: ```csharp using Microsoft.CommandPalette.Extensions; @@ -142,7 +144,7 @@ internal sealed partial class MyPage : ListPage ## Key Points -- Settings are automatically persisted by the CmdPal host +- Persistence requires explicit load/save logic; exposing settings to CmdPal does not save them - Use `SettingsChanged` event to react to changes in real-time - Access values via `GetSetting(id)` with the setting's string id - Pass the settings manager to pages/commands that need configuration diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e6b13a..b10acf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ Each entry links to the commit or pull request that introduced the change. ## [Unreleased] +## [0.6.1] - 2026-09-09 + +### Fixed + +- Dock visibility, reset-time, refresh-interval, and adaptive-forecast settings now persist per Windows user and are applied before the first refresh. Failed saves are shown on the settings page. ([PR #17](https://github.com/TheBeems/CodexUsageDock/pull/17)) +- The main status now identifies the most restrictive active quota window, including an exhausted weekly allowance. ([PR #17](https://github.com/TheBeems/CodexUsageDock/pull/17)) +- Local fallback data now uses the newest valid quota event timestamp across active and archived sessions, preserves explicitly inactive windows, skips unreadable files, and shows measurement age in the Dock. ([PR #17](https://github.com/TheBeems/CodexUsageDock/pull/17)) +- Live limits now update before optional token analysis finishes. Token scans are cancellable, cannot overlap, and cannot publish results for a superseded quota snapshot. ([PR #17](https://github.com/TheBeems/CodexUsageDock/pull/17)) +- Text and chart forecasts now use the same most recent continuous measurement segment, pausing after gaps or stale data. ([PR #17](https://github.com/TheBeems/CodexUsageDock/pull/17)) +- Invalid and null history entries no longer prevent startup. Local storage failures are reported, and deleting learned history reports success only after the cleared state is saved. ([PR #17](https://github.com/TheBeems/CodexUsageDock/pull/17)) + +### Changed + +- Tests now require isolated temporary settings and history storage, preventing synthetic measurements from reaching the user's saved history. ([PR #17](https://github.com/TheBeems/CodexUsageDock/pull/17)) +- Local storage paths and forecast analysis are shared helpers; local session readers consistently honor `CODEX_HOME`. ([PR #17](https://github.com/TheBeems/CodexUsageDock/pull/17)) + ## [0.6.0] - 2026-07-21 ### Added @@ -134,7 +150,8 @@ Each entry links to the commit or pull request that introduced the change. - Initial release of the Windows Command Palette extension for viewing local Codex usage. [commit ac72fe5](https://github.com/TheBeems/CodexUsageDock/commit/ac72fe50fcd1af36f41cda896f1d792899573351) - Automated release installer creation and smoke-test handling. [commit 64a3305](https://github.com/TheBeems/CodexUsageDock/commit/64a33058b7486dea12f026561c545b362eb2d622), [commit 21790a1](https://github.com/TheBeems/CodexUsageDock/commit/21790a1ea60a9bfec14ec578d77356c5576472fb) -[Unreleased]: https://github.com/TheBeems/CodexUsageDock/compare/eed6505c5fef9a4a6a5c39d6bd5bff619aa13f07...main +[Unreleased]: https://github.com/TheBeems/CodexUsageDock/compare/v0.6.1...main +[0.6.1]: https://github.com/TheBeems/CodexUsageDock/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/TheBeems/CodexUsageDock/commit/eed6505c5fef9a4a6a5c39d6bd5bff619aa13f07 [0.5.3]: https://github.com/TheBeems/CodexUsageDock/commit/312dc394ff51648f1b063cd39afdfabaf33d8a3e [0.5.2]: https://github.com/TheBeems/CodexUsageDock/releases/tag/v0.5.2 diff --git a/CodexUsageDock.Tests/LocalCodexTokenUsageReaderTests.cs b/CodexUsageDock.Tests/LocalCodexTokenUsageReaderTests.cs index 14567db..eccb4d1 100644 --- a/CodexUsageDock.Tests/LocalCodexTokenUsageReaderTests.cs +++ b/CodexUsageDock.Tests/LocalCodexTokenUsageReaderTests.cs @@ -3,8 +3,12 @@ namespace CodexUsageDock.Tests; -public sealed class LocalCodexTokenUsageReaderTests +public sealed class LocalCodexTokenUsageReaderTests : IDisposable { + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + [Fact] public async Task ReaderAggregatesActiveAndArchivedLogsAndDeduplicatesCopiedHistory() { @@ -141,12 +145,13 @@ public async Task ServicePublishesTokenUsageWithoutMakingAllowanceDependOnIt() [new DailyTokenUsage(DateOnly.FromDateTime(now.Date), 123_456)], now, LocalTokenUsageStatus.Complete); - using var service = new CodexUsageService( + using var service = _environment.CreateService( _ => Task.FromResult(snapshot), () => snapshot, localTokenUsageReader: (_, _, _, _) => Task.FromResult(expected)); await service.RefreshAsync(); + await service.TokenRefreshTask; Assert.Equal(UsageDataSource.AppServer, service.Current.Source); Assert.Same(expected, service.CurrentTokenUsage); @@ -163,12 +168,13 @@ public async Task ServiceKeepsAllowanceWhenTokenUsageReadFails() Source = UsageDataSource.AppServer, Error = null, }; - using var service = new CodexUsageService( + using var service = _environment.CreateService( _ => Task.FromResult(snapshot), () => snapshot, localTokenUsageReader: (_, _, _, _) => Task.FromException(new IOException("test failure"))); await service.RefreshAsync(); + await service.TokenRefreshTask; Assert.Equal(UsageDataSource.AppServer, service.Current.Source); Assert.Equal(LocalTokenUsageStatus.Unavailable, service.CurrentTokenUsage.Status); diff --git a/CodexUsageDock.Tests/ReliabilityTests.cs b/CodexUsageDock.Tests/ReliabilityTests.cs new file mode 100644 index 0000000..b1ec88d --- /dev/null +++ b/CodexUsageDock.Tests/ReliabilityTests.cs @@ -0,0 +1,303 @@ +using System.Text.Json; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class ReliabilityTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + private static void SubmitSettings(CodexUsageDockSettingsPage page, string interval = "15") + { + var form = page.GetContent().OfType().Last(); + form.SubmitForm(""" + {"showFiveHourLimit":"false","showWeeklyLimit":"true","showResetsAndCredits":"false", + "showResetTime":"false","useAdaptiveWeeklyForecast":"false","refreshInterval":"INTERVAL"} + """.Replace("INTERVAL", interval, StringComparison.Ordinal), "{}"); + } + + [Fact] + public void SettingsPersistAcrossNewPages() + { + var first = _environment.CreateSettings(); + SubmitSettings(first); + Assert.False(first.ShowFiveHourLimit); + Assert.Null(first.StatusMessage); + var restarted = _environment.CreateSettings(); + Assert.False(restarted.ShowFiveHourLimit); + Assert.True(restarted.ShowWeeklyLimit); + Assert.False(restarted.ShowResetsAndCredits); + Assert.False(restarted.ShowResetTime); + Assert.False(restarted.UseAdaptiveWeeklyForecast); + Assert.Equal(TimeSpan.FromMinutes(15), restarted.RefreshInterval); + } + + [Fact] + public void SettingsReportFailedSaveAndSupportRetry() + { + var settings = _environment.CreateSettings(); + SubmitSettings(settings, "5"); + var path = _environment.PathFor("settings.json"); + using (var held = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + SubmitSettings(settings); + Assert.Equal(TimeSpan.FromMinutes(15), settings.RefreshInterval); + Assert.Contains("could not be saved", settings.StatusMessage, StringComparison.Ordinal); + Assert.Equal(TimeSpan.FromMinutes(5), _environment.CreateSettings().RefreshInterval); + } + + SubmitSettings(settings); + Assert.Null(settings.StatusMessage); + Assert.Equal(TimeSpan.FromMinutes(15), _environment.CreateSettings().RefreshInterval); + } + + [Theory] + [InlineData("[null]")] + [InlineData("not json")] + public void SettingsRecoverFromInvalidDocuments(string json) + { + File.WriteAllText(_environment.PathFor("settings.json"), json); + var settings = _environment.CreateSettings(); + Assert.True(settings.ShowFiveHourLimit); + Assert.Equal(TimeSpan.FromMinutes(1), settings.RefreshInterval); + Assert.NotNull(settings.StatusMessage); + } + + [Fact] + public void SettingsIgnoreInvalidFieldsAndPreserveValidChoices() + { + File.WriteAllText(_environment.PathFor("settings.json"), """ + {"showFiveHourLimit":"false","showWeeklyLimit":null,"showResetTime":"invalid","refreshInterval":"999","unknown":"value"} + """); + var settings = _environment.CreateSettings(); + Assert.False(settings.ShowFiveHourLimit); + Assert.True(settings.ShowWeeklyLimit); + Assert.True(settings.ShowResetTime); + Assert.Equal(TimeSpan.FromMinutes(1), settings.RefreshInterval); + } + + [Fact] + public void ProviderAppliesSavedRefreshIntervalBeforeFirstRead() + { + SubmitSettings(_environment.CreateSettings()); + using var service = _environment.CreateService(); + using var provider = new CodexUsageDockCommandsProvider(service, _environment.CreateSettings()); + Assert.Equal(TimeSpan.FromMinutes(15), service.RefreshInterval); + var band = Assert.Single(provider.GetDockBands()!); + var items = Assert.IsAssignableFrom(band.Command).GetItems(); + Assert.Single(items); + Assert.StartsWith("Week", items[0].Title, StringComparison.Ordinal); + } + + [Theory] + [InlineData(90, 0, "Weekly window")] + [InlineData(0, 90, "5-hour window")] + public void SummaryWarnsAboutTheExhaustedWindow(int fiveHourRemaining, int weeklyRemaining, string expectedWindow) + { + var snapshot = Snapshot(weeklyRemaining) with { Primary = new RateLimitWindow(100 - fiveHourRemaining, 300, Now.AddHours(4)) }; + var summary = CodexUsageDockPage.FormatSummary(snapshot, Now); + Assert.Contains("Almost at your limit", summary, StringComparison.Ordinal); + Assert.Contains(expectedWindow, summary, StringComparison.Ordinal); + Assert.DoesNotContain("Plenty", summary, StringComparison.Ordinal); + } + + [Fact] + public void FallbackUsesMeasurementTimestampAndClearsInactiveWindows() + { + var home = _environment.PathFor("codex"); + var sessions = Path.Combine(home, "sessions"); + Directory.CreateDirectory(sessions); + var file = Path.Combine(sessions, "rollout-one.jsonl"); + File.WriteAllLines(file, [QuotaLine(Now.AddHours(-5), 80), QuotaLine(Now.AddHours(-4), null), "{\"payload\":{\"type\":\"message\"}}"]); + File.SetLastWriteTimeUtc(file, Now.UtcDateTime); + var result = LocalCodexSessionReader.ReadLatest(home, Now); + Assert.Equal(Now.AddHours(-4), result.UpdatedAt); + Assert.Null(result.Primary); + Assert.NotNull(result.Secondary); + Assert.Equal("Fallback · 4 hours old", UsageDockItem.FormatFallbackAge(result, Now)); + } + + [Fact] + public void FallbackFindsLatestMeasurementAcrossFilesAndIgnoresUnreadableAndMalformedFiles() + { + var home = _environment.PathFor("codex"); + var sessions = Path.Combine(home, "sessions"); + Directory.CreateDirectory(sessions); + for (var index = 0; index < 13; index++) + { + File.WriteAllText(Path.Combine(sessions, $"rollout-noise-{index}.jsonl"), "[\"rate_limits\"]\n"); + } + + var oldFile = Path.Combine(sessions, "rollout-old-file.jsonl"); + File.WriteAllLines(oldFile, [QuotaLine(Now.AddMinutes(-5), 20), QuotaLine(Now.AddMinutes(-10), 30), QuotaLine(Now.AddHours(1), 90)]); + File.SetLastWriteTimeUtc(oldFile, Now.AddDays(-1).UtcDateTime); + var lockedPath = Path.Combine(sessions, "rollout-locked.jsonl"); + File.WriteAllText(lockedPath, QuotaLine(Now, 99)); + using var held = new FileStream(lockedPath, FileMode.Open, FileAccess.Read, FileShare.None); + var result = LocalCodexSessionReader.ReadLatest(home, Now); + Assert.Equal(Now.AddMinutes(-5), result.UpdatedAt); + Assert.Equal(20, result.Primary!.UsedPercent); + } + + [Fact] + public void InvalidHistoryEntriesDoNotPreventStartup() + { + var weeklyPath = _environment.PathFor("weekly.json"); + File.WriteAllText(weeklyPath, "[null,{}]"); + using var service = _environment.CreateService(); + Assert.Empty(service.WeeklyHistory); + } + + [Fact] + public void AdaptiveHistoryIgnoresNullBucketsAndImpossibleCycles() + { + var path = _environment.PathFor("adaptive.json"); + var state = new AdaptiveWeeklyUsageState([null!, new(DateTimeOffset.MinValue, 10080, 60, 10, [])], + new AdaptiveWeeklyUsageCycle(Now.AddDays(3), 10080, 60, 10, + [null!, new(0, 60, 10), new(1, 361, 10), new(2, 10, double.MaxValue), new(2, 10, double.MaxValue)]), + null, true); + File.WriteAllText(path, JsonSerializer.Serialize(state, AdaptiveWeeklyUsageJsonContext.Default.AdaptiveWeeklyUsageState)); + var store = new AdaptiveWeeklyUsageStore(path); + Assert.Empty(store.Snapshot.CompletedCycles); + Assert.Single(store.Snapshot.ActiveCycle!.Buckets); + Assert.True(store.Clear()); + } + + [Fact] + public void InjectedHistoryStoresRemainIsolated() + { + using var other = new TestEnvironment(); + using var first = _environment.CreateService(); + using var second = other.CreateService(); + first.RecordHistory(Snapshot(80), Now); + Assert.Single(first.WeeklyHistory); + Assert.Empty(second.WeeklyHistory); + Assert.Single(new WeeklyUsageHistoryStore(_environment.PathFor("weekly.json")).Load(Now)); + Assert.False(File.Exists(other.PathFor("weekly.json"))); + Assert.False(File.Exists(other.PathFor("adaptive.json"))); + } + + [Fact] + public void WeeklyHistoryReportsFailedSaveAndRetries() + { + var path = _environment.PathFor("weekly.json"); + var store = new WeeklyUsageHistoryStore(path); + Assert.True(store.Save([new(Now.AddMinutes(-1), 90)])); + using (var held = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + Assert.False(store.Save([new(Now, 80)])); + Assert.NotNull(store.StorageError); + Assert.Equal(90, Assert.Single(new WeeklyUsageHistoryStore(path).Load(Now)).RemainingPercent); + } + + Assert.True(store.Save([new(Now, 80)])); + Assert.Null(store.StorageError); + Assert.Equal(80, Assert.Single(new WeeklyUsageHistoryStore(path).Load(Now)).RemainingPercent); + } + + [Fact] + public void ClearReportsFailureWithoutDiscardingHistoryAndCanBeRetried() + { + var path = _environment.PathFor("adaptive.json"); + var state = new AdaptiveWeeklyUsageState([], new(Now.AddDays(3), 10080, 60, 10, []), null, true); + File.WriteAllText(path, JsonSerializer.Serialize(state, AdaptiveWeeklyUsageJsonContext.Default.AdaptiveWeeklyUsageState)); + var store = new AdaptiveWeeklyUsageStore(path); + using (var held = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + Assert.False(store.Clear()); + Assert.NotNull(store.Snapshot.ActiveCycle); + Assert.NotNull(store.StorageError); + } + + Assert.True(store.Clear()); + Assert.Null(store.StorageError); + Assert.Null(new AdaptiveWeeklyUsageStore(path).Snapshot.ActiveCycle); + } + + [Fact] + public void TextAndChartBothPauseForecastAfterMeasurementGap() + { + var snapshot = Snapshot(70); + UsageHistoryEntry[] history = [new(Now.AddDays(-1), 100), new(Now.AddDays(-1).AddMinutes(5), 99), new(Now, 70)]; + using var json = JsonDocument.Parse(CodexUsageDockPage.FormatMainDataJson(snapshot, Now, false, [], history, TimeSpan.FromMinutes(1), false)); + Assert.Equal("Projection will appear after another measurement.", json.RootElement.GetProperty("weeklyProjection").GetString()); + Assert.Contains("Forecast is unavailable", json.RootElement.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); + } + + [Fact] + public void ForecastUsesOnlyContinuousMeasurementsAfterGap() + { + UsageHistoryEntry[] history = [new(Now.AddDays(-1), 100), new(Now.AddMinutes(-5), 75), new(Now, 70)]; + var result = UsageTrendAnalyzer.Analyze(history, Now.AddDays(-4), Now.AddDays(3), Now, true, TimeSpan.FromMinutes(5)); + Assert.Equal(Now.AddMinutes(70), result.Forecast!.EndsAt); + } + + [Fact] + public async Task SlowTokenReadDoesNotBlockLimitsOrAllowStaleTokensToPublish() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var tokenResult = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var tokenCalls = 0; + var snapshot = Snapshot(80); + using var service = _environment.CreateService(_ => Task.FromResult(snapshot), () => snapshot, + localTokenUsageReader: (_, _, _, _) => + { + Interlocked.Increment(ref tokenCalls); + started.TrySetResult(); + return tokenResult.Task; + }); + await service.RefreshAsync().WaitAsync(TestTimeout); + await started.Task.WaitAsync(TestTimeout); + Assert.False(service.IsLoading); + Assert.Same(snapshot, service.Current); + var originalTokens = service.TokenRefreshTask; + snapshot = snapshot with { Secondary = snapshot.Secondary! with { ResetsAt = Now.AddDays(6) } }; + await service.RefreshAsync().WaitAsync(TestTimeout); + Assert.Equal(1, Volatile.Read(ref tokenCalls)); + tokenResult.SetResult(new LocalTokenUsageSnapshot([new(DateOnly.FromDateTime(Now.Date), 123)], Now, LocalTokenUsageStatus.Complete)); + await originalTokens.WaitAsync(TestTimeout); + Assert.Equal(LocalTokenUsageStatus.Unavailable, service.CurrentTokenUsage.Status); + await service.RefreshAsync().WaitAsync(TestTimeout); + await service.TokenRefreshTask.WaitAsync(TestTimeout); + Assert.Equal(2, Volatile.Read(ref tokenCalls)); + Assert.Equal(LocalTokenUsageStatus.Complete, service.CurrentTokenUsage.Status); + } + + [Fact] + public async Task DisposeCancelsIndependentTokenRead() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var service = _environment.CreateService(_ => Task.FromResult(Snapshot(80)), () => Snapshot(80), + localTokenUsageReader: async (_, _, _, cancellationToken) => + { + started.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return LocalTokenUsageSnapshot.Unavailable; + }); + await service.RefreshAsync(); + await started.Task.WaitAsync(TestTimeout); + service.Dispose(); + await service.TokenRefreshTask.WaitAsync(TestTimeout); + } + + private static CodexUsageSnapshot Snapshot(int remaining) => new(null, new(100 - remaining, 10080, Now.AddDays(3)), + null, null, null, Now, UsageDataSource.AppServer, null); + + private static string QuotaLine(DateTimeOffset timestamp, int? primaryUsed) => JsonSerializer.Serialize(new + { + timestamp, + payload = new + { + rate_limits = new + { + primary = primaryUsed.HasValue ? new { used_percent = primaryUsed.Value, window_minutes = 300, resets_at = Now.AddHours(1).ToUnixTimeSeconds() } : null, + secondary = new { used_percent = 25, window_minutes = 10080, resets_at = Now.AddDays(3).ToUnixTimeSeconds() }, + }, + }, + }); +} diff --git a/CodexUsageDock.Tests/TestEnvironment.cs b/CodexUsageDock.Tests/TestEnvironment.cs new file mode 100644 index 0000000..ad6fc6c --- /dev/null +++ b/CodexUsageDock.Tests/TestEnvironment.cs @@ -0,0 +1,44 @@ +namespace CodexUsageDock.Tests; + +internal sealed class TestEnvironment : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "CodexUsageDock.Tests", Guid.NewGuid().ToString("N")); + + internal string PathFor(string name) + { + Directory.CreateDirectory(_directory); + return Path.Combine(_directory, name); + } + + internal CodexUsageDockSettingsPage CreateSettings() => new(PathFor("settings.json")); + + internal CodexUsageService CreateService() => CreateService( + _ => Task.FromResult(CodexUsageSnapshot.Loading), _ => CodexUsageSnapshot.Loading); + + internal CodexUsageService CreateService( + Func> appServerReader, + Func localSessionReader, + WeeklyUsageHistoryStore? weeklyHistoryStore = null, + AdaptiveWeeklyUsageStore? adaptiveWeeklyUsageStore = null, + Func>? localTokenUsageReader = null) => + new(appServerReader, localSessionReader, + weeklyHistoryStore ?? new WeeklyUsageHistoryStore(PathFor("weekly.json")), + adaptiveWeeklyUsageStore ?? new AdaptiveWeeklyUsageStore(PathFor("adaptive.json")), + localTokenUsageReader); + + internal CodexUsageService CreateService( + Func> appServerReader, + Func localSessionReader, + WeeklyUsageHistoryStore? weeklyHistoryStore = null, + AdaptiveWeeklyUsageStore? adaptiveWeeklyUsageStore = null, + Func>? localTokenUsageReader = null) => + CreateService(appServerReader, _ => localSessionReader(), weeklyHistoryStore, adaptiveWeeklyUsageStore, localTokenUsageReader); + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 9b84377..983fecb 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -6,8 +6,12 @@ using Xunit; namespace CodexUsageDock.Tests; -public sealed class UsageDataTests +public sealed class UsageDataTests : IDisposable { + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + private static readonly TimeSpan AsyncTestTimeout = TimeSpan.FromSeconds(5); private static readonly XNamespace Svg = "http://www.w3.org/2000/svg"; @@ -46,7 +50,7 @@ private static LocalTokenUsageSnapshot TokenUsage(params (DateOnly Date, long To [Fact] public void SettingsDefaultToShowingAllDockUsageInformation() { - var settings = new CodexUsageDockSettingsPage(); + var settings = _environment.CreateSettings(); Assert.True(settings.ShowFiveHourLimit); Assert.True(settings.ShowWeeklyLimit); @@ -196,18 +200,18 @@ public void FallbackMessageExplainsThatLiveDataIsUnavailableWithoutExposingDiagn [Fact] public void DetailsPageUsesTheProjectReleaseVersion() { - using var service = new CodexUsageService(); - using var page = new CodexUsageDockPage(service, new CodexUsageDockSettingsPage()); + using var service = _environment.CreateService(); + using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); - Assert.Equal("0.6.0", CodexUsageDockMetadata.Version); + Assert.Equal("0.6.1", CodexUsageDockMetadata.Version); Assert.Equal($"Codex Usage - {CodexUsageDockMetadata.Version}", page.Title); } [Fact] public void DetailsPageContextMenuIncludesRefreshThenSettings() { - using var service = new CodexUsageService(); - using var page = new CodexUsageDockPage(service, new CodexUsageDockSettingsPage()); + using var service = _environment.CreateService(); + using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); Assert.Collection( page.Commands, @@ -218,8 +222,8 @@ public void DetailsPageContextMenuIncludesRefreshThenSettings() [Fact] public void DetailsPageUsesNativeMediumDetailsPane() { - using var service = new CodexUsageService(); - using var page = new CodexUsageDockPage(service, new CodexUsageDockSettingsPage()); + using var service = _environment.CreateService(); + using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); var details = Assert.IsType
(page.Details); var main = Assert.IsType(Assert.Single(page.GetContent())); @@ -255,12 +259,8 @@ [new RateLimitResetCredit("Full reset", "available", now.AddDays(13))]), snapshot, now, isLoading: false, - [new UsageHistoryEntry(now.AddMinutes(-30), 90), new UsageHistoryEntry(now, 80)], - [ - new UsageHistoryEntry(now.AddHours(-12), 99), - new UsageHistoryEntry(now.AddMinutes(-10), 98.01), - new UsageHistoryEntry(now, 98), - ], + ContinuousHistory(now.AddMinutes(-30), 90, now, 80), + ContinuousHistory(now.AddHours(-12), 99, now, 98), TimeSpan.FromMinutes(1)); var details = CodexUsageDockPage.FormatDetailsBody(snapshot, now); using var mainData = JsonDocument.Parse(main); @@ -380,7 +380,7 @@ [new AdaptiveWeeklyUsageBucket(1, 60, 12)])) primaryHistory: [], weeklyHistory: [ - new UsageHistoryEntry(now.AddHours(-1), 90), + new UsageHistoryEntry(now.AddMinutes(-10), 90), new UsageHistoryEntry(now, 80), ], refreshInterval: TimeSpan.FromMinutes(1), @@ -399,10 +399,10 @@ public async Task DetailsPageRefreshUpdatesMainContentAndDetailsPane() { var now = DateTimeOffset.Now; var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var service = new CodexUsageService( + using var service = _environment.CreateService( _ => result.Task, () => throw new InvalidOperationException("Fallback should not run.")); - using var page = new CodexUsageDockPage(service, new CodexUsageDockSettingsPage()); + using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); var main = Assert.IsType(Assert.Single(page.GetContent())); var details = Assert.IsType
(page.Details); @@ -449,10 +449,10 @@ public async Task CompletedRefreshRebuildsAndInvalidatesDockBands() { var now = DateTimeOffset.Now; var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var service = new CodexUsageService( + var service = _environment.CreateService( _ => result.Task, () => throw new InvalidOperationException("Fallback should not run.")); - var provider = new CodexUsageDockCommandsProvider(service, new CodexUsageDockSettingsPage()); + var provider = new CodexUsageDockCommandsProvider(service, _environment.CreateSettings()); try { var invalidationCount = 0; @@ -1450,14 +1450,14 @@ public void Trend_EstimatesLimitTimeFromObservedConsumption() var now = new DateTimeOffset(2026, 7, 12, 14, 30, 0, TimeSpan.Zero); UsageHistoryEntry[] history = [ - new(now.AddMinutes(-30), 80), + new(now.AddMinutes(-10), 80), new(now, 60), ]; var trend = CodexUsageDockPage.FormatTrend(history, now); Assert.Contains("80% → 60%", trend, StringComparison.Ordinal); - Assert.Contains($"limit may be reached around {now.AddMinutes(90).ToLocalTime():HH:mm}", trend, StringComparison.Ordinal); + Assert.Contains($"limit may be reached around {now.AddMinutes(30).ToLocalTime():HH:mm}", trend, StringComparison.Ordinal); } [Fact] @@ -1468,7 +1468,7 @@ public void Trend_UsesOnlySamplesAfterLatestQuotaIncrease() [ new(now.AddMinutes(-90), 10), new(now.AddMinutes(-60), 5), - new(now.AddMinutes(-30), 100), + new(now.AddMinutes(-10), 100), new(now, 80), ]; @@ -1476,7 +1476,7 @@ public void Trend_UsesOnlySamplesAfterLatestQuotaIncrease() Assert.Contains("100% → 80%", trend, StringComparison.Ordinal); Assert.DoesNotContain("10%", trend, StringComparison.Ordinal); - Assert.Contains($"limit may be reached around {now.AddHours(2).ToLocalTime():HH:mm}", trend, StringComparison.Ordinal); + Assert.Contains($"limit may be reached around {now.AddMinutes(40).ToLocalTime():HH:mm}", trend, StringComparison.Ordinal); } [Fact] @@ -1575,7 +1575,7 @@ public async Task RefreshFallsBackWithoutExposingAppServerFailureDetails() Source = UsageDataSource.LocalSession, Error = null, }; - using var service = new CodexUsageService( + using var service = _environment.CreateService( _ => Task.FromException(new InvalidOperationException(@"C:\Users\Alice\token.json")), () => fallback); @@ -1590,7 +1590,7 @@ public async Task RefreshFallsBackWithoutExposingAppServerFailureDetails() [Fact] public async Task RefreshPublishesUnavailableStateWhenBothSourcesFail() { - using var service = new CodexUsageService( + using var service = _environment.CreateService( _ => Task.FromException(new InvalidOperationException("live failure")), () => throw new DirectoryNotFoundException(@"C:\Users\Alice\.codex\sessions")); @@ -1609,7 +1609,7 @@ public async Task ConcurrentRefreshesShareTheSameInFlightTask() { var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var readCount = 0; - using var service = new CodexUsageService( + using var service = _environment.CreateService( _ => { Interlocked.Increment(ref readCount); @@ -1640,7 +1640,7 @@ public async Task ConcurrentRefreshesShareTheSameInFlightTask() public async Task RefreshNotifiesLoadingAndCompletionWithoutTrustingSubscribers() { var loadingStates = new List(); - using var service = new CodexUsageService( + using var service = _environment.CreateService( _ => Task.FromResult(CodexUsageSnapshot.Loading with { Secondary = new RateLimitWindow(10, 10080, DateTimeOffset.Now.AddDays(3)), @@ -1660,7 +1660,7 @@ public async Task RefreshNotifiesLoadingAndCompletionWithoutTrustingSubscribers( public async Task DisposeCancelsAnInFlightRefreshWithoutFaultingItsTask() { var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var service = new CodexUsageService( + using var service = _environment.CreateService( async cancellationToken => { started.SetResult(); @@ -1681,7 +1681,7 @@ public async Task DisposeCancelsAnInFlightRefreshWithoutFaultingItsTask() public async Task DisposeCancelsAnInFlightFallbackReader() { var fallbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var service = new CodexUsageService( + using var service = _environment.CreateService( _ => Task.FromException(new InvalidOperationException("Live data unavailable.")), cancellationToken => { @@ -1708,7 +1708,7 @@ public void LocalFallbackHonorsPreCanceledToken() [Fact] public void History_DeduplicatesAndPrunesStaleFallbackSamples() { - using var service = new CodexUsageService(); + using var service = _environment.CreateService(); var now = new DateTimeOffset(2026, 7, 12, 14, 30, 0, TimeSpan.Zero); var staleSnapshot = CodexUsageSnapshot.Loading with { @@ -1728,7 +1728,7 @@ public void History_DeduplicatesAndPrunesStaleFallbackSamples() [Fact] public void History_RejectsFallbackSamplesOlderThanLatestLiveSample() { - using var service = new CodexUsageService(); + using var service = _environment.CreateService(); var now = new DateTimeOffset(2026, 7, 12, 14, 30, 0, TimeSpan.Zero); var liveSnapshot = CodexUsageSnapshot.Loading with { @@ -1754,7 +1754,7 @@ public void History_RejectsFallbackSamplesOlderThanLatestLiveSample() [Fact] public void History_PrunesExpiredSamplesWhenPrimaryDataIsUnavailable() { - using var service = new CodexUsageService(); + using var service = _environment.CreateService(); var now = new DateTimeOffset(2026, 7, 12, 14, 30, 0, TimeSpan.Zero); var liveSnapshot = CodexUsageSnapshot.Loading with { @@ -1841,7 +1841,7 @@ public void WeeklyHistoryPersistsAcrossServiceRestartsAndStaysSeparateFromFiveHo }; try { - using (var service = new CodexUsageService(_ => Task.FromResult(snapshot), () => snapshot, new WeeklyUsageHistoryStore(path))) + using (var service = _environment.CreateService(_ => Task.FromResult(snapshot), () => snapshot, new WeeklyUsageHistoryStore(path))) { service.RecordHistory(snapshot, now); service.RecordHistory(snapshot, now); @@ -1850,7 +1850,7 @@ public void WeeklyHistoryPersistsAcrossServiceRestartsAndStaysSeparateFromFiveHo Assert.Single(service.WeeklyHistory); } - using var restarted = new CodexUsageService(_ => Task.FromResult(snapshot), () => snapshot, new WeeklyUsageHistoryStore(path)); + using var restarted = _environment.CreateService(_ => Task.FromResult(snapshot), () => snapshot, new WeeklyUsageHistoryStore(path)); Assert.Empty(restarted.PrimaryHistory); Assert.Single(restarted.WeeklyHistory); } @@ -1881,7 +1881,7 @@ public void WeeklyTrendEstimatesRemainingAllowanceAtResetWhenLimitWillNotBeReach var now = DateTimeOffset.Now; var trend = CodexUsageDockPage.FormatTrend( "Weekly usage trend", - [new UsageHistoryEntry(now.AddDays(-1), 80), new UsageHistoryEntry(now, 70)], + ContinuousHistory(now.AddDays(-1), 80, now, 70), new RateLimitWindow(30, 10080, now.AddDays(3)), now, dataAvailable: true, @@ -1898,7 +1898,7 @@ public void WeeklyTrendEstimatesLimitWhenConsumptionWillExceedAllowanceBeforeRes var now = DateTimeOffset.Now; var trend = CodexUsageDockPage.FormatTrend( "Weekly usage trend", - [new UsageHistoryEntry(now.AddHours(-1), 30), new UsageHistoryEntry(now, 10)], + [new UsageHistoryEntry(now.AddMinutes(-10), 30), new UsageHistoryEntry(now, 10)], new RateLimitWindow(90, 10080, now.AddDays(3)), now, dataAvailable: true, @@ -1914,7 +1914,7 @@ public void WeeklyTrendIncludesDateWhenTheEstimatedLimitIsNotToday() var estimated = now.AddHours(99); var trend = CodexUsageDockPage.FormatTrend( "Weekly usage trend", - [new UsageHistoryEntry(now.AddHours(-1), 100), new UsageHistoryEntry(now, 99)], + ContinuousHistory(now.AddHours(-1), 100, now, 99), new RateLimitWindow(1, 10080, now.AddDays(6)), now, dataAvailable: true, @@ -1932,7 +1932,7 @@ public void WeeklyTrendUsesOnlySamplesAfterTheLatestWeeklyReset() [ new UsageHistoryEntry(now.AddHours(-3), 10), new UsageHistoryEntry(now.AddHours(-2), 5), - new UsageHistoryEntry(now.AddHours(-1), 100), + new UsageHistoryEntry(now.AddMinutes(-10), 100), new UsageHistoryEntry(now, 80), ], new RateLimitWindow(20, 10080, now.AddDays(6)), @@ -1954,7 +1954,7 @@ public void WeeklyTrendFiltersSamplesBeforeCurrentWindowStartWhenNoUsageIncrease [ new UsageHistoryEntry(now.AddDays(-2), 90), new UsageHistoryEntry(now.AddDays(-1).AddMinutes(-1), 50), - new UsageHistoryEntry(now.AddHours(-1), 40), + new UsageHistoryEntry(now.AddMinutes(-10), 40), new UsageHistoryEntry(now, 30), ], new RateLimitWindow(70, 10080, reset), @@ -2155,7 +2155,7 @@ public async Task ReenablingAdaptiveForecastDoesNotLearnMeasurementsCollectedWhi var latest = CreateSnapshot(70, now.AddMinutes(-1)); try { - using var service = new CodexUsageService( + using var service = _environment.CreateService( _ => Task.FromResult(latest), () => latest, new WeeklyUsageHistoryStore(historyPath), @@ -2305,4 +2305,14 @@ public void WeeklyChartRendersEveryAdaptiveForecastPoint() element => (string?)element.Attribute("stroke-dasharray") == "5 4"); Assert.True(((string?)dashed.Attribute("points"))!.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length >= 4); } + // Forecast arithmetic fixtures model uninterrupted polling; gap behavior has separate tests. + private static UsageHistoryEntry[] ContinuousHistory(DateTimeOffset start, double first, DateTimeOffset end, double last) + { + var steps = (int)Math.Ceiling((end - start).TotalMinutes / 5); + return Enumerable.Range(0, steps + 1) + .Select(index => new UsageHistoryEntry( + start.AddTicks((end - start).Ticks * index / steps), + first + (last - first) * index / steps)) + .ToArray(); + } } diff --git a/CodexUsageDock/AdaptiveWeeklyForecast.cs b/CodexUsageDock/AdaptiveWeeklyForecast.cs index 5737da2..6b81f10 100644 --- a/CodexUsageDock/AdaptiveWeeklyForecast.cs +++ b/CodexUsageDock/AdaptiveWeeklyForecast.cs @@ -42,13 +42,9 @@ internal AdaptiveWeeklyUsageStore(string path) _state = Load(); } - internal static AdaptiveWeeklyUsageStore CreateDefault() - { - var directory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "CodexUsageDock"); - return new AdaptiveWeeklyUsageStore(Path.Combine(directory, FileName)); - } + internal static AdaptiveWeeklyUsageStore CreateDefault() => new(LocalStorage.GetPath(FileName)); + + internal string? StorageError { get; private set; } internal AdaptiveWeeklyUsageHistory Snapshot => new( _state.CompletedCycles.ToArray(), @@ -105,11 +101,17 @@ internal void AdvanceBaseline( Save(); } - internal void Clear() + internal bool Clear() { // Keep only an empty initialization marker so old raw chart samples are not learnt again. - _state = new AdaptiveWeeklyUsageState([], null, null, true); - Save(); + var cleared = new AdaptiveWeeklyUsageState([], null, null, true); + if (!Save(cleared)) + { + return false; + } + + _state = cleared; + return true; } private static bool TryGetWindowSamples( @@ -246,43 +248,18 @@ private AdaptiveWeeklyUsageState Load() } catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException or NotSupportedException) { + LocalStorage.TraceFailure("load forecast history", error); + StorageError = "Saved forecast history could not be read. A new history will be collected."; return new AdaptiveWeeklyUsageState([], null, null, false); } } - private void Save() + private bool Save(AdaptiveWeeklyUsageState? state = null) { - var temporaryPath = $"{_path}.{Guid.NewGuid():N}.tmp"; - try - { - var directory = Path.GetDirectoryName(_path); - if (string.IsNullOrWhiteSpace(directory)) - { - return; - } - - Directory.CreateDirectory(directory); - File.WriteAllText(temporaryPath, JsonSerializer.Serialize(_state, AdaptiveWeeklyUsageJsonContext.Default.AdaptiveWeeklyUsageState)); - File.Move(temporaryPath, _path, overwrite: true); - } - catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException or NotSupportedException) - { - } - finally - { - try - { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } - } - catch (Exception error) when (error is IOException or UnauthorizedAccessException) - { - } - } + var saved = LocalStorage.TryWrite(_path, JsonSerializer.Serialize(state ?? _state, AdaptiveWeeklyUsageJsonContext.Default.AdaptiveWeeklyUsageState)); + StorageError = saved ? null : "Learned forecast history could not be saved. Please try again."; + return saved; } - private static AdaptiveWeeklyUsageState Normalize(AdaptiveWeeklyUsageState? state) { if (state is null) @@ -297,6 +274,8 @@ private static AdaptiveWeeklyUsageState Normalize(AdaptiveWeeklyUsageState? stat .Cast() .ToArray(), active); var lastSample = active is not null && state.LastSample is { } sample && IsValidSample(sample) + && sample.RecordedAt >= active.ResetsAt.AddMinutes(-active.WindowMinutes) + && sample.RecordedAt <= active.ResetsAt ? sample : null; return new AdaptiveWeeklyUsageState(completed, active, lastSample, state.IsInitialized); @@ -308,6 +287,8 @@ private static AdaptiveWeeklyUsageState Normalize(AdaptiveWeeklyUsageState? stat || cycle.WindowMinutes != (int)TimeSpan.FromDays(7).TotalMinutes || !double.IsFinite(cycle.ObservedMinutes) || !double.IsFinite(cycle.ConsumedPercent) + || cycle.ResetsAt < DateTimeOffset.MinValue.AddDays(7) + || cycle.ObservedMinutes > cycle.WindowMinutes || cycle.ObservedMinutes < 0 || cycle.ConsumedPercent < 0) { @@ -315,16 +296,19 @@ private static AdaptiveWeeklyUsageState Normalize(AdaptiveWeeklyUsageState? stat } var buckets = (cycle.Buckets ?? []) - .Where(bucket => bucket.Index is >= 0 and < BucketCount + .Where(bucket => bucket is not null && bucket.Index is >= 0 and < BucketCount && double.IsFinite(bucket.ObservedMinutes) && double.IsFinite(bucket.ConsumedPercent) && bucket.ObservedMinutes >= 0 + && bucket.ObservedMinutes <= BucketDuration.TotalMinutes && bucket.ConsumedPercent >= 0) .GroupBy(bucket => bucket.Index) .Select(group => new AdaptiveWeeklyUsageBucket( group.Key, group.Sum(bucket => bucket.ObservedMinutes), group.Sum(bucket => bucket.ConsumedPercent))) + .Where(bucket => bucket.ObservedMinutes <= BucketDuration.TotalMinutes + && double.IsFinite(bucket.ConsumedPercent)) .OrderBy(bucket => bucket.Index) .ToArray(); return cycle with { Buckets = buckets }; diff --git a/CodexUsageDock/CodexUsageDock.csproj b/CodexUsageDock/CodexUsageDock.csproj index 243825a..a3773a4 100644 --- a/CodexUsageDock/CodexUsageDock.csproj +++ b/CodexUsageDock/CodexUsageDock.csproj @@ -3,7 +3,7 @@ WinExe CodexUsageDock app.manifest - 0.6.0 + 0.6.1 10.0.26100.68-preview net10.0-windows10.0.26100.0 diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index 0ac8795..fd93b29 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -11,7 +11,7 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private readonly UsageDockItem _weekly; private readonly UsageDockItem _resetsAndCredits; private readonly ICommandItem[] _commands; - private WrappedDockItem? _dockBand; + private readonly CodexUsageDockPage _details; private ICommandItem[] _dockBands = []; public CodexUsageDockCommandsProvider() @@ -27,7 +27,9 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS Id = "nl.mathijs.codexusage"; Icon = new IconInfo("\uE943"); - var details = new CodexUsageDockPage(_usage, _settings); + _usage.SetRefreshInterval(_settings.RefreshInterval); + _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); + var details = _details = new CodexUsageDockPage(_usage, _settings); _fiveHour = new UsageDockItem(_usage, UsageDockItemKind.FiveHour, details, _settings); _weekly = new UsageDockItem(_usage, UsageDockItemKind.Weekly, details, _settings); _resetsAndCredits = new UsageDockItem(_usage, UsageDockItemKind.ResetsAndCredits, details); @@ -50,7 +52,6 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _settings.Changed += OnSettingsChanged; _settings.ClearAdaptiveHistoryRequested += OnClearAdaptiveHistoryRequested; _usage.Updated += OnUsageUpdated; - _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); RebuildDockBands(); _usage.Start(); @@ -66,14 +67,22 @@ private void OnSettingsChanged(object? sender, EventArgs e) _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); _fiveHour.Refresh(); _weekly.Refresh(); + _details.Refresh(); RebuildDockBands(); RaiseItemsChanged(); } private void OnClearAdaptiveHistoryRequested(object? sender, EventArgs e) { - _usage.ClearAdaptiveWeeklyHistory(); - _ = _usage.RefreshAsync(); + var cleared = _usage.ClearAdaptiveWeeklyHistory(); + _settings.ShowOperationStatus(cleared + ? "Learned forecast history deleted." + : "Learned forecast history could not be deleted. Please try again.", cleared); + _details.Refresh(); + if (cleared) + { + _ = _usage.RefreshAsync(); + } } private void OnUsageUpdated(object? sender, EventArgs e) @@ -90,10 +99,10 @@ private void OnUsageUpdated(object? sender, EventArgs e) private void RebuildDockBands() { var items = GetVisibleDockItems(); - _dockBand = items.Length == 0 + var dockBand = items.Length == 0 ? null : new WrappedDockItem(items, "nl.mathijs.codexusage.dock", DisplayName); - _dockBands = _dockBand is null ? [] : [_dockBand]; + _dockBands = dockBand is null ? [] : [dockBand]; } private IListItem[] GetVisibleDockItems() @@ -125,6 +134,7 @@ public override void Dispose() _fiveHour.Dispose(); _weekly.Dispose(); _resetsAndCredits.Dispose(); + _details.Dispose(); _usage.Dispose(); base.Dispose(); GC.SuppressFinalize(this); diff --git a/CodexUsageDock/CodexUsageService.cs b/CodexUsageDock/CodexUsageService.cs index 1352ff0..ddaa4be 100644 --- a/CodexUsageDock/CodexUsageService.cs +++ b/CodexUsageDock/CodexUsageService.cs @@ -17,8 +17,11 @@ internal sealed partial class CodexUsageService : IDisposable private readonly CancellationTokenSource _lifetimeCancellation = new(); private readonly Func> _appServerReader; private readonly Func _localSessionReader; - private readonly Func> _localTokenUsageReader; + private readonly Func>? _localTokenUsageReader; private Task? _refreshTask; + private Task? _tokenReadTask; + private Task _tokenRefreshTask = Task.CompletedTask; + private long _tokenGeneration; private bool _disposed; private bool _isLoading; private bool _started; @@ -29,6 +32,8 @@ public CodexUsageService() : this( CodexAppServerReader.ReadAsync, LocalCodexSessionReader.ReadLatest, + WeeklyUsageHistoryStore.CreateDefault(), + AdaptiveWeeklyUsageStore.CreateDefault(), localTokenUsageReader: new LocalCodexTokenUsageReader().ReadAsync) { } @@ -36,23 +41,23 @@ public CodexUsageService() internal CodexUsageService( Func> appServerReader, Func localSessionReader, - WeeklyUsageHistoryStore? weeklyHistoryStore = null, - AdaptiveWeeklyUsageStore? adaptiveWeeklyUsageStore = null, + WeeklyUsageHistoryStore weeklyHistoryStore, + AdaptiveWeeklyUsageStore adaptiveWeeklyUsageStore, Func>? localTokenUsageReader = null) { _appServerReader = appServerReader; _localSessionReader = localSessionReader; - _localTokenUsageReader = localTokenUsageReader ?? ((_, _, _, _) => Task.FromResult(LocalTokenUsageSnapshot.Unavailable)); - _weeklyHistoryStore = weeklyHistoryStore ?? WeeklyUsageHistoryStore.CreateDefault(); - _adaptiveWeeklyUsageStore = adaptiveWeeklyUsageStore ?? AdaptiveWeeklyUsageStore.CreateDefault(); + _localTokenUsageReader = localTokenUsageReader; + _weeklyHistoryStore = weeklyHistoryStore; + _adaptiveWeeklyUsageStore = adaptiveWeeklyUsageStore; _weeklyHistory.AddRange(_weeklyHistoryStore.Load(DateTimeOffset.Now)); } internal CodexUsageService( Func> appServerReader, Func localSessionReader, - WeeklyUsageHistoryStore? weeklyHistoryStore = null, - AdaptiveWeeklyUsageStore? adaptiveWeeklyUsageStore = null, + WeeklyUsageHistoryStore weeklyHistoryStore, + AdaptiveWeeklyUsageStore adaptiveWeeklyUsageStore, Func>? localTokenUsageReader = null) : this(appServerReader, _ => localSessionReader(), weeklyHistoryStore, adaptiveWeeklyUsageStore, localTokenUsageReader) { @@ -62,6 +67,17 @@ internal CodexUsageService( public LocalTokenUsageSnapshot CurrentTokenUsage { get; private set; } = LocalTokenUsageSnapshot.Unavailable; + internal Task TokenRefreshTask + { + get + { + lock (_refreshStateLock) + { + return _tokenRefreshTask; + } + } + } + public bool IsLoading { get @@ -158,17 +174,29 @@ internal void SetAdaptiveWeeklyForecastEnabled(bool enabled) } } - internal void ClearAdaptiveWeeklyHistory() + internal bool ClearAdaptiveWeeklyHistory() { lock (_historyLock) { - _adaptiveWeeklyUsageStore.Clear(); + return _adaptiveWeeklyUsageStore.Clear(); + } + } + + internal string? HistoryStorageError + { + get + { + lock (_historyLock) + { + return _weeklyHistoryStore.StorageError ?? _adaptiveWeeklyUsageStore.StorageError; + } } } public Task RefreshAsync() { TaskCompletionSource completion; + CancellationToken cancellationToken; lock (_refreshStateLock) { if (_disposed) @@ -184,10 +212,11 @@ public Task RefreshAsync() _isLoading = true; completion = new(TaskCreationOptions.RunContinuationsAsynchronously); _refreshTask = completion.Task; + cancellationToken = _lifetimeCancellation.Token; } RaiseUpdated(); - _ = ExecuteRefreshAsync(completion, _lifetimeCancellation.Token); + _ = ExecuteRefreshAsync(completion, cancellationToken); return completion.Task; } @@ -248,17 +277,18 @@ private static bool RecordWindowHistory( } private TimeSpan GetAdaptiveMaximumGap() => - TimeSpan.FromTicks(Math.Max(TimeSpan.FromMinutes(5).Ticks, RefreshInterval.Ticks) * 3); + UsageTrendHistory.MaximumGap(RefreshInterval); private async Task ExecuteRefreshAsync(TaskCompletionSource completion, CancellationToken cancellationToken) { + CodexUsageSnapshot? tokenSnapshot = null; try { var snapshot = await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false); - var tokenUsage = await ReadTokenUsageAsync(snapshot, cancellationToken).ConfigureAwait(false); - if (!cancellationToken.IsCancellationRequested && TryPublish(snapshot, tokenUsage)) + if (!cancellationToken.IsCancellationRequested && TryPublish(snapshot)) { RecordHistory(snapshot); + tokenSnapshot = snapshot; } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -268,7 +298,7 @@ private async Task ExecuteRefreshAsync(TaskCompletionSource completion, Cancella { TraceFailure("unexpected refresh", error); var unavailable = CreateUnavailableSnapshot(); - if (TryPublish(unavailable, LocalTokenUsageSnapshot.Unavailable with { UpdatedAt = DateTimeOffset.Now })) + if (TryPublish(unavailable)) { RecordHistory(unavailable); } @@ -281,6 +311,10 @@ private async Task ExecuteRefreshAsync(TaskCompletionSource completion, Cancella } RaiseUpdated(); + if (tokenSnapshot is not null) + { + StartTokenRefresh(tokenSnapshot); + } completion.TrySetResult(); } } @@ -328,7 +362,7 @@ private async Task ReadTokenUsageAsync( var windowEnd = DateTimeOffset.Now < weekly.ResetsAt ? DateTimeOffset.Now : weekly.ResetsAt; try { - return await _localTokenUsageReader(windowStart, windowEnd, TimeZoneInfo.Local, cancellationToken).ConfigureAwait(false); + return await _localTokenUsageReader!(windowStart, windowEnd, TimeZoneInfo.Local, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -341,7 +375,69 @@ private async Task ReadTokenUsageAsync( } } - private bool TryPublish(CodexUsageSnapshot snapshot, LocalTokenUsageSnapshot tokenUsage) + private void StartTokenRefresh(CodexUsageSnapshot snapshot) + { + lock (_refreshStateLock) + { + if (_disposed || _localTokenUsageReader is null || snapshot.Secondary is null + || _tokenReadTask is { IsCompleted: false }) + { + return; + } + + var cancellation = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCancellation.Token); + cancellation.CancelAfter(TimeSpan.FromSeconds(20)); + // Keep the actual read task, even after a timeout, so a non-cooperative + // file read cannot cause overlapping scans of the reader's mutable cache. + var generation = _tokenGeneration; + var read = _tokenReadTask = Task.Run(() => ReadTokenUsageAsync(snapshot, cancellation.Token)); + // Notifications must run outside the state lock, even if the read finishes immediately. + _tokenRefreshTask = Task.Run(() => ObserveTokenRefreshAsync(read, cancellation, generation)); + } + } + + private async Task ObserveTokenRefreshAsync(Task read, CancellationTokenSource cancellation, long generation) + { + try + { + var result = await read.WaitAsync(cancellation.Token).ConfigureAwait(false); + lock (_refreshStateLock) + { + if (_disposed || generation != _tokenGeneration) + { + return; + } + + CurrentTokenUsage = result; + } + + RaiseUpdated(); + } + catch (OperationCanceledException) + { + } + catch (Exception error) + { + TraceFailure("token refresh", error); + } + finally + { + if (read.IsCompleted) + { + cancellation.Dispose(); + } + else + { + _ = read.ContinueWith(task => + { + _ = task.Exception; + cancellation.Dispose(); + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + } + } + + private bool TryPublish(CodexUsageSnapshot snapshot) { lock (_refreshStateLock) { @@ -350,8 +446,14 @@ private bool TryPublish(CodexUsageSnapshot snapshot, LocalTokenUsageSnapshot tok return false; } + if (Current.Secondary?.ResetsAt != snapshot.Secondary?.ResetsAt + || snapshot.Source == UsageDataSource.Unavailable) + { + CurrentTokenUsage = LocalTokenUsageSnapshot.Unavailable; + } + Current = snapshot; - CurrentTokenUsage = tokenUsage; + _tokenGeneration++; return true; } } @@ -415,7 +517,7 @@ public void Dispose() } _disposed = true; - refreshTask = _refreshTask; + refreshTask = Task.WhenAll(_refreshTask ?? Task.CompletedTask, _tokenRefreshTask); } _timer.Stop(); diff --git a/CodexUsageDock/LocalCodexSessionReader.cs b/CodexUsageDock/LocalCodexSessionReader.cs index cbb81ec..e80c882 100644 --- a/CodexUsageDock/LocalCodexSessionReader.cs +++ b/CodexUsageDock/LocalCodexSessionReader.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; namespace CodexUsageDock; @@ -7,23 +8,48 @@ internal static class LocalCodexSessionReader internal static CodexUsageSnapshot ReadLatest(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - var sessions = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "sessions"); - var files = Directory.EnumerateFiles(sessions, "rollout-*.jsonl", SearchOption.AllDirectories) - .Select(path => - { - cancellationToken.ThrowIfCancellationRequested(); - return new FileInfo(path); - }) - .OrderByDescending(file => file.LastWriteTimeUtc) - .Take(12); + return ReadLatest(LocalStorage.GetCodexHome(), DateTimeOffset.UtcNow, cancellationToken); + } - foreach (var file in files) + internal static CodexUsageSnapshot ReadLatest(string codexHome, DateTimeOffset now, CancellationToken cancellationToken = default) + { + CodexUsageSnapshot? latest = null; + foreach (var directory in new[] { Path.Combine(codexHome, "sessions"), Path.Combine(codexHome, "archived_sessions") }) { cancellationToken.ThrowIfCancellationRequested(); - RateLimitWindow? primary = null; - RateLimitWindow? secondary = null; - string? plan = null; - using var stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + if (!Directory.Exists(directory)) + { + continue; + } + + var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; + try + { + foreach (var file in Directory.EnumerateFiles(directory, "rollout-*.jsonl", options)) + { + cancellationToken.ThrowIfCancellationRequested(); + var snapshot = ReadFile(file, now, cancellationToken); + if (snapshot is not null && (latest is null || snapshot.UpdatedAt > latest.UpdatedAt)) + { + latest = snapshot; + } + } + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + LocalStorage.TraceFailure("enumerate fallback sessions", error); + } + } + + return latest ?? throw new InvalidOperationException("No usable Codex usage measurement was found."); + } + + private static CodexUsageSnapshot? ReadFile(string path, DateTimeOffset now, CancellationToken cancellationToken) + { + CodexUsageSnapshot? latest = null; + try + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); using var reader = new StreamReader(stream); while (reader.ReadLine() is { } line) { @@ -36,33 +62,51 @@ internal static CodexUsageSnapshot ReadLatest(CancellationToken cancellationToke try { using var document = JsonDocument.Parse(line); - if (!document.RootElement.TryGetProperty("payload", out var payload) - || payload.ValueKind != JsonValueKind.Object - || !payload.TryGetProperty("rate_limits", out var rateLimits) - || rateLimits.ValueKind != JsonValueKind.Object) + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("timestamp", out var timestamp) + || timestamp.ValueKind != JsonValueKind.String + || !DateTimeOffset.TryParse(timestamp.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var recordedAt) + || recordedAt > now + || !root.TryGetProperty("payload", out var payload) || payload.ValueKind != JsonValueKind.Object + || !payload.TryGetProperty("rate_limits", out var limits) || limits.ValueKind != JsonValueKind.Object + || !TryReadWindow(limits, "primary", out var primary) + || !TryReadWindow(limits, "secondary", out var secondary) + || (!limits.TryGetProperty("primary", out _) && !limits.TryGetProperty("secondary", out _))) + { + continue; + } + + var windows = RateLimitWindowParser.Classify(primary, secondary); + if ((primary is not null || secondary is not null) && windows.FiveHour is null && windows.Weekly is null) { continue; } - primary = RateLimitWindowParser.TryParse(rateLimits, "primary", "used_percent", "window_minutes", "resets_at") ?? primary; - secondary = RateLimitWindowParser.TryParse(rateLimits, "secondary", "used_percent", "window_minutes", "resets_at") ?? secondary; - if (rateLimits.TryGetProperty("plan_type", out var planType) && planType.ValueKind == JsonValueKind.String) + var plan = limits.TryGetProperty("plan_type", out var planType) && planType.ValueKind == JsonValueKind.String + ? UsageText.SanitizeExternal(planType.GetString(), 32) : null; + if (latest is null || recordedAt >= latest.UpdatedAt) { - plan = UsageText.SanitizeExternal(planType.GetString(), 32) ?? plan; + latest = new CodexUsageSnapshot(windows.FiveHour, windows.Weekly, plan, null, null, recordedAt, UsageDataSource.LocalSession, null); } } catch (JsonException) { + // A writer may still be appending the final JSONL record. } } - - if (primary is not null || secondary is not null) - { - var windows = RateLimitWindowParser.Classify(primary, secondary); - return new CodexUsageSnapshot(windows.FiveHour, windows.Weekly, plan, null, null, file.LastWriteTime, UsageDataSource.LocalSession, null); - } } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + LocalStorage.TraceFailure("read fallback session", error); + } + + return latest; + } - throw new InvalidOperationException("No recent Codex usage data was found."); + private static bool TryReadWindow(JsonElement limits, string name, out RateLimitWindow? window) + { + window = RateLimitWindowParser.TryParse(limits, name, "used_percent", "window_minutes", "resets_at"); + return window is not null || !limits.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null; } -} +} \ No newline at end of file diff --git a/CodexUsageDock/LocalCodexTokenUsageReader.cs b/CodexUsageDock/LocalCodexTokenUsageReader.cs index c486883..ec7e95b 100644 --- a/CodexUsageDock/LocalCodexTokenUsageReader.cs +++ b/CodexUsageDock/LocalCodexTokenUsageReader.cs @@ -16,9 +16,7 @@ internal sealed class LocalCodexTokenUsageReader internal LocalCodexTokenUsageReader(string? codexHome = null) { - _codexHome = codexHome ?? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".codex"); + _codexHome = codexHome ?? LocalStorage.GetCodexHome(); } internal Task ReadAsync( diff --git a/CodexUsageDock/LocalStorage.cs b/CodexUsageDock/LocalStorage.cs new file mode 100644 index 0000000..9e0a866 --- /dev/null +++ b/CodexUsageDock/LocalStorage.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; + +namespace CodexUsageDock; + +internal static class LocalStorage +{ + internal static string GetPath(string fileName) => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodexUsageDock", fileName); + + internal static string GetCodexHome() => Path.GetFullPath( + Environment.GetEnvironmentVariable("CODEX_HOME") is { Length: > 0 } configured + ? configured + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex")); + + internal static bool TryWrite(string path, string content) + { + var temporaryPath = $"{path}.{Guid.NewGuid():N}.tmp"; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + File.WriteAllText(temporaryPath, content); + File.Move(temporaryPath, path, overwrite: true); + return true; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + TraceFailure("save local data", error); + return false; + } + finally + { + try + { + File.Delete(temporaryPath); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + TraceFailure("remove temporary local data", error); + } + } + } + + internal static void TraceFailure(string operation, Exception error) => + Trace.TraceWarning("Codex Usage Dock: {0} failed ({1}, HRESULT 0x{2:X8}); sensitive details omitted.", + operation, error.GetType().Name, error.HResult); +} diff --git a/CodexUsageDock/Pages/CodexUsageDockPage.cs b/CodexUsageDock/Pages/CodexUsageDockPage.cs index b26e8e1..e0b35dc 100644 --- a/CodexUsageDock/Pages/CodexUsageDockPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockPage.cs @@ -8,6 +8,8 @@ namespace CodexUsageDock; internal sealed partial class CodexUsageDockPage : ContentPage, IDisposable { private static readonly string Version = CodexUsageDockMetadata.Version; + private readonly object _presentationLock = new(); + private bool _disposed; private readonly CodexUsageService _usage; private readonly CodexUsageDockSettingsPage _settings; private readonly FormContent _mainContent = new() @@ -136,7 +138,9 @@ internal static string FormatSummary(CodexUsageSnapshot snapshot, DateTimeOffset private static (string Title, string Description) FormatSummaryParts(CodexUsageSnapshot snapshot, DateTimeOffset now) { - var activeWindow = snapshot.Primary ?? snapshot.Secondary; + var activeWindow = snapshot.Primary is { } primary && snapshot.Secondary is { } secondary + ? (primary.RemainingPercent <= secondary.RemainingPercent ? primary : secondary) + : snapshot.Primary ?? snapshot.Secondary; if (activeWindow is null) { return ("Status: Usage allowance unknown", FormatDataStatus(snapshot, now)); @@ -152,7 +156,10 @@ private static (string Title, string Description) FormatSummaryParts(CodexUsageS var weekly = snapshot.Primary is not null && snapshot.Secondary is not null ? $" Weekly allowance: {snapshot.Secondary.RemainingPercent:0}%." : string.Empty; - return ($"Status: {title}", $"{remaining:0}% available; resets {FormatRelativeTime(activeWindow.ResetsAt, now)}.{weekly}"); + var limitingWindow = snapshot.Primary is not null && snapshot.Secondary is not null + ? (ReferenceEquals(activeWindow, snapshot.Secondary) ? "Weekly window: " : "5-hour window: ") + : string.Empty; + return ($"Status: {title}", $"{limitingWindow}{remaining:0}% available; resets {FormatRelativeTime(activeWindow.ResetsAt, now)}.{weekly}"); } internal static string FormatCredits(CreditBalance? credits) @@ -209,7 +216,7 @@ internal static string FormatWindow(string name, RateLimitWindow? window, DateTi return $"## {name}\n\n**{window.RemainingPercent:0}% available** \nResets {FormatRelativeTime(window.ResetsAt, now)} · {FormatLocalTime(window.ResetsAt, "ddd d MMM HH:mm")}"; } - private static TrendAnalysis? AddWindowData( + private static UsageTrendAnalyzer.TrendAnalysis? AddWindowData( JsonObject data, string prefix, string windowName, @@ -248,9 +255,9 @@ internal static string FormatWindow(string name, RateLimitWindow? window, DateTi data[$"{prefix}PaceStatus"] = paceStatus; data[$"{prefix}PaceColor"] = paceColor; - var currentHistory = GetCurrentTrendHistory(history, windowStartsAt); - var trend = AnalyzeTrend( - currentHistory, + + var trend = UsageTrendAnalyzer.Analyze( + history, windowStartsAt, window.ResetsAt, now, @@ -266,7 +273,7 @@ private static void AddWeeklyTrendData( JsonObject data, RateLimitWindow? window, IReadOnlyList history, - TrendAnalysis? trend, + UsageTrendAnalyzer.TrendAnalysis? trend, bool dataAvailable, DateTimeOffset now, TimeSpan maximumGap, @@ -282,7 +289,7 @@ private static void AddWeeklyTrendData( data["weeklyForecastStatus"] = trend.ForecastStatus; var windowStartsAt = window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes); - var chartHistory = GetHistoryInWindow(history, windowStartsAt); + var chartHistory = history.Where(sample => sample.RecordedAt >= windowStartsAt).ToArray(); if (chartHistory.Length < 2) { return; @@ -458,7 +465,7 @@ private static string FormatTrendBodyForReset( bool adaptiveWeeklyForecastEnabled = false, AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null) { - var analysis = AnalyzeTrend( + var analysis = UsageTrendAnalyzer.Analyze( history, windowStartsAt, resetsAt, @@ -476,148 +483,11 @@ private static string FormatTrendBodyForReset( return $"{analysis.HistoryValues} \n{message}"; } - private static TrendAnalysis AnalyzeTrend( - IReadOnlyList history, - DateTimeOffset? windowStartsAt, - DateTimeOffset? resetsAt, - DateTimeOffset now, - bool dataAvailable, - TimeSpan maximumSampleAge, - bool adaptiveWeeklyForecastEnabled = false, - AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null) - { - if (!dataAvailable) - { - return new([], null, "Projection unavailable until fresh usage data is loaded.", false, null, "Forecast unavailable."); - } - - var currentWindow = GetCurrentTrendHistory(history, windowStartsAt); - if (currentWindow.Length < 2) - { - return new(currentWindow, null, "Projection will appear after another measurement.", false, null, "Forecast: waiting for another measurement."); - } - - var samples = currentWindow.Length <= 5 ? currentWindow : currentWindow.Where((_, index) => index % Math.Max(1, currentWindow.Length / 4) == 0).Take(4).Append(currentWindow[^1]).ToArray(); - var values = string.Join(" → ", samples.Select(sample => $"{sample.RemainingPercent:0}%")); - var first = currentWindow[0]; - var last = currentWindow[^1]; - var elapsedMinutes = (last.RecordedAt - first.RecordedAt).TotalMinutes; - var consumed = first.RemainingPercent - last.RemainingPercent; - if (elapsedMinutes < 2 || consumed <= 0.5) - { - return new(currentWindow, values, "No meaningful change yet; projection pending.", false, null, "Forecast: waiting for a meaningful change."); - } - - if (now - last.RecordedAt > maximumSampleAge) - { - return new(currentWindow, values, "Projection paused because the latest measurement is too old.", false, null, "Forecast: waiting for a fresh measurement."); - } - - var currentRate = consumed / elapsedMinutes; - if (resetsAt is { } reset) - { - AdaptiveWeeklyForecastProjection projection; - if (windowStartsAt is { } start) - { - projection = AdaptiveWeeklyForecast.Project( - last, - start, - reset, - currentRate, - adaptiveWeeklyForecastEnabled, - adaptiveWeeklyHistory); - } - else - { - var estimatedAtCurrentRate = last.RecordedAt.AddMinutes(last.RemainingPercent / currentRate); - var forecast = estimatedAtCurrentRate >= reset - ? new UsageTrendForecast( - reset, - Math.Max(0, last.RemainingPercent - currentRate * (reset - last.RecordedAt).TotalMinutes), - false, - [new UsageTrendForecastPoint(reset, Math.Max(0, last.RemainingPercent - currentRate * (reset - last.RecordedAt).TotalMinutes))]) - : new UsageTrendForecast( - estimatedAtCurrentRate, - 0, - true, - [new UsageTrendForecastPoint(estimatedAtCurrentRate, 0)]); - projection = new AdaptiveWeeklyForecastProjection(forecast, "Forecast: current pace only."); - } - - if (!projection.Forecast.ReachesLimitBeforeReset) - { - return new( - currentWindow, - values, - $"Projected at reset: {projection.Forecast.RemainingPercent:0}% available.", - true, - projection.Forecast, - projection.Status); - } - - return new( - currentWindow, - values, - $"At the current rate, the limit may be reached around {FormatLimitEstimate(projection.Forecast.EndsAt, now)}.", - true, - projection.Forecast, - projection.Status); - } - - var minutesToEmpty = last.RemainingPercent / currentRate; - var estimated = last.RecordedAt.AddMinutes(minutesToEmpty); - - return new( - currentWindow, - values, - $"At the current rate, the limit may be reached around {FormatLimitEstimate(estimated, now)}.", - true, - new UsageTrendForecast(estimated, 0, true, [new UsageTrendForecastPoint(estimated, 0)]), - "Forecast: current pace only."); - } - - private sealed record TrendAnalysis( - UsageHistoryEntry[] History, - string? HistoryValues, - string Message, - bool IsEstimate, - UsageTrendForecast? Forecast, - string ForecastStatus); - - private static UsageHistoryEntry[] GetCurrentTrendHistory( - IReadOnlyList history, - DateTimeOffset? windowStartsAt) - { - var historyInCurrentWindow = GetHistoryInWindow(history, windowStartsAt); - var segmentStart = 0; - for (var index = 1; index < historyInCurrentWindow.Length; index++) - { - if (WeeklyAllowanceRestoration.IsIncrease(historyInCurrentWindow[index - 1], historyInCurrentWindow[index])) - { - segmentStart = index; - } - } - - return historyInCurrentWindow[segmentStart..]; - } - - private static UsageHistoryEntry[] GetHistoryInWindow( - IReadOnlyList history, - DateTimeOffset? windowStartsAt) => - windowStartsAt is { } start - ? history.Where(sample => sample.RecordedAt >= start).ToArray() - : history.ToArray(); - - private static string FormatLimitEstimate(DateTimeOffset estimated, DateTimeOffset now) => - estimated.ToLocalTime().Date == now.ToLocalTime().Date - ? FormatLocalTime(estimated, "HH:mm") - : FormatLocalTime(estimated, "ddd d MMM HH:mm"); - private static TimeSpan TrendFreshness(TimeSpan refreshInterval) => - refreshInterval > TimeSpan.FromMinutes(5) ? refreshInterval : TimeSpan.FromMinutes(5); + UsageTrendHistory.Freshness(refreshInterval); private static TimeSpan TrendMaximumGap(TimeSpan refreshInterval) => - TimeSpan.FromTicks(TrendFreshness(refreshInterval).Ticks * 3); + UsageTrendHistory.MaximumGap(refreshInterval); internal static string FormatResetSummary(RateLimitResetCredits? resets, DateTimeOffset now) { @@ -678,28 +548,44 @@ private static string FormatLocalTime(DateTimeOffset value, string format) => private void UpdatePresentation() { - var snapshot = _usage.Current; - var now = DateTimeOffset.Now; - IsLoading = _usage.IsLoading; - _mainContent.DataJson = FormatMainDataJson( - snapshot, - now, - _usage.IsLoading, - _usage.PrimaryHistory, - _usage.WeeklyHistory, - _usage.RefreshInterval, - _settings.UseAdaptiveWeeklyForecast, - _usage.AdaptiveWeeklyHistory, - _usage.CurrentTokenUsage); - _details.Body = FormatDetailsBody(snapshot, now, _usage.WeeklyHistory); + lock (_presentationLock) + { + if (_disposed) + { + return; + } + + var snapshot = _usage.Current; + var now = DateTimeOffset.Now; + IsLoading = _usage.IsLoading; + _mainContent.DataJson = FormatMainDataJson( + snapshot, + now, + _usage.IsLoading, + _usage.PrimaryHistory, + _usage.WeeklyHistory, + _usage.RefreshInterval, + _settings.UseAdaptiveWeeklyForecast, + _usage.AdaptiveWeeklyHistory, + _usage.CurrentTokenUsage); + _details.Body = FormatDetailsBody(snapshot, now, _usage.WeeklyHistory) + + (_usage.HistoryStorageError is { } error ? $"\n\n> **Local storage:** {error}" : string.Empty); + } + RaiseItemsChanged(0); } private void OnUpdated(object? sender, EventArgs e) => UpdatePresentation(); + internal void Refresh() => UpdatePresentation(); + public void Dispose() { - _usage.Updated -= OnUpdated; + lock (_presentationLock) + { + _disposed = true; + _usage.Updated -= OnUpdated; + } GC.SuppressFinalize(this); } } diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 6937ecb..4ca0c76 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -1,6 +1,8 @@ using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CmdPal.Common.Commands; +using System.Text.Json; +using System.Text.Json.Nodes; namespace CodexUsageDock; @@ -13,9 +15,22 @@ internal sealed partial class CodexUsageDockSettingsPage : ContentPage private const string RefreshIntervalKey = "refreshInterval"; private const string UseAdaptiveWeeklyForecastKey = "useAdaptiveWeeklyForecast"; private readonly Settings _settings = new(); + private readonly string _path; + private readonly FormContent _statusContent = new() + { + TemplateJson = """ + {"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"${message}","color":"${color}","wrap":true}]} + """, + }; public CodexUsageDockSettingsPage() + : this(LocalStorage.GetPath("settings.json")) + { + } + + internal CodexUsageDockSettingsPage(string path) { + _path = Path.GetFullPath(path); Name = "Settings"; Title = "Codex Usage settings"; Icon = new IconInfo("\uE713"); @@ -74,6 +89,7 @@ public CodexUsageDockSettingsPage() Title = "Delete learned forecast history", }, ]; + Load(); _settings.SettingsChanged += OnSettingsChanged; } @@ -93,7 +109,18 @@ public CodexUsageDockSettingsPage() public TimeSpan RefreshInterval => ParseRefreshInterval(_settings.GetSetting(RefreshIntervalKey)); - public override IContent[] GetContent() => _settings.ToContent(); + internal string? StatusMessage { get; private set; } + + public override IContent[] GetContent() => StatusMessage is null + ? _settings.ToContent() + : [_statusContent, .. _settings.ToContent()]; + + internal void ShowOperationStatus(string? message, bool succeeded = false) + { + StatusMessage = message; + _statusContent.DataJson = new JsonObject { ["message"] = message, ["color"] = succeeded ? "Good" : "Attention" }.ToJsonString(); + RaiseItemsChanged(0); + } internal static TimeSpan ParseRefreshInterval(string? value) => value switch { @@ -102,5 +129,54 @@ public CodexUsageDockSettingsPage() _ => TimeSpan.FromMinutes(1), }; - private void OnSettingsChanged(object sender, Settings args) => Changed?.Invoke(this, EventArgs.Empty); + private void Load() + { + try + { + if (!File.Exists(_path)) + { + return; + } + + using var document = JsonDocument.Parse(File.ReadAllText(_path)); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new JsonException("Settings must be an object."); + } + + var valid = new JsonObject(); + foreach (var property in document.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.String) + { + continue; + } + + var value = property.Value.GetString(); + if (property.Name == RefreshIntervalKey && value is "1" or "5" or "15") + { + valid[property.Name] = value; + } + else if (property.Name is ShowFiveHourLimitKey or ShowWeeklyLimitKey or ShowResetsAndCreditsKey or ShowResetTimeKey or UseAdaptiveWeeklyForecastKey + && bool.TryParse(value, out var enabled)) + { + valid[property.Name] = enabled ? "true" : "false"; + } + } + + _settings.Update(valid.ToJsonString()); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException) + { + LocalStorage.TraceFailure("load settings", error); + ShowOperationStatus("Saved settings could not be read. Default settings are being used."); + } + } + + private void OnSettingsChanged(object sender, Settings args) + { + var saved = LocalStorage.TryWrite(_path, _settings.ToJson()); + ShowOperationStatus(saved ? null : "Settings apply now but could not be saved. Try saving again before restarting."); + Changed?.Invoke(this, EventArgs.Empty); + } } diff --git a/CodexUsageDock/UsageData.cs b/CodexUsageDock/UsageData.cs index 11ba5c6..ad489c0 100644 --- a/CodexUsageDock/UsageData.cs +++ b/CodexUsageDock/UsageData.cs @@ -188,9 +188,11 @@ internal static void ThrowIfNoKnownWindow(ClassifiedRateLimitWindows windows) || !window.TryGetProperty(usedPercentPropertyName, out var usedPercentValue) || usedPercentValue.ValueKind != JsonValueKind.Number || !usedPercentValue.TryGetDouble(out var usedPercent) + || !double.IsFinite(usedPercent) || !window.TryGetProperty(durationPropertyName, out var durationValue) || durationValue.ValueKind != JsonValueKind.Number || !durationValue.TryGetInt32(out var duration) + || duration <= 0 || !window.TryGetProperty(resetsAtPropertyName, out var resetsAtValue) || resetsAtValue.ValueKind != JsonValueKind.Number || !resetsAtValue.TryGetInt64(out var resetsAt)) @@ -200,7 +202,9 @@ internal static void ThrowIfNoKnownWindow(ClassifiedRateLimitWindows windows) try { - return new RateLimitWindow(usedPercent, duration, DateTimeOffset.FromUnixTimeSeconds(resetsAt)); + var reset = DateTimeOffset.FromUnixTimeSeconds(resetsAt); + _ = reset.AddMinutes(-duration); + return new RateLimitWindow(usedPercent, duration, reset); } catch (ArgumentOutOfRangeException) { diff --git a/CodexUsageDock/UsageDockItem.cs b/CodexUsageDock/UsageDockItem.cs index 44f28b3..f5db827 100644 --- a/CodexUsageDock/UsageDockItem.cs +++ b/CodexUsageDock/UsageDockItem.cs @@ -63,11 +63,25 @@ private void UpdateText() Title = $"{label} {window.RemainingPercent:0}%"; Subtitle = _settings?.ShowResetTime == false ? string.Empty : $"reset {FormatReset(window.ResetsAt)}"; + if (snapshot.Source == UsageDataSource.LocalSession) + { + Subtitle = FormatFallbackAge(snapshot, DateTimeOffset.Now) + + (Subtitle.Length > 0 ? $" · {Subtitle}" : string.Empty); + } Icon = new IconInfo(window.RemainingPercent <= 10 ? "\uE7BA" : "\uE916"); } internal void Refresh() => UpdateText(); + internal static string FormatFallbackAge(CodexUsageSnapshot snapshot, DateTimeOffset now) + { + var age = now - snapshot.UpdatedAt; + return age < TimeSpan.FromMinutes(1) ? "Fallback · less than a minute old" + : age < TimeSpan.FromHours(1) ? $"Fallback · {(int)age.TotalMinutes} minutes old" + : age < TimeSpan.FromDays(1) ? $"Fallback · {(int)age.TotalHours} hours old" + : $"Fallback · {(int)age.TotalDays} days old"; + } + internal static string FormatResetsAndCredits(CodexUsageSnapshot snapshot) { var resets = snapshot.ResetCredits is null ? "--" : snapshot.ResetCredits.AvailableCount.ToString(CultureInfo.CurrentCulture); diff --git a/CodexUsageDock/UsageTrendAnalyzer.cs b/CodexUsageDock/UsageTrendAnalyzer.cs new file mode 100644 index 0000000..0adc10d --- /dev/null +++ b/CodexUsageDock/UsageTrendAnalyzer.cs @@ -0,0 +1,120 @@ +using System.Globalization; + +namespace CodexUsageDock; + +internal static class UsageTrendAnalyzer +{ + internal static TrendAnalysis Analyze( + IReadOnlyList history, + DateTimeOffset? windowStartsAt, + DateTimeOffset? resetsAt, + DateTimeOffset now, + bool dataAvailable, + TimeSpan maximumSampleAge, + bool adaptiveWeeklyForecastEnabled = false, + AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null) + { + if (!dataAvailable) + { + return new([], null, "Projection unavailable until fresh usage data is loaded.", false, null, "Forecast unavailable."); + } + + var currentWindow = UsageTrendHistory.LatestSegment(history, windowStartsAt, resetsAt, now, TimeSpan.FromTicks(maximumSampleAge.Ticks * 3)); + if (currentWindow.Length > 0 && now - currentWindow[^1].RecordedAt > maximumSampleAge) + { + return new(currentWindow, null, "Projection paused because the latest measurement is too old.", false, null, "Forecast: waiting for a fresh measurement."); + } + + if (currentWindow.Length < 2) + { + return new(currentWindow, null, "Projection will appear after another measurement.", false, null, "Forecast: waiting for another measurement."); + } + + var samples = currentWindow.Length <= 5 ? currentWindow : currentWindow.Where((_, index) => index % Math.Max(1, currentWindow.Length / 4) == 0).Take(4).Append(currentWindow[^1]).ToArray(); + var values = string.Join(" → ", samples.Select(sample => $"{sample.RemainingPercent:0}%")); + var first = currentWindow[0]; + var last = currentWindow[^1]; + var elapsedMinutes = (last.RecordedAt - first.RecordedAt).TotalMinutes; + var consumed = first.RemainingPercent - last.RemainingPercent; + if (elapsedMinutes < 2 || consumed <= 0.5) + { + return new(currentWindow, values, "No meaningful change yet; projection pending.", false, null, "Forecast: waiting for a meaningful change."); + } + + var currentRate = consumed / elapsedMinutes; + if (resetsAt is { } reset) + { + AdaptiveWeeklyForecastProjection projection; + if (windowStartsAt is { } start) + { + projection = AdaptiveWeeklyForecast.Project( + last, + start, + reset, + currentRate, + adaptiveWeeklyForecastEnabled, + adaptiveWeeklyHistory); + } + else + { + var estimatedAtCurrentRate = last.RecordedAt.AddMinutes(last.RemainingPercent / currentRate); + var forecast = estimatedAtCurrentRate >= reset + ? new UsageTrendForecast( + reset, + Math.Max(0, last.RemainingPercent - currentRate * (reset - last.RecordedAt).TotalMinutes), + false, + [new UsageTrendForecastPoint(reset, Math.Max(0, last.RemainingPercent - currentRate * (reset - last.RecordedAt).TotalMinutes))]) + : new UsageTrendForecast( + estimatedAtCurrentRate, + 0, + true, + [new UsageTrendForecastPoint(estimatedAtCurrentRate, 0)]); + projection = new AdaptiveWeeklyForecastProjection(forecast, "Forecast: current pace only."); + } + + if (!projection.Forecast.ReachesLimitBeforeReset) + { + return new( + currentWindow, + values, + $"Projected at reset: {projection.Forecast.RemainingPercent:0}% available.", + true, + projection.Forecast, + projection.Status); + } + + return new( + currentWindow, + values, + $"At the current rate, the limit may be reached around {FormatLimitEstimate(projection.Forecast.EndsAt, now)}.", + true, + projection.Forecast, + projection.Status); + } + + var minutesToEmpty = last.RemainingPercent / currentRate; + var estimated = last.RecordedAt.AddMinutes(minutesToEmpty); + + return new( + currentWindow, + values, + $"At the current rate, the limit may be reached around {FormatLimitEstimate(estimated, now)}.", + true, + new UsageTrendForecast(estimated, 0, true, [new UsageTrendForecastPoint(estimated, 0)]), + "Forecast: current pace only."); + } + + internal sealed record TrendAnalysis( + UsageHistoryEntry[] History, + string? HistoryValues, + string Message, + bool IsEstimate, + UsageTrendForecast? Forecast, + string ForecastStatus); + + private static string FormatLimitEstimate(DateTimeOffset estimated, DateTimeOffset now) => + estimated.ToLocalTime().Date == now.ToLocalTime().Date + ? estimated.ToLocalTime().ToString("HH:mm", CultureInfo.CurrentCulture) + : estimated.ToLocalTime().ToString("ddd d MMM HH:mm", CultureInfo.CurrentCulture); + +} diff --git a/CodexUsageDock/UsageTrendHistory.cs b/CodexUsageDock/UsageTrendHistory.cs new file mode 100644 index 0000000..47b5a75 --- /dev/null +++ b/CodexUsageDock/UsageTrendHistory.cs @@ -0,0 +1,39 @@ +namespace CodexUsageDock; + +internal static class UsageTrendHistory +{ + internal static TimeSpan Freshness(TimeSpan refreshInterval) => + refreshInterval > TimeSpan.FromMinutes(5) ? refreshInterval : TimeSpan.FromMinutes(5); + + internal static TimeSpan MaximumGap(TimeSpan refreshInterval) => TimeSpan.FromTicks(Freshness(refreshInterval).Ticks * 3); + + internal static UsageHistoryEntry[] LatestSegment( + IReadOnlyList history, + DateTimeOffset? windowStart, + DateTimeOffset? windowEnd, + DateTimeOffset now, + TimeSpan maximumGap) + { + var samples = history + .Where(sample => sample is not null && double.IsFinite(sample.RemainingPercent) + && sample.RemainingPercent is >= 0 and <= 100 + && (!windowStart.HasValue || sample.RecordedAt >= windowStart) + && (!windowEnd.HasValue || sample.RecordedAt <= windowEnd) + && sample.RecordedAt <= now) + .OrderBy(sample => sample.RecordedAt) + .GroupBy(sample => sample.RecordedAt) + .Select(group => group.Last()) + .ToArray(); + var start = 0; + for (var index = 1; index < samples.Length; index++) + { + if (samples[index].RecordedAt - samples[index - 1].RecordedAt > maximumGap + || WeeklyAllowanceRestoration.IsIncrease(samples[index - 1], samples[index])) + { + start = index; + } + } + + return samples[start..]; + } +} diff --git a/CodexUsageDock/WeeklyUsageHistoryStore.cs b/CodexUsageDock/WeeklyUsageHistoryStore.cs index 964c5be..3155f22 100644 --- a/CodexUsageDock/WeeklyUsageHistoryStore.cs +++ b/CodexUsageDock/WeeklyUsageHistoryStore.cs @@ -13,13 +13,9 @@ internal WeeklyUsageHistoryStore(string path) _path = path; } - internal static WeeklyUsageHistoryStore CreateDefault() - { - var directory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "CodexUsageDock"); - return new WeeklyUsageHistoryStore(Path.Combine(directory, FileName)); - } + internal static WeeklyUsageHistoryStore CreateDefault() => new(LocalStorage.GetPath(FileName)); + + internal string? StorageError { get; private set; } internal IReadOnlyList Load(DateTimeOffset now) { @@ -41,43 +37,18 @@ internal IReadOnlyList Load(DateTimeOffset now) } catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException or NotSupportedException) { + LocalStorage.TraceFailure("load weekly history", error); + StorageError = "Saved weekly history could not be read. A new history will be collected."; return []; } } - internal void Save(IReadOnlyList entries) + internal bool Save(IReadOnlyList entries) { - var temporaryPath = $"{_path}.{Guid.NewGuid():N}.tmp"; - try - { - var directory = Path.GetDirectoryName(_path); - if (string.IsNullOrWhiteSpace(directory)) - { - return; - } - - Directory.CreateDirectory(directory); - File.WriteAllText(temporaryPath, JsonSerializer.Serialize(entries, typeof(IReadOnlyList), UsageHistoryJsonContext.Default)); - File.Move(temporaryPath, _path, overwrite: true); - } - catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException or NotSupportedException) - { - } - finally - { - try - { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } - } - catch (Exception error) when (error is IOException or UnauthorizedAccessException) - { - } - } + var saved = LocalStorage.TryWrite(_path, JsonSerializer.Serialize(entries, typeof(IReadOnlyList), UsageHistoryJsonContext.Default)); + StorageError = saved ? null : "Weekly usage history could not be saved. It may be lost after restarting."; + return saved; } - private static UsageHistoryEntry[] Normalize(List? entries, DateTimeOffset now) { if (entries is null) @@ -87,11 +58,11 @@ private static UsageHistoryEntry[] Normalize(List? entries, D var cutoff = now - TimeSpan.FromDays(7); return entries - .Where(entry => entry.RecordedAt >= cutoff + .Where(entry => entry is not null && entry.RecordedAt >= cutoff && entry.RecordedAt <= now && double.IsFinite(entry.RemainingPercent) && entry.RemainingPercent is >= 0 and <= 100) - .Select(entry => entry.ResetsAt is not null && entry.WindowMinutes is > 0 + .Select(entry => entry.ResetsAt is { } reset && entry.WindowMinutes == 10080 && reset >= DateTimeOffset.MinValue.AddDays(7) ? entry : entry with { ResetsAt = null, WindowMinutes = null }) .OrderBy(entry => entry.RecordedAt) diff --git a/CodexUsageDock/WeeklyUsageTrendChart.cs b/CodexUsageDock/WeeklyUsageTrendChart.cs index df39dbd..b5596eb 100644 --- a/CodexUsageDock/WeeklyUsageTrendChart.cs +++ b/CodexUsageDock/WeeklyUsageTrendChart.cs @@ -102,9 +102,8 @@ internal static class WeeklyUsageTrendChartRenderer ApplyDailyTokens(dailyUse, tokenUsage); var tokenScaleMaximum = GetTokenScaleMaximum(dailyUse); var calendarScale = new CalendarDayScale(dailyUse); - var forecastSegments = SplitAtGapsOrQuotaIncreases(samples, maximumGap); var renderedSegments = DownsampleSegments(SplitAtQuotaIncreases(samples), windowStart, window.ResetsAt); - var latestSegment = forecastSegments.LastOrDefault(); + var latestSegment = UsageTrendHistory.LatestSegment(samples, windowStart, window.ResetsAt, effectiveNow, maximumGap); var forecastSegment = latestSegment is { Length: >= 2 } ? latestSegment : null; var usableForecast = forecastSegment is not null && forecast is { } candidate && candidate.EndsAt > forecastSegment[^1].RecordedAt ? candidate @@ -236,17 +235,6 @@ private static UsageHistoryEntry[] Downsample( .ToArray(); } - private static List SplitAtGapsOrQuotaIncreases( - UsageHistoryEntry[] samples, - TimeSpan maximumGap) - { - var gap = maximumGap > TimeSpan.Zero ? maximumGap : TimeSpan.FromMinutes(5); - return SplitAtDiscontinuities( - samples, - (previous, current) => current.RecordedAt - previous.RecordedAt > gap || - WeeklyAllowanceRestoration.IsIncrease(previous, current)); - } - private static List SplitAtQuotaIncreases(UsageHistoryEntry[] samples) => SplitAtDiscontinuities( samples, diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 87228e4..f780b24 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -56,6 +56,8 @@ Run a testhost only on a compatible Windows architecture. ARM64 Windows can run Always specify both `Platform` and its matching RID. Omitting `-r win-x64` or `-r win-arm64` can make MSBuild combine the host architecture with a conflicting `PlatformTarget`. +Tests must use `TestEnvironment` or explicitly inject both history stores and a temporary settings path. Only the production parameterless service constructor selects the current user's storage. Never instantiate it in a unit test. Each test owns and deletes its unique temporary directory; synthetic quota events must never reach real user history. Token tests await `TokenRefreshTask` separately because `RefreshAsync` completes when limits are published. Forecast arithmetic fixtures should use continuous measurements; gap and stale-data scenarios are tested separately. + ## Integration smoke test After building the matching Debug package, run the non-destructive preflight: @@ -95,6 +97,8 @@ Complete every row on a clean x64 environment and a separate clean ARM64 environ Store install, update, and uninstall behavior must be tested with a Store-signed test acquisition when it is available. Development manifest registration is sufficient only for the earlier COM activation, discovery, page, Dock, and settings checks. +For settings and storage changes, also restart Command Palette and Windows in the isolated test environment and verify all saved choices, including the first refresh interval. Make the test settings/history file unwritable, verify a visible failure, restore write access, and retry. A failed learned-history deletion must preserve the saved history; a successful deletion must remain cleared after restart. With a large synthetic session directory, verify that limits appear before token analysis completes and that text and chart projections both pause after a measurement gap. + ## Build the Microsoft Store package The package artwork is generated from one canonical visual mark. Treat `scripts/generate-assets.ps1` as its source instead of editing individual PNG files. The release builder compares decoded artwork with a small rendering tolerance, because PNG encoding and anti-aliasing can differ between supported build hosts without changing the design: diff --git a/PRIVACY.md b/PRIVACY.md index ce0c4c5..5c34a74 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy -Last updated: July 19, 2026 +Last updated: September 9, 2026 Codex Usage Dock is a local Windows extension for PowerToys Command Palette. It displays Codex usage limits, earned resets, reset expiry times, and available credits in the Command Palette Dock. @@ -16,6 +16,8 @@ Communication initiated by the extension is limited to the local Codex app-serve ## Data storage +Settings are saved explicitly in `CodexUsageDock/settings.json` under the current Windows user's local application data directory, alongside the local history files. This file contains only display, refresh, and forecasting preferences. Failed writes are reported without logging file contents, personal paths, or credentials. Learned-history deletion is confirmed only after its empty state has been saved successfully. + The extension does not create an external user account or remote database. Settings and temporary runtime state remain on the user's Windows device. Daily token totals and per-file read positions are kept only in memory and are rebuilt from the current weekly window after a restart. To keep the weekly usage trend available after Command Palette restarts, it stores a rolling maximum of seven days of local timestamps and remaining weekly-percentage measurements. When the adaptive weekly forecast is enabled, it also stores at most eight aggregated quota-cycle profiles: total observed duration and consumption, plus six-hour usage buckets relative to the reset. These files contain no account, session, prompt, or message content and are never transmitted. Users can pause learning while keeping those profiles; measurements collected while paused are not added later. Users can also delete the learned profiles from Codex Usage settings. ## Permissions diff --git a/README.md b/README.md index 1b68550..90ecac4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,11 @@ It displays: - the number of available earned resets and their expiry times; - the remaining credits balance when Codex provides it. -The values refresh once per minute. The extension reads live data from the standalone Codex CLI app-server and uses local Codex session metadata as a fallback. It also reads aggregate `token_count` records from active and archived local Codex session logs to show locally observed total tokens per calendar day. These token bars are local activity observations, not an exact accounting of allowance consumption. The details page identifies the allowance source: the live route is explicitly the CLI app-server; session metadata may have been written by the desktop app, CLI, or another local Codex client and cannot be attributed more precisely. +The values refresh once per minute by default, with 5- and 15-minute intervals available in settings. The extension reads live data from the standalone Codex CLI app-server and uses local Codex session metadata as a fallback. Fallback selection uses the quota event's own timestamp, and the Dock shows its age. Local session readers use `CODEX_HOME` when set, otherwise the current user's `.codex` directory. The overall status identifies the most restrictive active quota window. + +The extension also reads aggregate `token_count` records from active and archived local Codex session logs to show locally observed total tokens per calendar day. Limits update first; token bars update independently when local analysis finishes. These token bars are local activity observations, not an exact accounting of allowance consumption. The details page identifies the allowance source: the live route is explicitly the CLI app-server; session metadata may have been written by the desktop app, CLI, or another local Codex client and cannot be attributed more precisely. + +Forecasts use the average consumption rate since the beginning of the latest continuous segment. A segment starts again after an allowance increase or a gap longer than three times the freshness allowance (the greater of five minutes and the refresh interval). Text and chart projections pause until enough fresh, continuous measurements show a meaningful decrease. The solid observed line still connects sampled values across gaps. ## Requirements @@ -46,7 +50,9 @@ The Dock will show entries similar to `5h 47%`, `Week 86%`, and `2 resets · 10. ## Customize the Dock -Open Command Palette and select **Codex Usage settings** to choose which usage entries appear in the Dock. You can independently show or hide the five-hour limit, weekly limit, and resets and credits, choose whether usage entries show their reset time, set the local data refresh interval to 1, 5, or 15 minutes, and enable or pause the adaptive weekly forecast. Pausing the forecast keeps its learned local history and excludes measurements collected while it is paused; **Delete learned forecast history** asks for confirmation before permanently clearing it. Command Palette stores these settings for the current user. +Open Command Palette and select **Codex Usage settings** to choose which usage entries appear in the Dock. You can independently show or hide the five-hour limit, weekly limit, and resets and credits, choose whether usage entries show their reset time, set the local data refresh interval to 1, 5, or 15 minutes, and enable or pause the adaptive weekly forecast. Pausing the forecast keeps its learned local history and excludes measurements collected while it is paused; **Delete learned forecast history** asks for confirmation before permanently clearing it. + +The extension saves these choices in `CodexUsageDock/settings.json` under the current user's Windows local application data directory and restores them before refreshing after a restart. If saving fails, the page explains that the choices apply only to the running session and lets you save again. Deleting learned history confirms success only after the cleared state is saved. History read/write failures also appear in Details. Choices lost by older versions cannot be recovered; set them once again after updating. ## Update