From 1ee38a90a249059bfd6ae23974e8c7290b88aed4 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:48:35 +0200 Subject: [PATCH 1/3] Add retained history, workday planning, and explicit account actions --- CHANGELOG.md | 3 + .../OptionalActionServiceTests.cs | 376 +++++++++++ .../PlanningHistoryIntegrationTests.cs | 215 ++++++ .../TaskAndResetProtocolTests.cs | 331 ++++++++++ CodexUsageDock.Tests/TestEnvironment.cs | 8 +- .../UsageAggregateStoreTests.cs | 258 ++++++++ CodexUsageDock.Tests/UsagePlanningTests.cs | 326 +++++++++ CodexUsageDock/CodexAppServerReader.cs | 146 +++- .../CodexUsageDockCommandsProvider.cs | 16 + CodexUsageDock/CodexUsageService.Actions.cs | 191 ++++++ .../CodexUsageService.Aggregates.cs | 68 ++ CodexUsageDock/CodexUsageService.cs | 10 +- CodexUsageDock/Pages/CodexActionsPage.cs | 111 ++++ CodexUsageDock/Pages/CodexHistoryPage.cs | 68 ++ CodexUsageDock/Pages/CodexPlanningPage.cs | 77 +++ .../Pages/CodexUsageDockSettingsPage.cs | 36 + CodexUsageDock/ResetAttemptJournal.cs | 36 + CodexUsageDock/ResetCreditData.cs | 49 ++ CodexUsageDock/TaskUsageData.cs | 130 ++++ CodexUsageDock/UsageAggregateStore.cs | 624 ++++++++++++++++++ CodexUsageDock/UsagePlanning.cs | 596 +++++++++++++++++ PRIVACY.md | 6 +- README.md | 10 + SPRINTS.md | 12 +- 24 files changed, 3692 insertions(+), 11 deletions(-) create mode 100644 CodexUsageDock.Tests/OptionalActionServiceTests.cs create mode 100644 CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs create mode 100644 CodexUsageDock.Tests/TaskAndResetProtocolTests.cs create mode 100644 CodexUsageDock.Tests/UsageAggregateStoreTests.cs create mode 100644 CodexUsageDock.Tests/UsagePlanningTests.cs create mode 100644 CodexUsageDock/CodexUsageService.Actions.cs create mode 100644 CodexUsageDock/CodexUsageService.Aggregates.cs create mode 100644 CodexUsageDock/Pages/CodexActionsPage.cs create mode 100644 CodexUsageDock/Pages/CodexHistoryPage.cs create mode 100644 CodexUsageDock/Pages/CodexPlanningPage.cs create mode 100644 CodexUsageDock/ResetAttemptJournal.cs create mode 100644 CodexUsageDock/ResetCreditData.cs create mode 100644 CodexUsageDock/TaskUsageData.cs create mode 100644 CodexUsageDock/UsageAggregateStore.cs create mode 100644 CodexUsageDock/UsagePlanning.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4abe7cb..3162838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Each entry links to the commit or pull request that introduced the change. ### Added +- Optional account-scoped quota history with 7/30/90-day retention, explicit CSV/JSON exports, and confirmed deletion. +- A workday planner with per-day and per-hour quota budgets, measurement evidence, and held-out recent-pace checks. +- Task-level server usage estimates and explicitly confirmed earned resets with account verification and persistent request IDs for safe retries. - Optional quiet usage alerts from fresh, identified accounts, compact Dock labels, and separate pinnable quota and credit entries with stable identifiers. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Account-wide daily token activity on compatible Codex versions, with independent refresh and account-identity verification. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Explicit executable and Codex home settings, with invalid-source errors and protection against results from a previous profile. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) diff --git a/CodexUsageDock.Tests/OptionalActionServiceTests.cs b/CodexUsageDock.Tests/OptionalActionServiceTests.cs new file mode 100644 index 0000000..97786d7 --- /dev/null +++ b/CodexUsageDock.Tests/OptionalActionServiceTests.cs @@ -0,0 +1,376 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class OptionalActionServiceTests : IDisposable +{ + private const string AccountA = "synthetic-account-a"; + private const string AccountB = "synthetic-account-b"; + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + private string JournalPath => _environment.PathFor("optional-reset.json"); + + [Fact] + public async Task AmbiguousResetSurvivesRestartAndRetriesTheSameKeyWithoutAnotherReportedCredit() + { + string? firstKey = null; + using (var first = CreateService(() => Quota(AccountA, 1), reset: (_, _, key, _) => + { + firstKey = key; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Ambiguous, Now)); + })) + { + await first.RefreshAsync().WaitAsync(Timeout); + await first.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + Assert.NotNull(firstKey); + Assert.Equal(firstKey, ReadPendingKey(AccountA)); + } + + string? retriedKey = null; + using var restarted = CreateService(() => Quota(AccountA, 0), reset: (_, _, key, _) => + { + retriedKey = key; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.AlreadyRedeemed, Now)); + }); + await restarted.RefreshAsync().WaitAsync(Timeout); + await restarted.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(firstKey, retriedKey); + Assert.Null(ReadPendingKey(AccountA)); + Assert.Contains("No second reset", restarted.ResetActionStatus, StringComparison.Ordinal); + } + + [Fact] + public async Task FailedRetryPreflightRetainsAnEarlierAmbiguousKey() + { + var keys = new List(); + var call = 0; + var quota = Quota(AccountA, 1); + using var service = CreateService(() => quota, reset: (_, _, key, _) => + { + keys.Add(key); + var outcome = ++call switch + { + 1 => ResetCreditOutcome.Ambiguous, + 2 => ResetCreditOutcome.Unavailable, + _ => ResetCreditOutcome.AlreadyRedeemed, + }; + return Task.FromResult(new ResetCreditResult(outcome, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + var original = ReadPendingKey(AccountA); + + quota = Quota(AccountA, null); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + Assert.Equal(original, ReadPendingKey(AccountA)); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(3, keys.Count); + Assert.All(keys, key => Assert.Equal(original, key)); + Assert.Null(ReadPendingKey(AccountA)); + } + + [Fact] + public async Task ConcurrentResetRequestsShareOneCallAndOneSavedKey() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + using var service = CreateService(() => Quota(AccountA, 2), reset: (_, _, _, _) => + { + Interlocked.Increment(ref calls); + started.TrySetResult(); + return result.Task; + }); + await service.RefreshAsync().WaitAsync(Timeout); + var first = service.ConsumeEarnedResetAsync(AccountA); + await started.Task.WaitAsync(Timeout); + var second = service.ConsumeEarnedResetAsync(AccountA); + var third = service.ConsumeEarnedResetAsync(AccountA); + Assert.Same(first, second); + Assert.Same(first, third); + Assert.NotNull(ReadPendingKey(AccountA)); + + result.SetResult(new(ResetCreditOutcome.Reset, Now)); + await Task.WhenAll(first, second, third).WaitAsync(Timeout); + Assert.Equal(1, calls); + Assert.Null(ReadPendingKey(AccountA)); + } + + [Theory] + [InlineData(null)] + [InlineData(0)] + public async Task UnknownOrZeroCreditsDoNotCreateANewResetAttempt(int? available) + { + var calls = 0; + using var service = CreateService(() => Quota(AccountA, available), reset: (_, _, _, _) => + { + calls++; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Reset, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(0, calls); + Assert.Null(ReadPendingKey(AccountA)); + Assert.False(File.Exists(LocalStorage.ContextPath(JournalPath, AccountA))); + } + + [Theory] + [InlineData(null)] + [InlineData(0)] + public async Task PendingResetCanBeRetriedWhenNoCreditCountIsAvailable(int? available) + { + var original = Guid.Parse("12345678-abcd-4321-abcd-1234567890ab").ToString("N"); + Assert.True(new ResetAttemptJournal(JournalPath).Save(AccountA, original)); + string? sentKey = null; + using var service = CreateService(() => Quota(AccountA, available), reset: (_, _, key, _) => + { + sentKey = key; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.AlreadyRedeemed, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(original, sentKey); + Assert.Null(ReadPendingKey(AccountA)); + } + + [Fact] + public async Task ExistingOpaqueKeyKeepsItsExactCasingDuringRetry() + { + var original = Guid.Parse("abcdef12-abcd-4321-abcd-1234567890ab").ToString("N").ToUpperInvariant(); + Assert.True(new ResetAttemptJournal(JournalPath).Save(AccountA, original)); + string? sentKey = null; + using var service = CreateService(() => Quota(AccountA, 0), reset: (_, _, key, _) => + { + sentKey = key; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Ambiguous, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(original, sentKey); + Assert.Equal(original, ReadPendingKey(AccountA)); + } + + [Fact] + public async Task PendingKeyIsDurablySavedBeforeTheConsumerIsCalled() + { + string? sentKey = null; + string? savedAtCall = null; + var recordReadableAtCall = false; + using var service = CreateService(() => Quota(AccountA, 1), reset: (_, account, key, _) => + { + sentKey = key; + recordReadableAtCall = new ResetAttemptJournal(JournalPath).TryRead(account, out savedAtCall); + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Ambiguous, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.True(recordReadableAtCall); + Assert.NotNull(sentKey); + Assert.Equal(sentKey, savedAtCall); + Assert.Equal(sentKey, ReadPendingKey(AccountA)); + } + + [Theory] + [InlineData("invalid-json")] + [InlineData("[]")] + [InlineData("{\"schemaVersion\":\"1\",\"pendingKey\":null}")] + [InlineData("{\"schemaVersion\":2,\"pendingKey\":null}")] + [InlineData("{\"schemaVersion\":1,\"pendingKey\":\"invalid-key\"}")] + public async Task InvalidJournalBlocksTheActionWithoutOverwritingRecoveryData(string contents) + { + var path = LocalStorage.ContextPath(JournalPath, AccountA); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, contents); + var calls = 0; + using var service = CreateService(() => Quota(AccountA, 1), reset: (_, _, _, _) => + { + calls++; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Reset, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(0, calls); + Assert.Equal(contents, File.ReadAllText(path)); + Assert.Contains("No reset request was sent", service.ResetActionStatus, StringComparison.Ordinal); + } + + [Fact] + public async Task JournalWriteFailurePreventsTheConsumerFromRunning() + { + Directory.CreateDirectory(LocalStorage.ContextPath(JournalPath, AccountA)); + var calls = 0; + using var service = CreateService(() => Quota(AccountA, 1), reset: (_, _, _, _) => + { + calls++; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Reset, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(0, calls); + Assert.Contains("could not be saved", service.ResetActionStatus, StringComparison.Ordinal); + } + + [Fact] + public async Task LateResetResultDoesNotReplaceTheNewAccountStatus() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var quota = Quota(AccountA, 1); + using var service = CreateService(() => quota, reset: (_, _, _, _) => + { + started.TrySetResult(); + return pending.Task; + }); + await service.RefreshAsync().WaitAsync(Timeout); + var action = service.ConsumeEarnedResetAsync(AccountA); + await started.Task.WaitAsync(Timeout); + quota = Quota(AccountB, 1); + await service.RefreshAsync().WaitAsync(Timeout); + var newAccountStatus = service.ResetActionStatus; + pending.SetResult(new(ResetCreditOutcome.Reset, Now)); + await action.WaitAsync(Timeout); + + Assert.Equal(AccountB, service.Current.AccountKey); + Assert.Equal(newAccountStatus, service.ResetActionStatus); + Assert.Null(ReadPendingKey(AccountA)); + Assert.Null(ReadPendingKey(AccountB)); + } + + [Fact] + public async Task LateTaskResultIsDiscardedAfterAnAccountChange() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var quota = Quota(AccountA, 1); + using var service = CreateService(() => quota, thread: (_, _, _, _) => + { + started.TrySetResult(); + return pending.Task; + }); + await service.RefreshAsync().WaitAsync(Timeout); + var action = service.ReadTaskUsageAsync("task-a"); + await started.Task.WaitAsync(Timeout); + quota = Quota(AccountB, 1); + await service.RefreshAsync().WaitAsync(Timeout); + var newAccountStatus = service.ThreadActionStatus; + pending.SetResult(TaskResult("task-a", AccountA)); + await action.WaitAsync(Timeout); + + Assert.Null(service.CurrentThreadUsage); + Assert.Equal(newAccountStatus, service.ThreadActionStatus); + } + + [Fact] + public async Task LatestRequestedTaskIsQueuedAndAnOlderTaskIsNeverPublishedAsItsResult() + { + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstResult = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondResult = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var published = new ConcurrentQueue(); + var calls = new ConcurrentQueue(); + using var service = CreateService(() => Quota(AccountA, 1), thread: (_, id, _, _) => + { + calls.Enqueue(id); + if (id == "task-a") + { + firstStarted.TrySetResult(); + return firstResult.Task; + } + secondStarted.TrySetResult(); + return secondResult.Task; + }); + service.Updated += (_, _) => + { + if (service.CurrentThreadUsage?.ThreadId is { } id) published.Enqueue(id); + }; + await service.RefreshAsync().WaitAsync(Timeout); + var first = service.ReadTaskUsageAsync("task-a"); + await firstStarted.Task.WaitAsync(Timeout); + var second = service.ReadTaskUsageAsync("task-b"); + firstResult.SetResult(TaskResult("task-a", AccountA)); + await secondStarted.Task.WaitAsync(Timeout); + Assert.Null(service.CurrentThreadUsage); + Assert.DoesNotContain("task-a", published); + secondResult.SetResult(TaskResult("task-b", AccountA)); + await Task.WhenAll(first, second).WaitAsync(Timeout); + + Assert.Collection(calls, id => Assert.Equal("task-a", id), id => Assert.Equal("task-b", id)); + Assert.Equal("task-b", service.CurrentThreadUsage!.ThreadId); + Assert.DoesNotContain("task-a", published); + } + + [Theory] + [InlineData(null, "task-a")] + [InlineData(AccountB, "task-a")] + [InlineData(AccountA, "foreign-task")] + public async Task TaskResponsesWithoutMatchingAccountAndTaskAreNotUsed(string? responseAccount, string responseTask) + { + using var service = CreateService(() => Quota(AccountA, 1), thread: (_, _, _, _) => + Task.FromResult(TaskResult(responseTask, responseAccount))); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ReadTaskUsageAsync("task-a").WaitAsync(Timeout); + + Assert.Null(service.CurrentThreadUsage); + Assert.DoesNotContain("estimate reported", service.ThreadActionStatus, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task StaleOrUnidentifiedUsageDoesNotAuthorizeResetOrTaskRequests() + { + var calls = 0; + var quota = Quota(null, 1); + using var service = CreateService(() => quota, + reset: (_, _, _, _) => { calls++; return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Reset, Now)); }, + thread: (_, id, account, _) => { calls++; return Task.FromResult(TaskResult(id, account)); }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + await service.ReadTaskUsageAsync("task-a").WaitAsync(Timeout); + quota = Quota(AccountA, 1) with { UpdatedAt = Now.AddHours(-1) }; + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + await service.ReadTaskUsageAsync("task-a").WaitAsync(Timeout); + + Assert.Equal(0, calls); + Assert.Null(service.CurrentThreadUsage); + Assert.Null(ReadPendingKey(AccountA)); + } + + private CodexUsageService CreateService(Func quota, + Func>? reset = null, + Func>? thread = null) + { + var service = _environment.CreateService(_ => Task.FromResult(quota()), () => quota(), clock: () => Now); + service.InitializeOptionalFeatures(_environment.PathFor("optional-aggregates.json"), JournalPath, reset, thread); + return service; + } + + private string? ReadPendingKey(string account) + { + Assert.True(new ResetAttemptJournal(JournalPath).TryRead(account, out var key)); + return key; + } + + private static CodexUsageSnapshot Quota(string? account, int? resets) => new( + null, new RateLimitWindow(20, 10080, Now.AddDays(3)), null, null, + resets is { } count ? new RateLimitResetCredits(count, null) : null, + Now, UsageDataSource.AppServer, null, AccountKey: account, DefaultBucketId: "codex"); + + private static ThreadUsageSnapshot TaskResult(string id, string? account) => + new(id, account, Now, ThreadUsageStatus.Available, EstimatedUsageCreditsMicros: 100, Groups: []); +} diff --git a/CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs b/CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs new file mode 100644 index 0000000..b560819 --- /dev/null +++ b/CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs @@ -0,0 +1,215 @@ +using System.Text.Json; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class PlanningHistoryIntegrationTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + [Fact] + public void PlanningPreferencesRoundTripAndExposeSafeDefaults() + { + var first = _environment.CreateSettings(); + + SubmitSettings(first, new Dictionary + { + ["historyRetentionDays"] = "90", + ["workdayEnd"] = "18:30", + ["remainingWorkdays"] = "5", + }); + + Assert.Equal(90, first.HistoryRetentionDays); + Assert.Equal(new TimeOnly(18, 30), first.WorkdayEnd); + Assert.Equal(5, first.RemainingWorkdays); + + var restarted = _environment.CreateSettings(); + Assert.Equal(90, restarted.HistoryRetentionDays); + Assert.Equal(new TimeOnly(18, 30), restarted.WorkdayEnd); + Assert.Equal(5, restarted.RemainingWorkdays); + + File.Delete(_environment.PathFor("settings.json")); + var defaults = _environment.CreateSettings(); + Assert.Equal(0, defaults.HistoryRetentionDays); + Assert.Equal(new TimeOnly(17, 0), defaults.WorkdayEnd); + Assert.Equal(1, defaults.RemainingWorkdays); + } + + [Fact] + public void InvalidPlanningPreferencesUseSafeDefaults() + { + File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( + new Dictionary + { + ["historyRetentionDays"] = "365", + ["workdayEnd"] = "25:61", + ["remainingWorkdays"] = "0", + })); + + var settings = _environment.CreateSettings(); + + Assert.Equal(0, settings.HistoryRetentionDays); + Assert.Equal(new TimeOnly(17, 0), settings.WorkdayEnd); + Assert.Equal(1, settings.RemainingWorkdays); + } + + [Fact] + public void AggregateRetentionIsOptInAndPausingDoesNotAppend() + { + var now = Now; + using var service = CreateService(() => now); + + service.RecordHistory(Snapshot("account-a", "default", 80, now), now); + var paused = service.GetAggregateHistory(); + Assert.Equal(0, paused.RetentionDays); + Assert.True(paused.Identified); + Assert.Empty(paused.Points); + + service.SetAggregateRetentionDays(30); + now = Now.AddMinutes(6); + service.RecordHistory(Snapshot("account-a", "default", 70, now), now); + var enabled = service.GetAggregateHistory(); + Assert.Equal(30, enabled.RetentionDays); + Assert.Equal(70, Assert.Single(enabled.Points).PrimaryRemainingPercent); + + service.SetAggregateRetentionDays(0); + now = Now.AddMinutes(12); + service.RecordHistory(Snapshot("account-a", "default", 60, now), now); + var pausedAgain = service.GetAggregateHistory(); + Assert.Equal(0, pausedAgain.RetentionDays); + var retained = Assert.Single(pausedAgain.Points); + Assert.Equal(70, retained.PrimaryRemainingPercent); + } + + [Fact] + public void AggregateHistoryIsSeparatedByAccountAndCategory() + { + var now = Now; + using var service = CreateService(() => now); + service.SetAggregateRetentionDays(30); + + service.RecordHistory(Snapshot("account-a", "default", 80, now), now); + Assert.Equal(80, Assert.Single(service.GetAggregateHistory().Points).PrimaryRemainingPercent); + + now = Now.AddMinutes(6); + service.RecordHistory(Snapshot("account-b", "default", 20, now), now); + Assert.Equal(20, Assert.Single(service.GetAggregateHistory().Points).PrimaryRemainingPercent); + + now = Now.AddMinutes(12); + service.RecordHistory(Snapshot("account-a", "review", 50, now), now); + Assert.Equal(50, Assert.Single(service.GetAggregateHistory().Points).PrimaryRemainingPercent); + + // Revisit the original scope with its original measurement. The store + // must reload account-a/default rather than exposing the review or b data. + service.RecordHistory(Snapshot("account-a", "default", 80, Now), Now); + var restored = service.GetAggregateHistory(); + Assert.Equal(80, Assert.Single(restored.Points).PrimaryRemainingPercent); + Assert.True(restored.Identified); + } + + [Fact] + public void ExplicitExportIsScopedAndContainsNoIdentifiers() + { + var now = Now; + using var service = CreateService(() => now); + service.SetAggregateRetentionDays(30); + + service.RecordHistory(Snapshot("account-a", "default", 80, now), now); + now = Now.AddMinutes(6); + service.RecordHistory(Snapshot("account-b", "default", 20, now), now); + + var result = service.ExportAggregateHistory(csv: false); + const string prefix = "Export saved to "; + Assert.StartsWith(prefix, result, StringComparison.Ordinal); + var exportPath = result[prefix.Length..]; + Assert.True(File.Exists(exportPath)); + + var content = File.ReadAllText(exportPath); + using var document = JsonDocument.Parse(content); + var observations = document.RootElement.GetProperty("observations"); + var observation = Assert.Single(observations.EnumerateArray()); + Assert.Equal(20, observation.GetProperty("primaryRemainingPercent").GetDouble()); + Assert.DoesNotContain("account-a", content, StringComparison.Ordinal); + Assert.DoesNotContain("account-b", content, StringComparison.Ordinal); + Assert.DoesNotContain("default", content, StringComparison.Ordinal); + Assert.DoesNotContain("accountKey", content, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("bucketId", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ContextGuardsKeepTheCurrentAccountHistoryAndBlockStaleActions() + { + var now = Now; + using var service = CreateService(() => now); + service.SetAggregateRetentionDays(30); + + service.RecordHistory(Snapshot("account-a", "default", 80, now), now); + var accountA = service.GetAggregateHistory(); + Assert.NotNull(accountA.Context); + + now = Now.AddMinutes(6); + service.RecordHistory(Snapshot("account-b", "default", 20, now), now); + var accountB = service.GetAggregateHistory(); + Assert.NotEqual(accountA.Context, accountB.Context); + Assert.Equal(20, Assert.Single(accountB.Points).PrimaryRemainingPercent); + + Assert.False(service.ClearAggregateHistory(accountA.Context)); + var exportDirectory = Path.Combine( + Path.GetDirectoryName(_environment.PathFor("aggregates.json"))!, + "exports"); + Assert.False(Directory.Exists(exportDirectory)); + + var staleExport = service.ExportAggregateHistory(csv: false, expectedContext: accountA.Context); + Assert.Contains("changed", staleExport, StringComparison.OrdinalIgnoreCase); + Assert.False(Directory.Exists(exportDirectory)); + + var preserved = service.GetAggregateHistory(); + Assert.Equal(accountB.Context, preserved.Context); + Assert.Equal(20, Assert.Single(preserved.Points).PrimaryRemainingPercent); + } + + private CodexUsageService CreateService(Func clock) => _environment.CreateService( + _ => Task.FromResult(CodexUsageSnapshot.Loading), + () => CodexUsageSnapshot.Loading, + clock: clock); + + private static CodexUsageSnapshot Snapshot( + string account, + string category, + double remaining, + DateTimeOffset updatedAt) => new( + new RateLimitWindow(100 - remaining, 300, updatedAt.AddHours(4)), + new RateLimitWindow(100 - remaining, 10080, updatedAt.AddDays(6)), + "pro", + null, + null, + updatedAt, + UsageDataSource.AppServer, + null, + AccountKey: account, + DefaultBucketId: category); + + private static void SubmitSettings( + CodexUsageDockSettingsPage page, + IReadOnlyDictionary values) + { + var payload = new Dictionary + { + ["historyRetentionDays"] = "0", + ["workdayEnd"] = "17:00", + ["remainingWorkdays"] = "1", + }; + + foreach (var pair in values) + { + payload[pair.Key] = pair.Value; + } + + var form = page.GetContent().OfType().Last(); + form.SubmitForm(JsonSerializer.Serialize(payload), "{}"); + } +} diff --git a/CodexUsageDock.Tests/TaskAndResetProtocolTests.cs b/CodexUsageDock.Tests/TaskAndResetProtocolTests.cs new file mode 100644 index 0000000..aa6c029 --- /dev/null +++ b/CodexUsageDock.Tests/TaskAndResetProtocolTests.cs @@ -0,0 +1,331 @@ +using System.Text.Json; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class TaskAndResetProtocolTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private static readonly string AccountKey = AccountScope("synthetic-account"); + private const string TaskId = "synthetic-thread-123"; + private const string ResetKey = "3d2054c5-d593-401d-84e4-bd8f733341cf"; + private const string AccountResponse = """{"result":{"accountId":"synthetic-account"}}"""; + private const string TaskResponse = """ + {"result":{"threadUsage":{"threadId":"synthetic-thread-123","estimatedUsageCreditsMicros":123456,"estimatedUsageUsdMicros":null,"groups":[]}}} + """; + + [Fact] + public void TaskUsagePreservesEstimatesAndNullableTokenClasses() + { + var snapshot = ParseThread(""" + { + "threadUsage": { + "threadId": "synthetic-thread-123", + "estimatedUsageCreditsMicros": 1234567, + "estimatedUsageUsdMicros": 12000, + "groups": [{ + "model": "synthetic-model", "reasoningEffort": "high", "speed": "fast", + "estimatedUsageCreditsMicros": 1234567, + "netNewInputTokens": 100, "cachedInputTokens": null, + "inputTokens": 150, "outputTokens": 20, "totalTokens": 170 + }] + } + } + """); + + Assert.Equal(ThreadUsageStatus.Available, snapshot.Status); + Assert.Equal(TaskId, snapshot.ThreadId); + Assert.Equal(AccountKey, snapshot.AccountKey); + Assert.Equal(1234567, snapshot.EstimatedUsageCreditsMicros); + Assert.Equal(12000, snapshot.EstimatedUsageUsdMicros); + var group = Assert.Single(snapshot.Groups!); + Assert.Equal("synthetic-model", group.Model); + Assert.Equal("high", group.ReasoningEffort); + Assert.Equal("fast", group.Speed); + Assert.Equal(100, group.NetNewInputTokens); + Assert.Null(group.CachedInputTokens); + Assert.Equal(150, group.InputTokens); + Assert.Equal(20, group.OutputTokens); + Assert.Equal(170, group.TotalTokens); + } + + [Fact] + public void InvalidTaskValuesAreUnknownAndLabelsAreSanitized() + { + var snapshot = ParseThread(""" + {"threadUsage": { + "threadId":"synthetic-thread-123", "estimatedUsageCreditsMicros":-1, "estimatedUsageUsdMicros":9223372036854775808, + "groups":[null,{}, {"model":" Model\n one ","reasoningEffort":[],"speed":"fast\tmode", + "estimatedUsageCreditsMicros":"100","netNewInputTokens":-2,"inputTokens":0,"outputTokens":null}] + }} + """); + + Assert.Equal(ThreadUsageStatus.Partial, snapshot.Status); + Assert.Null(snapshot.EstimatedUsageCreditsMicros); + Assert.Null(snapshot.EstimatedUsageUsdMicros); + var group = Assert.Single(snapshot.Groups!); + Assert.Equal("Model one", group.Model); + Assert.Null(group.ReasoningEffort); + Assert.Equal("fast mode", group.Speed); + Assert.Null(group.EstimatedUsageCreditsMicros); + Assert.Null(group.NetNewInputTokens); + Assert.Equal(0, group.InputTokens); + Assert.Null(group.TotalTokens); + } + + [Fact] + public void TaskGroupsAndLabelsAreBounded() + { + var snapshot = ParseThread(JsonSerializer.Serialize(new + { + threadUsage = new + { + threadId = TaskId, + estimatedUsageCreditsMicros = 0, + groups = Enumerable.Range(0, 70).Select(index => new + { + model = new string('m', 100) + index, + reasoningEffort = new string('e', 50), + speed = new string('s', 50), + totalTokens = index, + }), + }, + })); + + Assert.Equal(ThreadUsageStatus.Partial, snapshot.Status); + Assert.Equal(64, snapshot.Groups!.Count); + Assert.All(snapshot.Groups, group => + { + Assert.Equal(80, group.Model!.Length); + Assert.Equal(32, group.ReasoningEffort!.Length); + Assert.Equal(32, group.Speed!.Length); + }); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"threadUsage\":null}")] + [InlineData("{\"threadUsage\":{\"threadId\":\"foreign-thread\",\"estimatedUsageCreditsMicros\":100,\"groups\":[]}}")] + [InlineData("{\"threadUsage\":{\"threadId\":\"synthetic-thread-123\",\"estimatedUsageCreditsMicros\":null,\"groups\":[]}}")] + public void MissingOrForeignTaskUsageDoesNotProduceAnEstimate(string json) + { + var snapshot = ParseThread(json); + Assert.Equal(ThreadUsageStatus.Unavailable, snapshot.Status); + Assert.Null(snapshot.EstimatedUsageCreditsMicros); + Assert.Null(snapshot.Groups); + } + + [Fact] + public async Task TaskReadSendsTheSelectedIdBetweenMatchingAccountChecks() + { + var calls = new List(); + var result = await CodexAppServerReader.ReadThreadUsageSequenceAsync((method, id, parameters, _) => + { + calls.Add(CodexAppServerReader.CreateRequestJson(method, id, parameters)); + return Task.FromResult(JsonDocument.Parse(method == "account/usage/read" ? TaskResponse : AccountResponse)); + }, TaskId, AccountKey); + + Assert.Equal(3, calls.Count); + using var before = JsonDocument.Parse(calls[0]); + using var usage = JsonDocument.Parse(calls[1]); + using var after = JsonDocument.Parse(calls[2]); + Assert.Equal("account/rateLimits/read", before.RootElement.GetProperty("method").GetString()); + Assert.Equal("account/usage/read", usage.RootElement.GetProperty("method").GetString()); + Assert.Equal(TaskId, usage.RootElement.GetProperty("params").GetProperty("threadId").GetString()); + Assert.Equal("account/rateLimits/read", after.RootElement.GetProperty("method").GetString()); + Assert.Equal(ThreadUsageStatus.Available, result.Status); + Assert.Equal(123456, result.EstimatedUsageCreditsMicros); + } + + [Fact] + public async Task TaskReadRejectsAnAccountChangeBeforeOrAfterTheUsageRequest() + { + var beforeCalls = 0; + var beforeMismatch = await CodexAppServerReader.ReadThreadUsageSequenceAsync((_, _, _, _) => + { + beforeCalls++; + return Task.FromResult(JsonDocument.Parse(AccountResponse)); + }, TaskId, AccountScope("other-account")); + Assert.Equal(1, beforeCalls); + Assert.Equal(ThreadUsageStatus.AccountMismatch, beforeMismatch.Status); + + var afterMismatch = await CodexAppServerReader.ReadThreadUsageSequenceAsync((_, id, _, _) => + Task.FromResult(JsonDocument.Parse(id switch + { + 2 => AccountResponse, + 3 => TaskResponse, + _ => """{"result":{"accountId":"other-account"}}""", + })), TaskId, AccountKey); + Assert.Equal(ThreadUsageStatus.AccountMismatch, afterMismatch.Status); + Assert.Null(afterMismatch.EstimatedUsageCreditsMicros); + Assert.Null(afterMismatch.AccountKey); + } + + [Fact] + public async Task UnsupportedTaskMethodRemainsDistinctFromUnavailableUsage() + { + var result = await CodexAppServerReader.ReadThreadUsageSequenceAsync((_, id, _, _) => + Task.FromResult(JsonDocument.Parse(id == 2 ? AccountResponse + : """{"error":{"code":-32601,"message":"private error data"}}""")), TaskId, AccountKey); + Assert.Equal(ThreadUsageStatus.Unsupported, result.Status); + Assert.DoesNotContain("private", JsonSerializer.Serialize(result), StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData("thread\nrequest")] + [InlineData("thread with spaces")] + public async Task InvalidTaskIdsDoNotSendRequests(string id) + { + var result = await CodexAppServerReader.ReadThreadUsageSequenceAsync((_, _, _, _) => + throw new InvalidOperationException("Must not send"), id, AccountKey); + Assert.Equal(ThreadUsageStatus.Unavailable, result.Status); + } + + [Theory] + [InlineData("reset", (int)ResetCreditOutcome.Reset)] + [InlineData("alreadyRedeemed", (int)ResetCreditOutcome.AlreadyRedeemed)] + [InlineData("nothingToReset", (int)ResetCreditOutcome.NothingToReset)] + [InlineData("noCredit", (int)ResetCreditOutcome.NoCredit)] + [InlineData("unknown-outcome", (int)ResetCreditOutcome.Ambiguous)] + public void ResetParserPreservesTerminalServerOutcomes(string value, int expected) + { + using var document = JsonDocument.Parse(JsonSerializer.Serialize(new { result = new { outcome = value } })); + var result = ResetCreditParser.Parse(document.RootElement, Now); + Assert.Equal((ResetCreditOutcome)expected, result.Outcome); + Assert.Equal(Now, result.AttemptedAt); + } + + [Theory] + [InlineData("null")] + [InlineData("{}")] + [InlineData("{\"result\":{\"outcome\":null}}")] + [InlineData("{\"error\":{\"code\":-32603,\"message\":\"private details\"}}")] + [InlineData("{\"error\":{\"code\":-32601},\"result\":{\"outcome\":\"reset\"}}")] + public void UncertainResetRepliesRemainAmbiguous(string json) + { + using var document = JsonDocument.Parse(json); + var result = ResetCreditParser.Parse(document.RootElement, Now); + Assert.Equal(ResetCreditOutcome.Ambiguous, result.Outcome); + Assert.DoesNotContain("private", JsonSerializer.Serialize(result), StringComparison.Ordinal); + } + + [Fact] + public async Task ResetChecksTheExpectedAccountBeforeSendingTheCallerKey() + { + var requests = new List(); + var result = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((method, id, parameters, _) => + { + requests.Add(CodexAppServerReader.CreateRequestJson(method, id, parameters)); + return Task.FromResult(JsonDocument.Parse(id == 2 ? AccountResponse : """{"result":{"outcome":"reset"}}""")); + }, AccountKey, ResetKey); + + Assert.Equal(2, requests.Count); + using var before = JsonDocument.Parse(requests[0]); + using var consume = JsonDocument.Parse(requests[1]); + Assert.Equal("account/rateLimits/read", before.RootElement.GetProperty("method").GetString()); + Assert.Equal("account/rateLimitResetCredit/consume", consume.RootElement.GetProperty("method").GetString()); + Assert.Equal(ResetKey, consume.RootElement.GetProperty("params").GetProperty("idempotencyKey").GetString()); + Assert.False(consume.RootElement.GetProperty("params").TryGetProperty("creditId", out _)); + Assert.Equal(ResetCreditOutcome.Reset, result.Outcome); + } + + [Fact] + public async Task ResetAccountMismatchPreventsTheMutation() + { + var calls = 0; + var result = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, _, _, _) => + { + calls++; + return Task.FromResult(JsonDocument.Parse(AccountResponse)); + }, AccountScope("other-account"), ResetKey); + Assert.Equal(1, calls); + Assert.Equal(ResetCreditOutcome.AccountMismatch, result.Outcome); + + var unverified = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, _, _, _) => + throw new InvalidOperationException("Must not send"), "unverified", ResetKey); + Assert.Equal(ResetCreditOutcome.AccountMismatch, unverified.Outcome); + } + + [Fact] + public async Task FailedPreflightIsSafeButFailureAfterConsumeRemainsAmbiguousAndKeepsTheKey() + { + var unavailable = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, _, _, _) => + Task.FromException(new IOException("before consume")), AccountKey, ResetKey); + Assert.Equal(ResetCreditOutcome.Unavailable, unavailable.Outcome); + + var keys = new List(); + var failed = await Attempt(throwAfterSend: true); + Assert.Equal(ResetCreditOutcome.Ambiguous, failed.Outcome); + Assert.Single(keys); + var retried = await Attempt(throwAfterSend: false); + Assert.Equal(ResetCreditOutcome.AlreadyRedeemed, retried.Outcome); + Assert.Equal(new[] { ResetKey, ResetKey }, keys); + + Task Attempt(bool throwAfterSend) => + CodexAppServerReader.ConsumeResetCreditSequenceAsync((method, id, parameters, _) => + { + if (id == 2) return Task.FromResult(JsonDocument.Parse(AccountResponse)); + using var request = JsonDocument.Parse(CodexAppServerReader.CreateRequestJson(method, id, parameters)); + keys.Add(request.RootElement.GetProperty("params").GetProperty("idempotencyKey").GetString()!); + return throwAfterSend + ? Task.FromException(new IOException("after possible send")) + : Task.FromResult(JsonDocument.Parse("""{"result":{"outcome":"alreadyRedeemed"}}""")); + }, AccountKey, ResetKey); + } + + [Fact] + public async Task CancellationAfterConsumeBeginsIsAmbiguous() + { + using var cancellation = new CancellationTokenSource(); + var result = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, id, _, _) => + { + if (id == 2) return Task.FromResult(JsonDocument.Parse(AccountResponse)); + cancellation.Cancel(); + return Task.FromException(new OperationCanceledException(cancellation.Token)); + }, AccountKey, ResetKey, cancellation.Token); + Assert.Equal(ResetCreditOutcome.Ambiguous, result.Outcome); + } + + [Fact] + public async Task UnsupportedResetAndCancellationBeforeConsumeDoNotClaimAmbiguousConsumption() + { + var unsupported = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, id, _, _) => + Task.FromResult(JsonDocument.Parse(id == 2 ? AccountResponse : """{"error":{"code":-32601}}""")), AccountKey, ResetKey); + Assert.Equal(ResetCreditOutcome.Unsupported, unsupported.Outcome); + + using var cancellation = new CancellationTokenSource(); + var calls = 0; + var canceled = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, _, _, _) => + { + calls++; + cancellation.Cancel(); + return Task.FromResult(JsonDocument.Parse(AccountResponse)); + }, AccountKey, ResetKey, cancellation.Token); + Assert.Equal(1, calls); + Assert.Equal(ResetCreditOutcome.Unavailable, canceled.Outcome); + } + + [Theory] + [InlineData("")] + [InlineData("key\nwith-control")] + [InlineData("key with space")] + public async Task InvalidIdempotencyKeysFailBeforeAnyRequest(string key) + { + await Assert.ThrowsAsync(() => + CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, _, _, _) => + throw new InvalidOperationException("Must not send"), AccountKey, key)); + } + + private static ThreadUsageSnapshot ParseThread(string json) + { + using var document = JsonDocument.Parse(json); + return ThreadUsageParser.Parse(document.RootElement, TaskId, AccountKey, Now); + } + + private static string AccountScope(string accountId) + { + using var result = JsonDocument.Parse(JsonSerializer.Serialize(new { rateLimits = new { }, accountId })); + return CodexAppServerReader.ParseSnapshot(result.RootElement, default, Now).AccountKey!; + } +} diff --git a/CodexUsageDock.Tests/TestEnvironment.cs b/CodexUsageDock.Tests/TestEnvironment.cs index dd0d596..9057230 100644 --- a/CodexUsageDock.Tests/TestEnvironment.cs +++ b/CodexUsageDock.Tests/TestEnvironment.cs @@ -22,11 +22,15 @@ internal CodexUsageService CreateService( AdaptiveWeeklyUsageStore? adaptiveWeeklyUsageStore = null, Func>? localTokenUsageReader = null, Func? clock = null, - Func>? accountUsageReader = null) => - new(appServerReader, localSessionReader, + Func>? accountUsageReader = null) + { + var service = new CodexUsageService(appServerReader, localSessionReader, weeklyHistoryStore ?? new WeeklyUsageHistoryStore(PathFor("weekly.json")), adaptiveWeeklyUsageStore ?? new AdaptiveWeeklyUsageStore(PathFor("adaptive.json")), localTokenUsageReader, clock, accountUsageReader); + service.InitializeOptionalFeatures(PathFor("aggregates.json"), PathFor("reset-attempt.json")); + return service; + } internal CodexUsageService CreateService( Func> appServerReader, diff --git a/CodexUsageDock.Tests/UsageAggregateStoreTests.cs b/CodexUsageDock.Tests/UsageAggregateStoreTests.cs new file mode 100644 index 0000000..68145bf --- /dev/null +++ b/CodexUsageDock.Tests/UsageAggregateStoreTests.cs @@ -0,0 +1,258 @@ +using System.Text.Json; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class UsageAggregateStoreTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + [Fact] + public void RetentionCanBeChangedAndIsAppliedToReloadedData() + { + var path = _environment.PathFor("usage-aggregates.json"); + var store = new UsageAggregateStore(path, 30, () => Now); + + Assert.True(store.Record(Point(Now.AddDays(-20)), Now, out var firstError), firstError); + Assert.True(store.Record(Point(Now.AddDays(-6)), Now, out var secondError), secondError); + Assert.Equal(2, store.Snapshot.Count); + + Assert.True(store.SetRetentionDays(7, Now, out var retentionError), retentionError); + Assert.Single(store.Snapshot); + Assert.Equal(Now.AddDays(-6), store.Snapshot[0].RecordedAt); + + var reloaded = new UsageAggregateStore(path, 7, () => Now); + Assert.Equal(7, reloaded.RetentionDays); + Assert.Single(reloaded.Snapshot); + Assert.Equal(Now.AddDays(-6), reloaded.Snapshot[0].RecordedAt); + } + + [Fact] + public void SameBucketAndResetReplacesTheLatestPoint() + { + var path = _environment.PathFor("usage-aggregates.json"); + var store = new UsageAggregateStore(path, 30, () => Now); + var reset = Now.AddDays(1); + + Assert.True(store.Record(Point(Now.AddMinutes(1), 80, reset), Now.AddMinutes(1))); + Assert.True(store.Record(Point(Now.AddMinutes(4), 70, reset), Now.AddMinutes(4))); + + var replaced = Assert.Single(store.Snapshot); + Assert.Equal(70, replaced.PrimaryRemainingPercent); + Assert.Equal(Now.AddMinutes(4), replaced.RecordedAt); + } + + [Fact] + public void AResetChangeWithinTheSameBucketIsPreserved() + { + var path = _environment.PathFor("usage-aggregates.json"); + var store = new UsageAggregateStore(path, 30, () => Now); + + Assert.True(store.Record(Point(Now.AddMinutes(1), 80, Now.AddDays(1)), Now.AddMinutes(1))); + Assert.True(store.Record(Point(Now.AddMinutes(4), 70, Now.AddDays(2)), Now.AddMinutes(4))); + + Assert.Equal(2, store.Snapshot.Count); + Assert.Equal( + [80d, 70d], + store.Snapshot.Select(observation => observation.PrimaryRemainingPercent!.Value).ToArray()); + } + + [Fact] + public void LiveFactoryRequiresKnownFreshAppServerData() + { + var reset = Now.AddHours(4); + var snapshot = new CodexUsageSnapshot( + new RateLimitWindow(20, 300, reset), + null, + "pro", + null, + null, + Now.AddMinutes(-1), + UsageDataSource.AppServer, + null, + AccountKey: "already-hashed-account-key", + DefaultBucketId: "default"); + + Assert.True(UsageAggregateStore.TryCreateLivePoint(snapshot, Now, TimeSpan.FromMinutes(1), out var point)); + Assert.NotNull(point); + Assert.Equal(UsageDataSource.AppServer, point!.Source); + Assert.Equal(80, point.PrimaryRemainingPercent); + Assert.Equal(reset, point.PrimaryResetsAt); + Assert.DoesNotContain("already-hashed-account-key", JsonSerializer.Serialize(point), StringComparison.Ordinal); + + Assert.False(UsageAggregateStore.TryCreateLivePoint( + snapshot with { AccountKey = null }, Now, TimeSpan.FromMinutes(1), out _)); + Assert.False(UsageAggregateStore.TryCreateLivePoint( + snapshot with { Source = UsageDataSource.LocalSession }, Now, TimeSpan.FromMinutes(1), out _)); + Assert.False(UsageAggregateStore.TryCreateLivePoint( + snapshot with { UpdatedAt = Now.AddMinutes(-6) }, Now, TimeSpan.FromMinutes(1), out _)); + Assert.False(UsageAggregateStore.TryCreateLivePoint( + snapshot with { UpdatedAt = Now.AddMinutes(1) }, Now, TimeSpan.FromMinutes(1), out _)); + } + + [Fact] + public void InvalidDirectObservationIsRejectedWithGenericError() + { + var store = new UsageAggregateStore(_environment.PathFor("usage-aggregates.json"), 30, () => Now); + var invalid = new UsageAggregatePoint( + Now, + double.NaN, + null, + Now.AddHours(1), + null, + UsageDataSource.AppServer); + + Assert.False(store.Record(invalid, Now, out var error)); + Assert.Equal("The usage observation was invalid and was not saved.", error); + Assert.Empty(store.Snapshot); + } + + [Fact] + public void MalformedNullAndOversizedDocumentsAreBoundedSafely() + { + var path = _environment.PathFor("usage-aggregates.json"); + File.WriteAllText(path, "null"); + + var malformed = new UsageAggregateStore(path, 30, () => Now); + Assert.Empty(malformed.Snapshot); + Assert.Contains("could not be read", malformed.StorageError, StringComparison.Ordinal); + + var points = Enumerable.Range(0, UsageAggregateStore.MaximumEntries + 5) + .Select(index => Point( + Now.AddMinutes(-index * 2), + 20 + index % 70, + Now.AddMinutes(-index * 2).AddDays(1))) + .ToArray(); + var document = new UsageAggregateStoreDocument(UsageAggregateStore.SchemaVersion, 90, points); + File.WriteAllText(path, JsonSerializer.Serialize(document)); + + var capped = new UsageAggregateStore(path, 90, () => Now); + Assert.Equal(UsageAggregateStore.MaximumEntries, capped.Snapshot.Count); + Assert.Equal(Now, capped.Snapshot[^1].RecordedAt); + Assert.Equal(Now.AddMinutes(-2 * (UsageAggregateStore.MaximumEntries - 1)), capped.Snapshot[0].RecordedAt); + } + + [Fact] + public void InvalidEntriesAndNullItemsAreIgnoredDuringReload() + { + var path = _environment.PathFor("usage-aggregates.json"); + var invalidDocument = new + { + SchemaVersion = UsageAggregateStore.SchemaVersion, + RetentionDays = 30, + Observations = new object?[] + { + null, + new + { + RecordedAt = Now, + PrimaryRemainingPercent = 101d, + WeeklyRemainingPercent = (double?)null, + PrimaryResetsAt = Now.AddHours(1), + WeeklyResetsAt = (DateTimeOffset?)null, + Source = UsageDataSource.AppServer, + }, + new + { + RecordedAt = Now.AddMinutes(1), + PrimaryRemainingPercent = 20d, + WeeklyRemainingPercent = (double?)null, + PrimaryResetsAt = Now.AddHours(1), + WeeklyResetsAt = (DateTimeOffset?)null, + Source = UsageDataSource.LocalSession, + }, + }, + }; + File.WriteAllText(path, JsonSerializer.Serialize(invalidDocument)); + + var store = new UsageAggregateStore(path, 30, () => Now); + + Assert.Empty(store.Snapshot); + } + + [Fact] + public void ExportsAreDeterministicInvariantAndPrivacyBounded() + { + var path = _environment.PathFor("usage-aggregates.json"); + var store = new UsageAggregateStore(path, 30, () => Now); + Assert.True(store.Record( + Point(Now.AddMinutes(-1), 87.5, Now.AddHours(4), weeklyRemaining: 12.25, weeklyReset: Now.AddDays(6)), + Now)); + + var json = store.ExportJson(Now); + var repeatedJson = store.ExportJson(Now); + var csv = store.ExportCsv(Now); + + Assert.Equal(json, repeatedJson); + using var parsed = JsonDocument.Parse(json); + Assert.Equal(UsageAggregateStore.SchemaVersion, parsed.RootElement.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("UTC ISO 8601", parsed.RootElement.GetProperty("units").GetProperty("timestamps").GetString()); + Assert.Contains("percent (0-100)", json, StringComparison.Ordinal); + Assert.Contains("87.5", json, StringComparison.Ordinal); + Assert.Contains("12.25", json, StringComparison.Ordinal); + Assert.Contains("2026-09-09T11:59:00.0000000Z", json, StringComparison.Ordinal); + Assert.Contains("schemaVersion=1", csv, StringComparison.Ordinal); + Assert.Contains("recordedAtUtc,primaryRemainingPercent,weeklyRemainingPercent", csv, StringComparison.Ordinal); + Assert.Contains("87.5", csv, StringComparison.Ordinal); + Assert.DoesNotContain("87,5", csv, StringComparison.Ordinal); + Assert.DoesNotContain("\"tokens\"", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("\"cost\"", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("\"account\"", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("\"session\"", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ContextStoresAreIsolatedAndDoNotPersistContextText() + { + var path = _environment.PathFor("usage-aggregates.json"); + var baseStore = new UsageAggregateStore(path, 30, () => Now); + var accountStore = baseStore.ForContext("account-id|default-category"); + + Assert.True(accountStore.Record(Point(Now.AddMinutes(-1)), Now)); + Assert.Single(accountStore.Snapshot); + Assert.Empty(baseStore.Snapshot); + + foreach (var file in Directory.EnumerateFiles( + Path.GetDirectoryName(path)!, + "*", + SearchOption.AllDirectories)) + { + Assert.DoesNotContain("account-id", file, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("default-category", file, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("account-id", File.ReadAllText(file), StringComparison.Ordinal); + Assert.DoesNotContain("default-category", File.ReadAllText(file), StringComparison.Ordinal); + } + } + + [Fact] + public void ClearOnlyRemovesTheCurrentStore() + { + var path = _environment.PathFor("usage-aggregates.json"); + var baseStore = new UsageAggregateStore(path, 30, () => Now); + var contextStore = baseStore.ForContext("known-account|default"); + + Assert.True(baseStore.Record(Point(Now.AddMinutes(-2)), Now)); + Assert.True(contextStore.Record(Point(Now.AddMinutes(-1)), Now)); + Assert.True(contextStore.Clear(out var error), error); + + Assert.Single(baseStore.Snapshot); + Assert.Empty(contextStore.Snapshot); + Assert.Single(new UsageAggregateStore(path, 30, () => Now).Snapshot); + } + + private static UsageAggregatePoint Point( + DateTimeOffset recordedAt, + double primaryRemaining = 80, + DateTimeOffset? primaryReset = null, + double? weeklyRemaining = null, + DateTimeOffset? weeklyReset = null) => new( + recordedAt, + primaryRemaining, + weeklyRemaining, + primaryReset ?? recordedAt.AddHours(1), + weeklyReset, + UsageDataSource.AppServer); +} diff --git a/CodexUsageDock.Tests/UsagePlanningTests.cs b/CodexUsageDock.Tests/UsagePlanningTests.cs new file mode 100644 index 0000000..d20d5ba --- /dev/null +++ b/CodexUsageDock.Tests/UsagePlanningTests.cs @@ -0,0 +1,326 @@ +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class UsagePlanningTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(1); + + [Fact] + public void FreshPlanReportsRemainingQuotaAndRatesUntilTheEarlierBoundary() + { + var desiredEnd = Now.AddHours(5); + var plan = UsagePlanner.Plan( + Presentation( + primaryRemaining: 50, + secondaryRemaining: 80, + primaryReset: Now.AddHours(2), + secondaryReset: Now.AddDays(4)), + Now, + RefreshInterval, + desiredEnd, + remainingWorkdays: 3); + + Assert.True(plan.IsAvailable); + Assert.Equal(50, plan.Primary!.RemainingPercent); + Assert.Equal(TimeSpan.FromHours(2), plan.Primary.Horizon); + Assert.Equal(Now.AddHours(2), plan.Primary.HorizonEnd); + Assert.True(plan.Primary.ResetsBeforeDesiredEnd); + Assert.Equal(1, plan.Primary.Workdays); + Assert.Equal(25, plan.Primary.AvailablePointsPerHour, precision: 8); + Assert.Equal(50, plan.Primary.AvailablePointsPerWorkday, precision: 8); + Assert.Contains("resets before the desired end", plan.Primary.HorizonMessage, StringComparison.Ordinal); + + Assert.Equal(80, plan.Weekly!.RemainingPercent); + Assert.Equal(TimeSpan.FromHours(5), plan.Weekly.Horizon); + Assert.False(plan.Weekly.ResetsBeforeDesiredEnd); + Assert.Equal(3, plan.Weekly.Workdays); + Assert.Equal(80 / 3d / 5d, plan.Weekly.AvailablePointsPerHour, precision: 8); + Assert.Contains("not a guarantee", plan.Disclaimer, StringComparison.Ordinal); + Assert.Contains("not a guarantee", plan.Status, StringComparison.Ordinal); + } + + [Fact] + public void WeeklyWorkdayAssumptionAffectsDailyAndHourlyBudget() + { + var desiredEnd = Now.AddHours(5); + var fiveDayPlan = UsagePlanner.Plan( + Presentation(primaryRemaining: null, secondaryRemaining: 50, secondaryReset: Now.AddDays(7)), + Now, + RefreshInterval, + desiredEnd, + remainingWorkdays: 5); + var oneDayPlan = UsagePlanner.Plan( + Presentation(primaryRemaining: null, secondaryRemaining: 50, secondaryReset: Now.AddDays(7)), + Now, + RefreshInterval, + desiredEnd, + remainingWorkdays: 1); + + Assert.Equal(10, fiveDayPlan.Weekly!.AvailablePointsPerWorkday, precision: 8); + Assert.Equal(2, fiveDayPlan.Weekly.AvailablePointsPerHour, precision: 8); + Assert.Equal(50, oneDayPlan.Weekly!.AvailablePointsPerWorkday, precision: 8); + Assert.Equal(10, oneDayPlan.Weekly.AvailablePointsPerHour, precision: 8); + } + + [Fact] + public void InvalidEndOrWorkdayCountReturnsAnExplanation() + { + var presentation = Presentation(); + + var now = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now); + var zeroDays = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now.AddHours(1), 0); + var eightDays = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now.AddHours(1), 8); + + Assert.False(now.IsAvailable); + Assert.Contains("desired end must be after now", now.Status, StringComparison.Ordinal); + Assert.False(zeroDays.IsAvailable); + Assert.Contains("between 1 and 7", zeroDays.Status, StringComparison.Ordinal); + Assert.False(eightDays.IsAvailable); + Assert.Contains("between 1 and 7", eightDays.Status, StringComparison.Ordinal); + } + + [Fact] + public void StaleFutureUnattributedAndBlockedDataPausePlanning() + { + var desiredEnd = Now.AddHours(2); + var cases = new[] + { + (Presentation(updatedAt: Now.AddMinutes(-6)), "usage data is stale"), + (Presentation(updatedAt: Now.AddMinutes(1)), "timestamp is in the future"), + (Presentation(accountKey: null), "account identity is unavailable"), + (Presentation(ordinaryUsageAllowed: false), "ordinary usage is currently blocked"), + }; + + foreach (var (presentation, expected) in cases) + { + var plan = UsagePlanner.Plan(presentation, Now, RefreshInterval, desiredEnd); + Assert.False(plan.IsAvailable); + Assert.Contains(expected, plan.Status, StringComparison.Ordinal); + Assert.Empty(plan.Windows); + } + } + + [Fact] + public void LastConfirmedAndLoadingDataAreNotTreatedAsFreshPlans() + { + var desiredEnd = Now.AddHours(2); + var lastConfirmed = UsagePlanner.Plan( + Presentation(source: UsageDataSource.LastConfirmed), + Now, + RefreshInterval, + desiredEnd); + var loading = UsagePlanner.Plan( + Presentation(isLoading: true), + Now, + RefreshInterval, + desiredEnd); + + Assert.False(lastConfirmed.IsAvailable); + Assert.Contains("last confirmed", lastConfirmed.Status, StringComparison.Ordinal); + Assert.False(loading.IsAvailable); + Assert.Contains("loading", loading.Status, StringComparison.Ordinal); + } + + [Fact] + public void ExpiredPrimaryCanLeaveAValidWeeklyPlanAndExpiredBothPause() + { + var desiredEnd = Now.AddDays(1); + var primaryExpired = UsagePlanner.Plan( + Presentation(primaryReset: Now.AddMinutes(-1), secondaryReset: Now.AddDays(4)), + Now, + RefreshInterval, + desiredEnd); + var bothExpired = UsagePlanner.Plan( + Presentation(primaryReset: Now.AddMinutes(-1), secondaryReset: Now.AddMinutes(-1)), + Now, + RefreshInterval, + desiredEnd); + + Assert.True(primaryExpired.IsAvailable); + Assert.Null(primaryExpired.Primary); + Assert.NotNull(primaryExpired.Weekly); + Assert.False(bothExpired.IsAvailable); + Assert.Contains("no valid", bothExpired.Status, StringComparison.Ordinal); + } + + [Fact] + public void ForecastUsesOnlyAUsableCurrentPaceAndReportsUnavailableEvidenceWhenInsufficient() + { + var reset = Now.AddHours(4); + UsageHistoryEntry[] history = + [ + new(Now.AddMinutes(-10), 100), + new(Now, 50), + ]; + var plan = UsagePlanner.Plan( + Presentation( + primaryRemaining: 50, + secondaryRemaining: null, + primaryReset: reset, + primaryHistory: history), + Now, + RefreshInterval, + Now.AddHours(1)); + + Assert.True(plan.IsAvailable); + Assert.True(plan.Primary!.Forecast.IsAvailable); + Assert.True(plan.Primary.Forecast.ReachesLimitBeforeReset); + Assert.Contains("current pace", plan.Primary.Forecast.Status, StringComparison.Ordinal); + Assert.Equal(2, plan.Primary.Evidence.MeasurementCount); + Assert.Equal(2, plan.Primary.Evidence.SegmentMeasurementCount); + Assert.False(plan.Primary.Backtest.IsAvailable); + + var insufficient = UsagePlanner.Plan( + Presentation( + primaryRemaining: 50, + secondaryRemaining: null, + primaryReset: reset, + primaryHistory: [new(Now, 50)]), + Now, + RefreshInterval, + Now.AddHours(1)); + Assert.False(insufficient.Primary!.Forecast.IsAvailable); + Assert.Contains("another measurement", insufficient.Primary.Forecast.Status, StringComparison.Ordinal); + } + + [Fact] + public void GapsBreakTheContinuousSegmentAndPauseForecast() + { + UsageHistoryEntry[] history = + [ + new(Now.AddMinutes(-40), 100), + new(Now.AddMinutes(-20), 90), + new(Now, 80), + ]; + var plan = UsagePlanner.Plan( + Presentation( + primaryRemaining: 80, + secondaryRemaining: null, + primaryHistory: history), + Now, + RefreshInterval, + Now.AddHours(1)); + + Assert.Equal(3, plan.Primary!.Evidence.MeasurementCount); + Assert.Equal(1, plan.Primary.Evidence.SegmentMeasurementCount); + Assert.False(plan.Primary.Forecast.IsAvailable); + Assert.Contains("another measurement", plan.Primary.Forecast.Status, StringComparison.Ordinal); + Assert.False(plan.Primary.Backtest.IsAvailable); + } + + [Fact] + public void EvidenceDescribesMeasurementsSegmentsAdaptiveCyclesAndItsLimit() + { + UsageHistoryEntry[] history = + [ + new(Now.AddMinutes(-20), 100), + new(Now.AddMinutes(-10), 90), + new(Now, 80), + ]; + var adaptive = new AdaptiveWeeklyUsageHistory( + [new AdaptiveWeeklyUsageCycle(Now.AddDays(-7), 10080, 60, 10, [])], + new AdaptiveWeeklyUsageCycle(Now.AddDays(7), 10080, 60, 10, [])); + var plan = UsagePlanner.Plan( + Presentation( + primaryRemaining: null, + secondaryRemaining: 80, + weeklyHistory: history, + adaptiveHistory: adaptive), + Now, + RefreshInterval, + Now.AddHours(1)); + + var evidence = plan.Weekly!.Evidence; + Assert.Equal(3, evidence.MeasurementCount); + Assert.Equal(TimeSpan.FromMinutes(20), evidence.MeasurementSpan); + Assert.Equal(3, evidence.SegmentMeasurementCount); + Assert.Equal(TimeSpan.FromMinutes(20), evidence.SegmentSpan); + Assert.Equal(2, evidence.AdaptiveCycleCount); + Assert.Contains("3 measurements", evidence.Summary, StringComparison.Ordinal); + Assert.Contains("continuous segment", evidence.Summary, StringComparison.Ordinal); + Assert.Contains("adaptive weekly cycles: 2", evidence.Summary, StringComparison.Ordinal); + Assert.Contains("not a calibrated reliability probability", evidence.Summary, StringComparison.Ordinal); + } + + [Fact] + public void BacktestHoldsOutTheLatestMeasurementAndDoesNotTrainOnIt() + { + UsageHistoryEntry[] firstHistory = + [ + new(Now.AddMinutes(-20), 100), + new(Now.AddMinutes(-10), 90), + new(Now, 70), + ]; + UsageHistoryEntry[] changedHoldout = + [ + new(Now.AddMinutes(-20), 100), + new(Now.AddMinutes(-10), 90), + new(Now, 60), + ]; + var first = UsagePlanner.Plan( + Presentation(primaryRemaining: 70, secondaryRemaining: null, primaryHistory: firstHistory), + Now, + RefreshInterval, + Now.AddHours(1)).Primary!.Backtest; + var second = UsagePlanner.Plan( + Presentation(primaryRemaining: 60, secondaryRemaining: null, primaryHistory: changedHoldout), + Now, + RefreshInterval, + Now.AddHours(1)).Primary!.Backtest; + + Assert.True(first.IsAvailable); + Assert.Equal(2, first.TrainingSampleCount); + Assert.Equal(Now, first.HeldOutAt); + Assert.Equal(80, first.PredictedRemainingPercent!.Value, precision: 8); + Assert.Equal(70, first.ActualRemainingPercent!.Value, precision: 8); + Assert.Equal(10, first.AbsoluteErrorPercentagePoints!.Value, precision: 8); + Assert.Equal(first.PredictedRemainingPercent, second.PredictedRemainingPercent); + Assert.Equal(20, second.AbsoluteErrorPercentagePoints!.Value, precision: 8); + Assert.Contains("excluded from the preceding-pace calculation", first.Status, StringComparison.Ordinal); + } + + private static UsagePresentation Presentation( + double? primaryRemaining = 80, + double? secondaryRemaining = 80, + int primaryMinutes = 300, + int secondaryMinutes = 10080, + DateTimeOffset? primaryReset = null, + DateTimeOffset? secondaryReset = null, + DateTimeOffset? updatedAt = null, + UsageDataSource source = UsageDataSource.AppServer, + string? accountKey = "account-a", + bool isLoading = false, + bool? ordinaryUsageAllowed = null, + IReadOnlyList? primaryHistory = null, + IReadOnlyList? weeklyHistory = null, + AdaptiveWeeklyUsageHistory? adaptiveHistory = null) + { + var primary = primaryRemaining is { } primaryValue + ? new RateLimitWindow(100 - primaryValue, primaryMinutes, primaryReset ?? Now.AddHours(4)) + : null; + var secondary = secondaryRemaining is { } secondaryValue + ? new RateLimitWindow(100 - secondaryValue, secondaryMinutes, secondaryReset ?? Now.AddDays(6)) + : null; + var snapshot = new CodexUsageSnapshot( + primary, + secondary, + "pro", + null, + null, + updatedAt ?? Now, + source, + null, + AccountKey: accountKey, + OrdinaryUsageAllowed: ordinaryUsageAllowed, + DefaultBucketId: "codex"); + return new UsagePresentation( + snapshot, + primaryHistory ?? Array.Empty(), + weeklyHistory ?? Array.Empty(), + adaptiveHistory ?? new AdaptiveWeeklyUsageHistory([], null), + LocalTokenUsageSnapshot.Unavailable, + isLoading); + } +} diff --git a/CodexUsageDock/CodexAppServerReader.cs b/CodexUsageDock/CodexAppServerReader.cs index fd2c5d8..1359b06 100644 --- a/CodexUsageDock/CodexAppServerReader.cs +++ b/CodexUsageDock/CodexAppServerReader.cs @@ -92,6 +92,140 @@ internal static async Task ReadAccountUsageSequenceAsync( : AccountUsageSnapshot.Unavailable with { UpdatedAt = DateTimeOffset.Now }; } + internal static async Task ReadThreadUsageAsync( + CodexSourceOptions options, string threadId, string expectedAccountKey, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!ThreadUsageParser.IsValidThreadId(threadId)) return EmptyThreadUsage(ThreadUsageStatus.Unavailable); + if (!IsValidAccountKey(expectedAccountKey)) return EmptyThreadUsage(ThreadUsageStatus.AccountMismatch); + try + { + return await WithAppServerAsync(options, (process, token) => + ReadThreadUsageSequenceAsync((method, id, parameters, requestToken) => + RequestAsync(process, method, id, parameters, requestToken), threadId, expectedAccountKey, token), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception error) when (error is IOException or InvalidOperationException or JsonException + or OperationCanceledException or System.ComponentModel.Win32Exception or UnauthorizedAccessException) + { + return EmptyThreadUsage(ThreadUsageStatus.Unavailable); + } + } + + internal static async Task ReadThreadUsageSequenceAsync( + Func?, CancellationToken, Task> request, + string threadId, string expectedAccountKey, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!ThreadUsageParser.IsValidThreadId(threadId)) return EmptyThreadUsage(ThreadUsageStatus.Unavailable); + if (!IsValidAccountKey(expectedAccountKey)) return EmptyThreadUsage(ThreadUsageStatus.AccountMismatch); + + using var before = await request("account/rateLimits/read", 2, null, cancellationToken).ConfigureAwait(false); + if (IsMethodNotFound(before.RootElement)) return EmptyThreadUsage(ThreadUsageStatus.Unsupported); + if (HasError(before.RootElement)) return EmptyThreadUsage(ThreadUsageStatus.Unavailable); + var beforeKey = GetResponseAccountKey(before.RootElement); + if (!string.Equals(expectedAccountKey, beforeKey, StringComparison.OrdinalIgnoreCase)) + return EmptyThreadUsage(ThreadUsageStatus.AccountMismatch); + + using var usage = await request("account/usage/read", 3, + writer => WriteStringParameter(writer, "threadId", threadId), cancellationToken).ConfigureAwait(false); + if (IsMethodNotFound(usage.RootElement)) return EmptyThreadUsage(ThreadUsageStatus.Unsupported); + if (HasError(usage.RootElement)) return EmptyThreadUsage(ThreadUsageStatus.Unavailable); + + using var after = await request("account/rateLimits/read", 4, null, cancellationToken).ConfigureAwait(false); + if (HasError(after.RootElement)) return EmptyThreadUsage(ThreadUsageStatus.Unavailable); + if (!string.Equals(beforeKey, GetResponseAccountKey(after.RootElement), StringComparison.Ordinal)) + return EmptyThreadUsage(ThreadUsageStatus.AccountMismatch); + + return TryGetObject(usage.RootElement, "result", out var result) + ? ThreadUsageParser.Parse(result, threadId, beforeKey!, DateTimeOffset.Now) + : EmptyThreadUsage(ThreadUsageStatus.Unavailable); + } + + internal static async Task ConsumeResetCreditAsync( + CodexSourceOptions options, string expectedAccountKey, string idempotencyKey, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ValidateIdempotencyKey(idempotencyKey); + if (!IsValidAccountKey(expectedAccountKey)) return new(ResetCreditOutcome.AccountMismatch, DateTimeOffset.Now); + + var consumeStarted = false; + ResetCreditResult? completed = null; + try + { + return await WithAppServerAsync(options, async (process, token) => + { + var result = await ConsumeResetCreditSequenceAsync((method, id, parameters, requestToken) => + { + if (method == "account/rateLimitResetCredit/consume") consumeStarted = true; + return RequestAsync(process, method, id, parameters, requestToken); + }, expectedAccountKey, idempotencyKey, token).ConfigureAwait(false); + completed = result; + return result; + }, cancellationToken).ConfigureAwait(false); + } + catch (Exception) + { + // Cleanup can fail after the server has replied. Keep a known result; + // otherwise never describe a possibly dispatched reset as safe to replace. + return completed ?? new(consumeStarted ? ResetCreditOutcome.Ambiguous : ResetCreditOutcome.Unavailable, DateTimeOffset.Now); + } + } + + internal static async Task ConsumeResetCreditSequenceAsync( + Func?, CancellationToken, Task> request, + string expectedAccountKey, string idempotencyKey, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ValidateIdempotencyKey(idempotencyKey); + var attemptedAt = DateTimeOffset.Now; + if (!IsValidAccountKey(expectedAccountKey)) return new(ResetCreditOutcome.AccountMismatch, attemptedAt); + + var consumeStarted = false; + try + { + using var before = await request("account/rateLimits/read", 2, null, cancellationToken).ConfigureAwait(false); + if (IsMethodNotFound(before.RootElement)) return new(ResetCreditOutcome.Unsupported, attemptedAt); + if (HasError(before.RootElement)) return new(ResetCreditOutcome.Unavailable, attemptedAt); + if (!string.Equals(expectedAccountKey, GetResponseAccountKey(before.RootElement), StringComparison.OrdinalIgnoreCase)) + return new(ResetCreditOutcome.AccountMismatch, attemptedAt); + + cancellationToken.ThrowIfCancellationRequested(); + attemptedAt = DateTimeOffset.Now; + consumeStarted = true; + using var response = await request("account/rateLimitResetCredit/consume", 3, + writer => WriteStringParameter(writer, "idempotencyKey", idempotencyKey), cancellationToken).ConfigureAwait(false); + return ResetCreditParser.Parse(response.RootElement, attemptedAt); + } + catch (Exception) + { + return new(consumeStarted ? ResetCreditOutcome.Ambiguous : ResetCreditOutcome.Unavailable, attemptedAt); + } + } + + private static ThreadUsageSnapshot EmptyThreadUsage(ThreadUsageStatus status) => + ThreadUsageSnapshot.Unavailable with { Status = status, UpdatedAt = DateTimeOffset.Now }; + + private static bool IsValidAccountKey(string? value) => value is { Length: 64 } && value.All(char.IsAsciiHexDigit); + + private static void ValidateIdempotencyKey(string value) + { + if (!ResetCreditParser.IsValidIdempotencyKey(value)) + throw new ArgumentException("A bounded, nonempty idempotency key is required.", nameof(value)); + } + + private static void WriteStringParameter(Utf8JsonWriter writer, string name, string value) + { + writer.WritePropertyName("params"); + writer.WriteStartObject(); + writer.WriteString(name, value); + writer.WriteEndObject(); + } + private static string? GetResponseAccountKey(JsonElement response) => !HasError(response) && TryGetObject(response, "result", out var result) ? ParseAccountKey(result) : null; @@ -102,14 +236,18 @@ internal static bool IsMethodNotFound(JsonElement response) => && code.TryGetInt32(out var number) && number == -32601; - private static async Task RequestAsync(Process process, string method, int id, CancellationToken cancellationToken) - { - await SendAsync(process, method, id, method == "account/usage/read" ? static writer => + private static Task RequestAsync(Process process, string method, int id, CancellationToken cancellationToken) => + RequestAsync(process, method, id, method == "account/usage/read" ? static writer => { writer.WritePropertyName("params"); writer.WriteStartObject(); writer.WriteEndObject(); - } : null, cancellationToken).ConfigureAwait(false); + } : null, cancellationToken); + + private static async Task RequestAsync(Process process, string method, int id, + Action? parameters, CancellationToken cancellationToken) + { + await SendAsync(process, method, id, parameters, cancellationToken).ConfigureAwait(false); var responses = await ReadResponsesAsync(process.StandardOutput, cancellationToken, id).ConfigureAwait(false); return responses[id]; } diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index 34d3f37..5fe42e5 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -14,6 +14,9 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private readonly CodexUsageDockPage _details; private readonly CodexUsageDiagnosticsPage _diagnostics; private readonly CodexAccountActivityPage _accountActivity; + private readonly CodexPlanningPage _planner; + private readonly CodexHistoryPage _history; + private readonly CodexActionsPage _actions; private readonly UsageAlertEvaluator _alerts = new(); private readonly Action _notify; private readonly Func _clock; @@ -45,10 +48,14 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); ApplySourceSettings(); _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); + _usage.SetAggregateRetentionDays(_settings.HistoryRetentionDays); var details = _details = new CodexUsageDockPage(_usage, _settings); _diagnostics = new CodexUsageDiagnosticsPage(_usage); _diagnostics.Id = "nl.mathijs.codexusage.diagnostics"; _accountActivity = new CodexAccountActivityPage(_usage); + _planner = new CodexPlanningPage(_usage, _settings, _clock); + _history = new CodexHistoryPage(_usage); + _actions = new CodexActionsPage(_usage); _fiveHour = new UsageDockItem(_usage, UsageDockItemKind.FiveHour, details, _settings); _weekly = new UsageDockItem(_usage, UsageDockItemKind.Weekly, details, _settings); _resetsAndCredits = new UsageDockItem(_usage, UsageDockItemKind.ResetsAndCredits, details); @@ -76,6 +83,9 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS Title = "Codex account activity", Subtitle = "Account-wide daily tokens reported by Codex", }, + new CommandItem(_planner) { Title = "Codex workday planner", Subtitle = "Daily quota budget, recent pace, and forecast evidence" }, + new CommandItem(_history) { Title = "Codex usage history", Subtitle = "Retained quota observations, CSV/JSON export, and deletion" }, + new CommandItem(_actions) { Title = "Codex task usage and earned resets", Subtitle = "Request a task estimate or explicitly use an earned reset" }, ]; _settings.Changed += OnSettingsChanged; @@ -116,9 +126,12 @@ private void OnSettingsChanged(object? sender, EventArgs e) _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); var sourceChanged = ApplySourceSettings(); _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); + _usage.SetAggregateRetentionDays(_settings.HistoryRetentionDays); _fiveHour.Refresh(); _weekly.Refresh(); _details.Refresh(); + _planner.Refresh(); + _history.Refresh(); RebuildDockBands(); RaiseItemsChanged(); if (sourceChanged) _ = _usage.RefreshAsync(); @@ -212,6 +225,9 @@ public override void Dispose() _details.Dispose(); _diagnostics.Dispose(); _accountActivity.Dispose(); + _planner.Dispose(); + _history.Dispose(); + _actions.Dispose(); _usage.Dispose(); base.Dispose(); GC.SuppressFinalize(this); diff --git a/CodexUsageDock/CodexUsageService.Actions.cs b/CodexUsageDock/CodexUsageService.Actions.cs new file mode 100644 index 0000000..ae6a220 --- /dev/null +++ b/CodexUsageDock/CodexUsageService.Actions.cs @@ -0,0 +1,191 @@ +namespace CodexUsageDock; + +internal sealed partial class CodexUsageService +{ + private ResetAttemptJournal? _resetJournal; + private Func>? _resetCreditConsumer; + private Func>? _threadUsageReader; + private Task? _resetActionTask; + private Task? _threadActionTask; + private PendingTaskRead? _pendingTaskRead; + private long _taskRequestVersion; + private sealed record PendingTaskRead(CodexSourceOptions Options, string ThreadId, string Account, + long SourceGeneration, long RequestVersion); + internal string ResetActionStatus { get; private set; } = "Only an explicit, confirmed action can use an existing earned reset."; + internal ThreadUsageSnapshot? CurrentThreadUsage { get; private set; } + internal string ThreadActionStatus { get; private set; } = "Enter a Codex task ID to request its reported usage estimate."; + + internal (CodexUsageSnapshot Usage, ThreadUsageSnapshot? Task, string TaskStatus, string ResetStatus) GetActionPresentation() + { + lock (_refreshStateLock) { return (Current, CurrentThreadUsage, ThreadActionStatus, ResetActionStatus); } + } + + internal void InitializeOptionalFeatures(string aggregatePath, string resetJournalPath, + Func>? resetConsumer = null, + Func>? threadReader = null) + { + if (_started) throw new InvalidOperationException("Optional storage must be configured before monitoring starts."); + _aggregatePath = Path.GetFullPath(aggregatePath); + _resetJournal = new ResetAttemptJournal(Path.GetFullPath(resetJournalPath)); + _resetCreditConsumer = resetConsumer ?? (_usesConfiguredSources ? CodexAppServerReader.ConsumeResetCreditAsync : null); + _threadUsageReader = threadReader ?? (_usesConfiguredSources ? CodexAppServerReader.ReadThreadUsageAsync : null); + } + + internal Task ConsumeEarnedResetAsync(string expectedAccountKey) + { + Task task; + lock (_refreshStateLock) + { + if (_resetActionTask is { IsCompleted: false }) return _resetActionTask; + if (_disposed || _resetJournal is null || _resetCreditConsumer is null + || !IsFreshAccount(expectedAccountKey)) + { + ResetActionStatus = "Refresh a live, identified Codex account before using a reset."; + task = Task.FromResult(ResetActionStatus); + } + else + { + var options = _sourceOptions; + var generation = _sourceGeneration; + var available = Current.ResetCredits?.AvailableCount; + ResetActionStatus = "Checking the existing reset attempt…"; + task = _resetActionTask = Task.Run(() => RunResetAsync(options, expectedAccountKey, available, generation)); + } + } + RaiseUpdated(); + return task; + } + + private async Task RunResetAsync(CodexSourceOptions options, string account, int? available, long generation) + { + string message; + if (!_resetJournal!.TryRead(account, out var key)) + message = "The pending reset record could not be read safely. No reset request was sent."; + else if (key is null && available is not > 0) + message = "No available earned reset is currently reported. No reset request was sent."; + else + { + key ??= Guid.NewGuid().ToString("N"); + if (!_resetJournal.Save(account, key)) + message = "The reset request ID could not be saved. No reset request was sent."; + else + { + ResetCreditResult result; + try + { + result = await _resetCreditConsumer!(options, account, key, _lifetimeCancellation.Token).ConfigureAwait(false); + } + catch (Exception error) + { + LocalStorage.TraceFailure("request earned reset", error); + result = new(ResetCreditOutcome.Ambiguous, _clock()); + } + message = DescribeResetOutcome(result.Outcome); + // Any unresolved attempt, including a retry whose preflight failed, keeps its original key. + if (result.Outcome is ResetCreditOutcome.Reset or ResetCreditOutcome.AlreadyRedeemed + or ResetCreditOutcome.NothingToReset or ResetCreditOutcome.NoCredit) + { + if (!_resetJournal.Save(account, null)) + message += " The recovery record could not be cleared; a retry will reuse the same request ID."; + } + await RefreshAsync().ConfigureAwait(false); + } + } + lock (_refreshStateLock) + { + if (!_disposed && generation == _sourceGeneration && Current.AccountKey == account) + ResetActionStatus = message; + } + RaiseUpdated(); + return message; + } + + internal static string DescribeResetOutcome(ResetCreditOutcome outcome) => outcome switch + { + ResetCreditOutcome.Reset => "An earned reset was applied. Usage limits have been requested again.", + ResetCreditOutcome.AlreadyRedeemed => "This request had already redeemed its reset. No second reset was used.", + ResetCreditOutcome.NothingToReset => "The service reports nothing to reset. No reset was applied.", + ResetCreditOutcome.NoCredit => "The service reports no eligible earned reset. No reset was applied.", + ResetCreditOutcome.Unsupported => "This Codex CLI does not support earned resets. The request ID is retained for a safe retry.", + ResetCreditOutcome.AccountMismatch => "The Codex account changed before the request. No reset was sent to the changed account.", + ResetCreditOutcome.Unavailable => "The service could not be checked before sending the reset. The request ID is retained.", + _ => "The reset outcome is unknown. Confirm a retry to reuse the same request ID, including after restarting the extension.", + }; + + private bool IsFreshAccount(string expectedAccountKey) => !string.IsNullOrWhiteSpace(expectedAccountKey) + && Current.AccountKey == expectedAccountKey && Current.Source == UsageDataSource.AppServer + && !_isLoading && UsageFreshness.IsFresh(Current.UpdatedAt, _clock(), RefreshInterval); + + internal Task ReadTaskUsageAsync(string threadId) + { + Task task; + lock (_refreshStateLock) + { + if (_disposed || _threadUsageReader is null || Current.AccountKey is not { } account || !IsFreshAccount(account)) + { + ThreadActionStatus = "Refresh a live, identified account before requesting task usage."; + CurrentThreadUsage = null; + task = Task.CompletedTask; + } + else if (!ThreadUsageParser.IsValidThreadId(threadId?.Trim())) + { + ThreadActionStatus = "Enter a task ID of at most 128 letters, digits, hyphens, or underscores."; + CurrentThreadUsage = null; + task = Task.CompletedTask; + } + else + { + _pendingTaskRead = new(_sourceOptions, threadId!.Trim(), account, _sourceGeneration, ++_taskRequestVersion); + ThreadActionStatus = "Reading the latest requested task estimate…"; + CurrentThreadUsage = null; + task = _threadActionTask is { IsCompleted: false } active ? active + : _threadActionTask = Task.Run(RunTaskReadsAsync); + } + } + RaiseUpdated(); + return task; + } + + private async Task RunTaskReadsAsync() + { + while (true) + { + PendingTaskRead request; + lock (_refreshStateLock) + { + if (_disposed || _pendingTaskRead is null) { _threadActionTask = null; return; } + request = _pendingTaskRead; + _pendingTaskRead = null; + } + ThreadUsageSnapshot? result = null; + try { result = await _threadUsageReader!(request.Options, request.ThreadId, request.Account, _lifetimeCancellation.Token).ConfigureAwait(false); } + catch (Exception error) { LocalStorage.TraceFailure("read task usage", error); } + lock (_refreshStateLock) + { + if (_disposed || request.SourceGeneration != _sourceGeneration || Current.AccountKey != request.Account + || request.RequestVersion != _taskRequestVersion) continue; + var usable = result?.AccountKey == request.Account && result.ThreadId == request.ThreadId + && result.Status is ThreadUsageStatus.Available or ThreadUsageStatus.Partial; + CurrentThreadUsage = usable ? result : null; + ThreadActionStatus = result?.Status switch + { + ThreadUsageStatus.Available when usable => "Task usage estimate reported by Codex.", + ThreadUsageStatus.Partial when usable => "Partial task estimate; missing values are not zero.", + ThreadUsageStatus.Unsupported => "This Codex CLI does not support task usage estimates.", + ThreadUsageStatus.AccountMismatch => "The account changed during the read. The result was discarded.", + _ => "Task usage is unavailable. Check the task ID and try again.", + }; + } + RaiseUpdated(); + } + } + + private void ClearActionPresentation() + { + CurrentThreadUsage = null; + _pendingTaskRead = null; + _taskRequestVersion++; + ThreadActionStatus = "Enter a Codex task ID to request its reported usage estimate."; + ResetActionStatus = "Only an explicit, confirmed action can use an existing earned reset."; + } +} diff --git a/CodexUsageDock/CodexUsageService.Aggregates.cs b/CodexUsageDock/CodexUsageService.Aggregates.cs new file mode 100644 index 0000000..22af2f8 --- /dev/null +++ b/CodexUsageDock/CodexUsageService.Aggregates.cs @@ -0,0 +1,68 @@ +namespace CodexUsageDock; + +internal sealed partial class CodexUsageService +{ + private string? _aggregatePath; + private UsageAggregateStore? _aggregateStore; + private int _aggregateRetentionDays; + + internal void SetAggregateRetentionDays(int days) + { + if (days is not (0 or 7 or 30 or 90)) throw new ArgumentOutOfRangeException(nameof(days)); + lock (_historyLock) + { + if (_aggregateRetentionDays == days) return; + _aggregateRetentionDays = days; + OpenAggregateStore(); + if (days != 0) _aggregateStore?.SetRetentionDays(days, _clock()); + } + RaiseUpdated(); + } + + private void OpenAggregateStore() => _aggregateStore = _aggregatePath is not null && _historyContext is not null + ? new UsageAggregateStore(LocalStorage.ContextPath(_aggregatePath, _historyContext), + _aggregateRetentionDays == 0 ? 90 : _aggregateRetentionDays, _clock) + : null; + + private void RecordAggregate(CodexUsageSnapshot snapshot, DateTimeOffset now) + { + if (_aggregateRetentionDays == 0 || _aggregateStore is null) return; + if (UsageAggregateStore.TryCreateLivePoint(snapshot, now, RefreshInterval, out var point)) + _aggregateStore.Record(point!, now); + } + + internal (IReadOnlyList Points, int RetentionDays, string? Error, bool Identified, string? Context) GetAggregateHistory() + { + lock (_historyLock) + { + return (_aggregateStore?.Snapshot ?? [], _aggregateRetentionDays, _aggregateStore?.StorageError, _historyContext is not null, _historyContext); + } + } + + internal bool ClearAggregateHistory(string? expectedContext = null) + { + bool cleared; + lock (_historyLock) + { + if (expectedContext is not null && expectedContext != _historyContext) return false; + cleared = _aggregateStore?.Clear() ?? _historyContext is not null; + } + RaiseUpdated(); + return cleared; + } + + internal string ExportAggregateHistory(bool csv, string? expectedContext = null) + { + lock (_historyLock) + { + if (expectedContext is not null && expectedContext != _historyContext) + return "The account or quota category changed. Open its history and request the export again."; + if (_aggregateStore is null || _aggregatePath is null) + return "Identify the current Codex account before exporting its history."; + var content = csv ? _aggregateStore.ExportCsv(_clock()) : _aggregateStore.ExportJson(_clock()); + var file = $"codex-usage-{_clock():yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.{(csv ? "csv" : "json")}"; + var destination = Path.Combine(Path.GetDirectoryName(_aggregatePath)!, "exports", file); + return LocalStorage.TryWrite(destination, content) ? $"Export saved to {destination}" : "The export could not be saved."; + } + } +} diff --git a/CodexUsageDock/CodexUsageService.cs b/CodexUsageDock/CodexUsageService.cs index dd2aa89..56deedd 100644 --- a/CodexUsageDock/CodexUsageService.cs +++ b/CodexUsageDock/CodexUsageService.cs @@ -47,6 +47,7 @@ public CodexUsageService() localTokenUsageReader: new LocalCodexTokenUsageReader().ReadAsync) { _usesConfiguredSources = true; + InitializeOptionalFeatures(LocalStorage.GetPath("aggregates.json"), LocalStorage.GetPath("reset-attempt.json")); } internal CodexUsageService( @@ -169,6 +170,7 @@ internal bool ConfigureSource(CodexSourceOptions options, string? error = null) Current = CodexUsageSnapshot.Loading; CurrentTokenUsage = LocalTokenUsageSnapshot.Unavailable; CurrentAccountUsage = AccountUsageSnapshot.Unavailable; + ClearActionPresentation(); _accountReadAfter = DateTimeOffset.MinValue; if (_usesConfiguredSources) { @@ -180,6 +182,7 @@ internal bool ConfigureSource(CodexSourceOptions options, string? error = null) _historyContext = null; _primaryHistory.Clear(); _weeklyHistory.Clear(); + _aggregateStore = null; } } RaiseUpdated(); @@ -315,8 +318,11 @@ internal void RecordHistory(CodexUsageSnapshot snapshot, DateTimeOffset? recorde // A returning account may have been observed while learning was paused. // Resume at this measurement rather than replaying another context's gap. _adaptiveWeeklyForecastNeedsBaseline = _adaptiveWeeklyForecastEnabled; + OpenAggregateStore(); } + RecordAggregate(snapshot, now); + RecordWindowHistory(_primaryHistory, snapshot.Primary, snapshot.UpdatedAt, now, now - TimeSpan.FromHours(5)); var weeklyHistoryChanged = RecordWindowHistory(_weeklyHistory, snapshot.Secondary, snapshot.UpdatedAt, now, now - TimeSpan.FromDays(7)); if (weeklyHistoryChanged && context is not null) @@ -594,6 +600,7 @@ private bool TryPublish(CodexUsageSnapshot snapshot, long generation) CurrentTokenUsage = LocalTokenUsageSnapshot.Unavailable; if (Current.AccountKey != snapshot.AccountKey) { + ClearActionPresentation(); CurrentAccountUsage = AccountUsageSnapshot.Unavailable; _accountReadAfter = DateTimeOffset.MinValue; } @@ -664,7 +671,8 @@ public void Dispose() } _disposed = true; - refreshTask = Task.WhenAll(_refreshTask ?? Task.CompletedTask, _tokenRefreshTask, _accountRefreshTask); + refreshTask = Task.WhenAll(_refreshTask ?? Task.CompletedTask, _tokenRefreshTask, _accountRefreshTask, + (Task?)_resetActionTask ?? Task.CompletedTask, _threadActionTask ?? Task.CompletedTask); } _timer.Stop(); diff --git a/CodexUsageDock/Pages/CodexActionsPage.cs b/CodexUsageDock/Pages/CodexActionsPage.cs new file mode 100644 index 0000000..dc204bc --- /dev/null +++ b/CodexUsageDock/Pages/CodexActionsPage.cs @@ -0,0 +1,111 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CmdPal.Common.Commands; + +namespace CodexUsageDock; + +internal sealed partial class CodexActionsPage : ContentPage, IDisposable +{ + private readonly CodexUsageService _service; + private readonly TaskUsageForm _form; + private readonly object _gate = new(); + private MarkdownContent _content = new(string.Empty); + private bool _disposed; + + internal CodexActionsPage(CodexUsageService service) + { + _service = service; + _form = new TaskUsageForm(service); + Id = "nl.mathijs.codexusage.actions"; + Name = "Open"; + Title = "Codex task usage and earned resets"; + Icon = new IconInfo("\uE945"); + service.Updated += OnUpdated; + Refresh(); + } + + public override IContent[] GetContent() { lock (_gate) { return [_form, _content]; } } + + private void Refresh() + { + var presentation = _service.GetActionPresentation(); + var body = new StringBuilder("# Task usage\n\n").Append(presentation.TaskStatus).Append("\n\n"); + if (presentation.Task is { } task) body.Append(FormatTask(task)); + body.Append("\n# Earned resets\n\nReported available: ") + .Append(presentation.Usage.ResetCredits?.AvailableCount.ToString(CultureInfo.InvariantCulture) ?? "Unknown") + .Append(".\n\n").Append(presentation.ResetStatus) + .Append("\n\nUse the confirmed reset action only when you want to redeem one existing earned reset. ") + .Append("After an unknown outcome, retrying uses the same saved request ID. The backend decides eligibility.\n"); + var expectedAccount = presentation.Usage.AccountKey; + var commands = new List + { + new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, + }; + if (expectedAccount is not null) + { + commands.Add(new CommandContextItem(new ConfirmableCommand( + new AnonymousCommand(() => { _ = _service.ConsumeEarnedResetAsync(expectedAccount); }) { Result = CommandResult.KeepOpen() }, + "Use or retry an earned reset?", + "Redeem one existing earned reset for the currently identified Codex account. A retry of an uncertain request reuses its saved ID. No reset is purchased.", + () => true) { Name = "Use or retry an earned reset" }) { Title = "Use or retry an earned reset" }); + } + lock (_gate) + { + if (_disposed) return; + _content = new MarkdownContent(body.ToString()); + Commands = commands.ToArray(); + } + RaiseItemsChanged(0); + } + + internal static string FormatTask(ThreadUsageSnapshot task) + { + if (task.Status is not (ThreadUsageStatus.Available or ThreadUsageStatus.Partial)) return string.Empty; + var body = new StringBuilder("Task: ").Append(UsageText.EscapeMarkdown(task.ThreadId ?? "Unknown")) + .Append(".\n\n**Server estimate, not a bill or quota percentage.** Last read: ") + .Append(task.UpdatedAt.ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture)).Append(".\n\n") + .Append("Estimated credits: ").Append(Micro(task.EstimatedUsageCreditsMicros)) + .Append("; estimated USD: ").Append(Micro(task.EstimatedUsageUsdMicros)).Append(".\n\n") + .Append("| Model / effort / speed | Input | Cached input | Net new input | Output | Total | Estimated credits |\n") + .Append("| --- | ---: | ---: | ---: | ---: | ---: | ---: |\n"); + foreach (var group in task.Groups ?? []) + body.Append("| ").Append(UsageText.EscapeMarkdown(string.Join(" / ", group.Model ?? "Unknown model", group.ReasoningEffort ?? "Unknown effort", group.Speed ?? "Unknown speed"))) + .Append(" | ").Append(Tokens(group.InputTokens)).Append(" | ").Append(Tokens(group.CachedInputTokens)) + .Append(" | ").Append(Tokens(group.NetNewInputTokens)).Append(" | ").Append(Tokens(group.OutputTokens)) + .Append(" | ").Append(Tokens(group.TotalTokens)).Append(" | ").Append(Micro(group.EstimatedUsageCreditsMicros)).Append(" |\n"); + return body.Append("\nCached and net-new input are components of input; do not add them to input again. Missing values are not zero.\n").ToString(); + } + + private static string Micro(long? value) => value is { } amount ? (amount / 1_000_000m).ToString("0.######", CultureInfo.InvariantCulture) : "Not reported"; + private static string Tokens(long? value) => value?.ToString("N0", CultureInfo.InvariantCulture) ?? "Not reported"; + private void OnUpdated(object? sender, EventArgs args) => Refresh(); + public void Dispose() { lock (_gate) { _disposed = true; _service.Updated -= OnUpdated; } } +} + +internal sealed partial class TaskUsageForm : FormContent +{ + private readonly CodexUsageService _service; + internal TaskUsageForm(CodexUsageService service) + { + _service = service; + TemplateJson = """ + {"type":"AdaptiveCard","version":"1.5","body":[{"type":"Input.Text","id":"threadId","label":"Codex task ID","placeholder":"Paste the task ID","maxLength":128,"isRequired":true}],"actions":[{"type":"Action.Submit","title":"Read task estimate"}]} + """; + } + + public override CommandResult SubmitForm(string payload) + { + try + { + if (string.IsNullOrEmpty(payload) || payload.Length > 4096) return CommandResult.KeepOpen(); + using var doc = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 8 }); + if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("threadId", out var id) + && id.ValueKind == JsonValueKind.String) _ = _service.ReadTaskUsageAsync(id.GetString()!); + } + catch (JsonException) { } + return CommandResult.KeepOpen(); + } +} diff --git a/CodexUsageDock/Pages/CodexHistoryPage.cs b/CodexUsageDock/Pages/CodexHistoryPage.cs new file mode 100644 index 0000000..27029cf --- /dev/null +++ b/CodexUsageDock/Pages/CodexHistoryPage.cs @@ -0,0 +1,68 @@ +using System.Globalization; +using System.Text; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CmdPal.Common.Commands; + +namespace CodexUsageDock; + +internal sealed partial class CodexHistoryPage : ContentPage, IDisposable +{ + private readonly CodexUsageService _service; + private readonly object _gate = new(); + private MarkdownContent _content = new(string.Empty); + private string? _operation; + private bool _disposed; + + internal CodexHistoryPage(CodexUsageService service) + { + _service = service; + Id = "nl.mathijs.codexusage.history"; + Name = "Open"; + Title = "Codex usage history"; + Icon = new IconInfo("\uE81C"); + service.Updated += OnUpdated; + Refresh(); + } + + public override IContent[] GetContent() { lock (_gate) { return [_content]; } } + + private void Export(bool csv, string context) { _operation = _service.ExportAggregateHistory(csv, context); Refresh(); } + private void Clear(string context) { _operation = _service.ClearAggregateHistory(context) ? "Retained observations deleted." : "Retained observations could not be deleted, or the account/category changed."; Refresh(); } + + internal void Refresh() + { + var history = _service.GetAggregateHistory(); + var body = new StringBuilder("# Usage history\n\n"); + if (_operation is not null) body.Append(UsageText.EscapeMarkdown(_operation)).Append("\n\n"); + if (!history.Identified) body.Append("Waiting for an identified account. Histories are separated by account and quota category.\n\n"); + body.Append(history.RetentionDays == 0 ? "Collection paused. Choose 7, 30, or 90 days in settings to retain observations." + : $"Retention: {history.RetentionDays} days. Up to one observation per five minutes, with separate reset transitions.") + .Append("\n\n").Append(history.Points.Count.ToString(CultureInfo.InvariantCulture)).Append(" retained observations. ") + .Append("Exports contain UTC quota percentages and reset times, without account IDs, conversation content, tokens, or costs.\n\n"); + if (history.Error is not null) body.Append(history.Error).Append("\n\n"); + body.Append("The most recent 30 observations are shown. The exports include all retained rows for this context.\n\n") + .Append("| Observed UTC | Five-hour remaining | Weekly remaining |\n| --- | ---: | ---: |\n"); + foreach (var point in history.Points.TakeLast(30).Reverse()) + body.Append("| ").Append(point.RecordedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)).Append(" | ") + .Append(Percent(point.PrimaryRemainingPercent)).Append(" | ").Append(Percent(point.WeeklyRemainingPercent)).Append(" |\n"); + lock (_gate) + { + if (_disposed) return; + _content = new MarkdownContent(body.ToString()); + Commands = history.Context is not { } context ? [] : + [ + new CommandContextItem(new AnonymousCommand(() => Export(true, context)) { Result = CommandResult.KeepOpen() }) { Title = "Export CSV file" }, + new CommandContextItem(new AnonymousCommand(() => Export(false, context)) { Result = CommandResult.KeepOpen() }) { Title = "Export JSON file" }, + new CommandContextItem(new ConfirmableCommand(new AnonymousCommand(() => Clear(context)) { Result = CommandResult.KeepOpen() }, + "Delete retained usage observations?", "Delete this account and quota category's retained observations. Existing exports and learned forecasts are kept.", () => true) + { Name = "Delete retained observations" }) { Title = "Delete retained observations" }, + ]; + } + RaiseItemsChanged(0); + } + + private static string Percent(double? value) => value?.ToString("0.#", CultureInfo.InvariantCulture) is { } text ? text + "%" : "Not reported"; + private void OnUpdated(object? sender, EventArgs args) => Refresh(); + public void Dispose() { lock (_gate) { _disposed = true; _service.Updated -= OnUpdated; } } +} diff --git a/CodexUsageDock/Pages/CodexPlanningPage.cs b/CodexUsageDock/Pages/CodexPlanningPage.cs new file mode 100644 index 0000000..a340e18 --- /dev/null +++ b/CodexUsageDock/Pages/CodexPlanningPage.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using System.Text; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +internal sealed partial class CodexPlanningPage : ContentPage, IDisposable +{ + private readonly CodexUsageService _service; + private readonly CodexUsageDockSettingsPage _settings; + private readonly Func _clock; + private readonly object _gate = new(); + private MarkdownContent _content = new(string.Empty); + private bool _disposed; + + internal CodexPlanningPage(CodexUsageService service, CodexUsageDockSettingsPage settings, Func? clock = null) + { + _service = service; + _settings = settings; + _clock = clock ?? (() => DateTimeOffset.Now); + Id = "nl.mathijs.codexusage.planner"; + Name = "Open"; + Title = "Codex workday planner"; + Icon = new IconInfo("\uE787"); + service.Updated += OnUpdated; + Refresh(); + } + + public override IContent[] GetContent() { lock (_gate) { return [_content]; } } + + internal void Refresh() + { + var now = _clock(); + var localEnd = now.LocalDateTime.Date.Add(_settings.WorkdayEnd.ToTimeSpan()); + var body = TimeZoneInfo.Local.IsInvalidTime(localEnd) + ? "The selected workday end does not exist in today's local time zone. Choose another time in settings." + : FormatPlan(UsagePlanner.Plan(_service.GetPresentation(), now, _service.RefreshInterval, + new DateTimeOffset(localEnd, TimeZoneInfo.Local.GetUtcOffset(localEnd)), _settings.RemainingWorkdays)); + lock (_gate) + { + if (_disposed) return; + _content = new MarkdownContent(body); + Commands = [new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, + new CommandContextItem(_settings) { Title = "Change planning assumptions" }, + new CommandContextItem(new CopyTextCommand(body)) { Title = "Copy plan" }]; + } + RaiseItemsChanged(0); + } + + internal static string FormatPlan(UsagePlanningResult plan) + { + var text = new StringBuilder("# Workday planner\n\n").Append(plan.Status).Append("\n\n"); + text.Append("Target today: ").Append(plan.DesiredEnd.ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture)) + .Append(". Remaining workdays before weekly reset: ").Append(plan.RequestedWorkdays?.ToString(CultureInfo.InvariantCulture) ?? "1") + .Append(". Change these assumptions in settings.\n\n"); + foreach (var window in plan.Windows) + { + text.Append("## ").Append(window.Label).Append(" allowance\n\n") + .Append(window.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture)).Append("% remaining; budget ") + .Append(window.AvailablePointsPerWorkday.ToString("0.#", CultureInfo.InvariantCulture)).Append(" quota percentage points per workday, or ") + .Append(window.AvailablePointsPerHour.ToString("0.#", CultureInfo.InvariantCulture)).Append(" points per hour today.\n\n") + .Append(window.HorizonMessage).Append("\n\n") + .Append("**Recent pace:** ").Append(window.Forecast.Status).Append("\n\n") + .Append("**Evidence:** ").Append(window.Evidence.Summary).Append("\n\n") + .Append("**Last observation check:** ").Append(window.Backtest.Status).Append("\n\n"); + if (window.Backtest.AbsoluteErrorPercentagePoints is { } error) + text.Append("Held-out prediction error: ").Append(error.ToString("0.##", CultureInfo.InvariantCulture)) + .Append(" percentage points. One held-out observation does not establish long-term accuracy.\n\n"); + } + return text.Append("Quota points are not tokens, money, or a guaranteed number of tasks. Both windows apply independently. ") + .Append("This planner uses recent pace; the dashboard may additionally use its optional learned weekly pattern.").ToString(); + } + + private void OnUpdated(object? sender, EventArgs args) => Refresh(); + public void Dispose() { lock (_gate) { _disposed = true; _service.Updated -= OnUpdated; } } +} diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 0b959d5..9dd522a 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -22,6 +22,9 @@ internal sealed partial class CodexUsageDockSettingsPage : ContentPage private const string CodexExecutablePathKey = "codexExecutablePath"; private const string CodexHomePathKey = "codexHomePath"; private const int MaximumPathLength = 1024; + private const string HistoryRetentionKey = "historyRetentionDays"; + private const string WorkdayEndKey = "workdayEnd"; + private const string RemainingWorkdaysKey = "remainingWorkdays"; private readonly Settings _settings = new(); private readonly string _path; private readonly FormContent _statusContent = new() @@ -113,6 +116,25 @@ internal CodexUsageDockSettingsPage(string path) Label = "Refresh interval", Description = "How often the extension refreshes local Codex usage data.", }); + _settings.Add(new ChoiceSetSetting(HistoryRetentionKey, + [new("Collection paused", "0"), new("7 days", "7"), new("30 days", "30"), new("90 days", "90")]) + { + Label = "Retain usage observations", + Description = "Optional local quota history for export. Pausing keeps saved data; use History to delete it.", + }); + _settings.Add(new TextSetting(WorkdayEndKey, "17:00") + { + Label = "Workday end (HH:mm)", + Description = "Local time used by the planner for today. After this time, planning pauses until the next day.", + Multiline = false, + }); + _settings.Add(new ChoiceSetSetting(RemainingWorkdaysKey, + [new("1 workday", "1"), new("2 workdays", "2"), new("3 workdays", "3"), new("4 workdays", "4"), + new("5 workdays", "5"), new("6 workdays", "6"), new("7 workdays", "7")]) + { + Label = "Workdays remaining before weekly reset", + Description = "Your planning assumption, including today. The extension does not infer your calendar.", + }); var clearHistory = new ConfirmableCommand( new AnonymousCommand(() => ClearAdaptiveHistoryRequested?.Invoke(this, EventArgs.Empty)) { @@ -163,6 +185,13 @@ internal CodexUsageDockSettingsPage(string path) public TimeSpan RefreshInterval => ParseRefreshInterval(_settings.GetSetting(RefreshIntervalKey)); + internal int HistoryRetentionDays => _settings.GetSetting(HistoryRetentionKey) switch + { "7" => 7, "30" => 30, "90" => 90, _ => 0 }; + internal TimeOnly WorkdayEnd => TimeOnly.TryParseExact(_settings.GetSetting(WorkdayEndKey), "HH:mm", + System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var time) ? time : new(17, 0); + internal int RemainingWorkdays => int.TryParse(_settings.GetSetting(RemainingWorkdaysKey), out var days) + && days is >= 1 and <= 7 ? days : 1; + internal string? StatusMessage { get; private set; } public override IContent[] GetContent() => StatusMessage is null @@ -224,6 +253,13 @@ private void Load() { valid[property.Name] = value; } + else if (property.Name == HistoryRetentionKey && value is "0" or "7" or "30" or "90" + || property.Name == RemainingWorkdaysKey && value is "1" or "2" or "3" or "4" or "5" or "6" or "7" + || property.Name == WorkdayEndKey && TimeOnly.TryParseExact(value, "HH:mm", + System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out _)) + { + valid[property.Name] = value; + } else if (IsBooleanSetting(property.Name) && bool.TryParse(value, out var enabled)) { valid[property.Name] = enabled ? "true" : "false"; diff --git a/CodexUsageDock/ResetAttemptJournal.cs b/CodexUsageDock/ResetAttemptJournal.cs new file mode 100644 index 0000000..e983c97 --- /dev/null +++ b/CodexUsageDock/ResetAttemptJournal.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CodexUsageDock; + +// Persist before sending a mutation so a lost response or restart cannot create a second redemption. +internal sealed class ResetAttemptJournal(string path) +{ + internal bool TryRead(string accountKey, out string? key) + { + key = null; + try + { + var scoped = LocalStorage.ContextPath(path, accountKey); + if (!File.Exists(scoped)) return true; + if (new FileInfo(scoped).Length > 4096) return false; + using var document = JsonDocument.Parse(File.ReadAllText(scoped), new JsonDocumentOptions { MaxDepth = 4 }); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("schemaVersion", out var version) || !version.TryGetInt32(out var value) || value != 1 + || !root.TryGetProperty("pendingKey", out var pending)) return false; + if (pending.ValueKind == JsonValueKind.Null) return true; + if (pending.ValueKind != JsonValueKind.String || !Guid.TryParseExact(pending.GetString(), "N", out _)) return false; + key = pending.GetString(); + return true; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException or InvalidOperationException) + { + LocalStorage.TraceFailure("read pending reset", error); + return false; + } + } + + internal bool Save(string accountKey, string? key) => LocalStorage.TryWrite( + LocalStorage.ContextPath(path, accountKey), new JsonObject { ["schemaVersion"] = 1, ["pendingKey"] = key }.ToJsonString()); +} diff --git a/CodexUsageDock/ResetCreditData.cs b/CodexUsageDock/ResetCreditData.cs new file mode 100644 index 0000000..42cbb24 --- /dev/null +++ b/CodexUsageDock/ResetCreditData.cs @@ -0,0 +1,49 @@ +using System.Text.Json; + +namespace CodexUsageDock; + +internal enum ResetCreditOutcome +{ + Reset, + AlreadyRedeemed, + NothingToReset, + NoCredit, + Unsupported, + AccountMismatch, + // The consume request was not started; no credit could have been consumed. + Unavailable, + // A retry must use the original idempotency key. + Ambiguous, +} + +internal sealed record ResetCreditResult(ResetCreditOutcome Outcome, DateTimeOffset AttemptedAt); + +internal static class ResetCreditParser +{ + internal static bool IsValidIdempotencyKey(string? value) => value is { Length: > 0 and <= 128 } + && value.All(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_'); + + internal static ResetCreditResult Parse(JsonElement response, DateTimeOffset attemptedAt) + { + if (response.ValueKind == JsonValueKind.Object && response.TryGetProperty("error", out _) + && response.TryGetProperty("result", out _)) return new(ResetCreditOutcome.Ambiguous, attemptedAt); + if (CodexAppServerReader.IsMethodNotFound(response)) return new(ResetCreditOutcome.Unsupported, attemptedAt); + if (response.ValueKind != JsonValueKind.Object + || response.TryGetProperty("error", out _) + || !response.TryGetProperty("result", out var result) || result.ValueKind != JsonValueKind.Object + || !result.TryGetProperty("outcome", out var value) || value.ValueKind != JsonValueKind.String) + { + return new(ResetCreditOutcome.Ambiguous, attemptedAt); + } + + var outcome = value.GetString() switch + { + "reset" => ResetCreditOutcome.Reset, + "alreadyRedeemed" => ResetCreditOutcome.AlreadyRedeemed, + "nothingToReset" => ResetCreditOutcome.NothingToReset, + "noCredit" => ResetCreditOutcome.NoCredit, + _ => ResetCreditOutcome.Ambiguous, + }; + return new(outcome, attemptedAt); + } +} diff --git a/CodexUsageDock/TaskUsageData.cs b/CodexUsageDock/TaskUsageData.cs new file mode 100644 index 0000000..eca9865 --- /dev/null +++ b/CodexUsageDock/TaskUsageData.cs @@ -0,0 +1,130 @@ +using System.Text.Json; + +namespace CodexUsageDock; + +internal enum ThreadUsageStatus +{ + Available, + Partial, + Unsupported, + Unavailable, + AccountMismatch, +} + +internal sealed record ThreadUsageGroup( + string? Model, + string? ReasoningEffort, + string? Speed, + long? EstimatedUsageCreditsMicros, + long? NetNewInputTokens, + long? CachedInputTokens, + long? InputTokens, + long? OutputTokens, + long? TotalTokens); + +internal sealed record ThreadUsageSnapshot( + string? ThreadId, + string? AccountKey, + DateTimeOffset UpdatedAt, + ThreadUsageStatus Status, + long? EstimatedUsageCreditsMicros = null, + long? EstimatedUsageUsdMicros = null, + IReadOnlyList? Groups = null) +{ + internal static ThreadUsageSnapshot Unavailable { get; } = new(null, null, DateTimeOffset.MinValue, ThreadUsageStatus.Unavailable); +} + +internal static class ThreadUsageParser +{ + internal const int MaximumGroups = 64; + + internal static bool IsValidThreadId(string? value) => value is { Length: > 0 and <= 128 } + && value.All(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_'); + + internal static ThreadUsageSnapshot Parse(JsonElement result, string expectedThreadId, string accountKey, DateTimeOffset now) + { + if (!IsValidThreadId(expectedThreadId) || string.IsNullOrWhiteSpace(accountKey) || result.ValueKind != JsonValueKind.Object + || !result.TryGetProperty("threadUsage", out var usage) || usage.ValueKind != JsonValueKind.Object + || !usage.TryGetProperty("threadId", out var thread) || thread.ValueKind != JsonValueKind.String + || !string.Equals(thread.GetString(), expectedThreadId, StringComparison.Ordinal)) + { + return ThreadUsageSnapshot.Unavailable with { UpdatedAt = now }; + } + + var partial = false; + var credits = ReadNonnegative(usage, "estimatedUsageCreditsMicros", ref partial); + var usd = ReadNonnegative(usage, "estimatedUsageUsdMicros", ref partial); + var groups = new List(); + if (usage.TryGetProperty("groups", out var groupValues) && groupValues.ValueKind == JsonValueKind.Array) + { + var count = 0; + foreach (var group in groupValues.EnumerateArray()) + { + if (++count > MaximumGroups) + { + partial = true; + break; + } + if (group.ValueKind != JsonValueKind.Object) + { + partial = true; + continue; + } + + var parsed = new ThreadUsageGroup( + ReadLabel(group, "model", 80, ref partial), + ReadLabel(group, "reasoningEffort", 32, ref partial), + ReadLabel(group, "speed", 32, ref partial), + ReadNonnegative(group, "estimatedUsageCreditsMicros", ref partial), + ReadNonnegative(group, "netNewInputTokens", ref partial), + ReadNonnegative(group, "cachedInputTokens", ref partial), + ReadNonnegative(group, "inputTokens", ref partial), + ReadNonnegative(group, "outputTokens", ref partial), + ReadNonnegative(group, "totalTokens", ref partial)); + if (parsed is { Model: null, ReasoningEffort: null, Speed: null, EstimatedUsageCreditsMicros: null, + NetNewInputTokens: null, CachedInputTokens: null, InputTokens: null, OutputTokens: null, TotalTokens: null }) + { + partial = true; + continue; + } + + groups.Add(parsed); + } + } + else + { + partial = true; + } + + if (credits is null && usd is null && groups.Count == 0) + { + return ThreadUsageSnapshot.Unavailable with { UpdatedAt = now }; + } + + return new(expectedThreadId, accountKey, now, partial ? ThreadUsageStatus.Partial : ThreadUsageStatus.Available, + credits, usd, groups.ToArray()); + } + + private static string? ReadLabel(JsonElement element, string name, int maximumLength, ref bool partial) + { + if (!element.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null) return null; + if (value.ValueKind == JsonValueKind.String) + { + var original = value.GetString(); + var safe = UsageText.SanitizeExternal(original, maximumLength); + if (!string.Equals(original, safe, StringComparison.Ordinal)) partial = true; + return safe; + } + + partial = true; + return null; + } + + private static long? ReadNonnegative(JsonElement element, string name, ref bool partial) + { + if (!element.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null) return null; + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var number) && number >= 0) return number; + partial = true; + return null; + } +} diff --git a/CodexUsageDock/UsageAggregateStore.cs b/CodexUsageDock/UsageAggregateStore.cs new file mode 100644 index 0000000..d385d01 --- /dev/null +++ b/CodexUsageDock/UsageAggregateStore.cs @@ -0,0 +1,624 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodexUsageDock; + +internal sealed record UsageAggregatePoint( + DateTimeOffset RecordedAt, + double? PrimaryRemainingPercent, + double? WeeklyRemainingPercent, + DateTimeOffset? PrimaryResetsAt, + DateTimeOffset? WeeklyResetsAt, + UsageDataSource Source); + +internal sealed record UsageAggregateStoreDocument( + int SchemaVersion, + int RetentionDays, + UsageAggregatePoint?[]? Observations); + +internal sealed class UsageAggregateStore +{ + internal const int SchemaVersion = 1; + internal const int MaximumEntries = 27_000; + internal const int DefaultRetentionDays = 30; + internal const int MaximumDocumentBytes = 16 * 1024 * 1024; + + private static readonly TimeSpan ObservationBucket = TimeSpan.FromMinutes(5); + private const string FileName = "usage-aggregates.json"; + private const string LoadErrorMessage = "Saved usage observations could not be read. A new history will be collected."; + private const string SaveErrorMessage = "Usage observations could not be saved. They may be lost after restarting."; + private const string InvalidObservationMessage = "The usage observation was invalid and was not saved."; + private const string InvalidRetentionMessage = "Retention must be 7, 30, or 90 days."; + private const string ClearErrorMessage = "Usage observations could not be cleared."; + private const string ExportCaveat = "Local quota observations only; no tokens, costs, account identifiers, or session content."; + private readonly object _gate = new(); + private readonly string _path; + private readonly Func _clock; + private List _observations; + private int _retentionDays; + private string? _storageError; + + internal UsageAggregateStore( + string path, + int retentionDays = DefaultRetentionDays, + Func? clock = null) + { + _path = Path.GetFullPath(path); + _retentionDays = NormalizeRetentionDays(retentionDays); + _clock = clock ?? (() => DateTimeOffset.UtcNow); + _observations = Load(); + } + + internal static UsageAggregateStore CreateDefault() => new(LocalStorage.GetPath(FileName)); + + internal UsageAggregateStore ForContext(string context) => + new(LocalStorage.ContextPath(_path, context), _retentionDays, _clock); + + internal int RetentionDays + { + get + { + lock (_gate) + { + return _retentionDays; + } + } + } + + internal string? StorageError + { + get + { + lock (_gate) + { + return _storageError; + } + } + } + + internal IReadOnlyList Snapshot + { + get + { + lock (_gate) + { + return _observations.ToArray(); + } + } + } + + internal IReadOnlyList Read(DateTimeOffset now) + { + lock (_gate) + { + if (!TryNormalizeNow(now, out var normalizedNow)) + { + return _observations.ToArray(); + } + + var normalized = Normalize(_observations, normalizedNow, _retentionDays, out var changed); + if (changed) + { + var previous = _observations; + if (TrySave(normalized, out _)) + { + _observations = normalized; + } + else + { + _observations = previous; + } + } + + return _observations.ToArray(); + } + } + + internal bool SetRetentionDays(int retentionDays, DateTimeOffset now) => + SetRetentionDays(retentionDays, now, out _); + + internal bool SetRetentionDays(int retentionDays, DateTimeOffset now, out string? error) + { + error = null; + if (!TryGetSupportedRetention(retentionDays, out var normalizedRetention)) + { + error = InvalidRetentionMessage; + SetStorageError(error); + return false; + } + + if (!TryNormalizeNow(now, out var normalizedNow)) + { + error = InvalidObservationMessage; + SetStorageError(error); + return false; + } + + lock (_gate) + { + var previousRetention = _retentionDays; + var previousObservations = _observations; + _retentionDays = normalizedRetention; + var normalized = Normalize(_observations, normalizedNow, _retentionDays, out _); + if (!TrySave(normalized, out error)) + { + _retentionDays = previousRetention; + _observations = previousObservations; + return false; + } + + _observations = normalized; + return true; + } + } + + internal bool Record(UsageAggregatePoint point) => + Record(point, _clock(), out _); + + internal bool Record(UsageAggregatePoint point, out string? error) => + Record(point, _clock(), out error); + + internal bool Record(UsageAggregatePoint point, DateTimeOffset now) => + Record(point, now, out _); + + internal bool Record(UsageAggregatePoint point, DateTimeOffset now, out string? error) + { + error = null; + if (!TryNormalizeNow(now, out var normalizedNow) + || !TryNormalizePoint(point, normalizedNow, out var normalizedPoint)) + { + error = InvalidObservationMessage; + SetStorageError(error); + return false; + } + + lock (_gate) + { + var previous = _observations; + var updated = new List(previous); + var key = GetObservationKey(normalizedPoint); + var existingIndex = FindLastIndex(updated, key); + if (existingIndex >= 0) + { + if (updated[existingIndex].RecordedAt > normalizedPoint.RecordedAt) + { + return true; + } + + updated[existingIndex] = normalizedPoint; + } + else + { + updated.Add(normalizedPoint); + } + + updated = Normalize(updated, normalizedNow, _retentionDays, out _); + if (!TrySave(updated, out error)) + { + _observations = previous; + return false; + } + + _observations = updated; + return true; + } + } + + internal bool Clear() => Clear(out _); + + internal bool Clear(out string? error) + { + error = null; + lock (_gate) + { + var previous = _observations; + if (!TrySave([], out error)) + { + _observations = previous; + error ??= ClearErrorMessage; + return false; + } + + _observations = []; + return true; + } + } + + internal string ExportJson() => ExportJson(_clock()); + + internal string ExportJson(DateTimeOffset now) + { + var observations = Read(now); + var output = new StringBuilder(Math.Min(1_000_000, observations.Count * 180 + 512)); + output.Append("{\n"); + output.Append(" \"schemaVersion\": 1,\n"); + output.Append(" \"units\": {\n"); + output.Append(" \"timestamps\": \"UTC ISO 8601\",\n"); + output.Append(" \"remainingPercent\": \"percent (0-100)\"\n"); + output.Append(" },\n"); + output.Append(" \"caveat\": ").Append(Quote(ExportCaveat)).Append(",\n"); + output.Append(" \"observations\": ["); + for (var index = 0; index < observations.Count; index++) + { + if (index == 0) + { + output.Append('\n'); + } + else + { + output.Append(",\n"); + } + + AppendJsonObservation(output, observations[index]); + } + + if (observations.Count > 0) + { + output.Append('\n'); + } + + output.Append(" ]\n"); + output.Append('}'); + return output.ToString(); + } + + internal string ExportCsv() => ExportCsv(_clock()); + + internal string ExportCsv(DateTimeOffset now) + { + var observations = Read(now); + var output = new StringBuilder(Math.Min(1_000_000, observations.Count * 160 + 512)); + output.Append("# schemaVersion=1\n"); + output.Append("# units=recordedAtUtc and reset timestamps are UTC ISO 8601; remainingPercent fields are percent from 0 to 100\n"); + output.Append("# caveat=Local quota observations only; no tokens, costs, account identifiers, or session content\n"); + output.Append("recordedAtUtc,primaryRemainingPercent,weeklyRemainingPercent,primaryResetsAtUtc,weeklyResetsAtUtc,source\n"); + foreach (var observation in observations) + { + output.Append(FormatUtc(observation.RecordedAt)).Append(','); + AppendCsvNumber(output, observation.PrimaryRemainingPercent).Append(','); + AppendCsvNumber(output, observation.WeeklyRemainingPercent).Append(','); + AppendCsvTimestamp(output, observation.PrimaryResetsAt).Append(','); + AppendCsvTimestamp(output, observation.WeeklyResetsAt).Append(','); + output.Append(observation.Source.ToString()).Append('\n'); + } + + return output.ToString(); + } + + internal static bool TryCreateLivePoint( + CodexUsageSnapshot? snapshot, + DateTimeOffset now, + TimeSpan refreshInterval, + out UsageAggregatePoint? point) + { + point = null; + if (snapshot is null + || snapshot.Source != UsageDataSource.AppServer + || string.IsNullOrWhiteSpace(snapshot.AccountKey) + || !TryNormalizeNow(now, out var normalizedNow) + || snapshot.UpdatedAt > normalizedNow + || !UsageFreshness.IsFresh(snapshot.UpdatedAt, normalizedNow, refreshInterval)) + { + return false; + } + + var primary = TryGetWindowValues(snapshot.Primary, normalizedNow); + var weekly = TryGetWindowValues(snapshot.Secondary, normalizedNow); + if (primary is null && weekly is null) + { + return false; + } + + point = new UsageAggregatePoint( + snapshot.UpdatedAt.ToUniversalTime(), + primary?.RemainingPercent, + weekly?.RemainingPercent, + primary?.ResetsAt, + weekly?.ResetsAt, + UsageDataSource.AppServer); + return true; + } + + private List Load() + { + try + { + if (!File.Exists(_path)) + { + return []; + } + + var fileInfo = new FileInfo(_path); + if (fileInfo.Length > MaximumDocumentBytes) + { + SetStorageError(LoadErrorMessage); + return []; + } + + var document = JsonSerializer.Deserialize( + File.ReadAllText(_path), + UsageAggregateStoreJsonContext.Default.UsageAggregateStoreDocument); + if (document is null + || document.SchemaVersion != SchemaVersion + || document.Observations is null + || !TryNormalizeNow(_clock(), out var now)) + { + SetStorageError(LoadErrorMessage); + return []; + } + + var normalized = Normalize(document.Observations, now, _retentionDays, out var changed); + if (changed) + { + _ = TrySave(normalized, out _); + } + + return normalized; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException or NotSupportedException or InvalidOperationException) + { + LocalStorage.TraceFailure("load usage observations", error); + SetStorageError(LoadErrorMessage); + return []; + } + } + + private bool TrySave(IReadOnlyList observations, out string? error) + { + error = null; + try + { + var document = new UsageAggregateStoreDocument( + SchemaVersion, + _retentionDays, + observations.ToArray()); + var json = JsonSerializer.Serialize( + document, + UsageAggregateStoreJsonContext.Default.UsageAggregateStoreDocument); + if (!LocalStorage.TryWrite(_path, json)) + { + error = SaveErrorMessage; + SetStorageError(error); + return false; + } + + SetStorageError(null); + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException or NotSupportedException or InvalidOperationException or ArgumentException) + { + LocalStorage.TraceFailure("save usage observations", exception); + error = SaveErrorMessage; + SetStorageError(error); + return false; + } + } + + private static List Normalize( + IEnumerable observations, + DateTimeOffset now, + int retentionDays, + out bool changed) + { + var original = observations.ToArray(); + var accepted = new List(); + foreach (var observation in original) + { + if (observation is null || !TryNormalizePoint(observation, now, out var normalized)) + { + continue; + } + + if (!IsWithinRetention(normalized.RecordedAt, now, retentionDays)) + { + continue; + } + + accepted.Add(normalized); + } + + accepted.Sort(static (left, right) => left.RecordedAt.CompareTo(right.RecordedAt)); + var deduplicated = new List(Math.Min(accepted.Count, MaximumEntries)); + var indexes = new Dictionary(); + foreach (var observation in accepted) + { + var key = GetObservationKey(observation); + if (indexes.TryGetValue(key, out var index)) + { + deduplicated[index] = observation; + } + else + { + indexes[key] = deduplicated.Count; + deduplicated.Add(observation); + } + } + + if (deduplicated.Count > MaximumEntries) + { + deduplicated = deduplicated.TakeLast(MaximumEntries).ToList(); + } + + changed = accepted.Count != original.Length + || deduplicated.Count != accepted.Count + || !deduplicated.SequenceEqual(original.Where(observation => observation is not null)!.Cast()); + return deduplicated; + } + + private static bool IsWithinRetention(DateTimeOffset recordedAt, DateTimeOffset now, int retentionDays) + { + try + { + var age = now - recordedAt; + return age >= TimeSpan.Zero && age <= TimeSpan.FromDays(retentionDays); + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + private static bool TryNormalizePoint( + UsageAggregatePoint point, + DateTimeOffset now, + out UsageAggregatePoint normalized) + { + normalized = point; + if (point.Source != UsageDataSource.AppServer + || !IsValidTimestamp(point.RecordedAt) + || point.RecordedAt > now + || !IsValidPercent(point.PrimaryRemainingPercent) + || !IsValidPercent(point.WeeklyRemainingPercent)) + { + return false; + } + + if (point.PrimaryRemainingPercent is null && point.WeeklyRemainingPercent is null) + { + return false; + } + + if (!TryNormalizeReset(point.PrimaryResetsAt, point.RecordedAt, out var primaryReset) + || !TryNormalizeReset(point.WeeklyResetsAt, point.RecordedAt, out var weeklyReset)) + { + return false; + } + + normalized = new UsageAggregatePoint( + point.RecordedAt.ToUniversalTime(), + point.PrimaryRemainingPercent, + point.WeeklyRemainingPercent, + point.PrimaryRemainingPercent is null ? null : primaryReset, + point.WeeklyRemainingPercent is null ? null : weeklyReset, + UsageDataSource.AppServer); + return true; + } + + private static bool TryNormalizeReset( + DateTimeOffset? reset, + DateTimeOffset recordedAt, + out DateTimeOffset? normalized) + { + normalized = null; + if (reset is not { } value) + { + return true; + } + + if (!IsValidTimestamp(value) || value < recordedAt) + { + return false; + } + + normalized = value.ToUniversalTime(); + return true; + } + + private static (double RemainingPercent, DateTimeOffset ResetsAt)? TryGetWindowValues( + RateLimitWindow? window, + DateTimeOffset now) + { + if (!UsageFreshness.IsValidWindow(window, now) + || !IsValidTimestamp(window!.ResetsAt)) + { + return null; + } + + return (window!.RemainingPercent, window.ResetsAt.ToUniversalTime()); + } + + private static bool IsValidPercent(double? value) => + value is null || double.IsFinite(value.Value) && value.Value is >= 0 and <= 100; + + private static bool IsValidTimestamp(DateTimeOffset value) => + value > DateTimeOffset.MinValue && value < DateTimeOffset.MaxValue; + + private static bool TryNormalizeNow(DateTimeOffset now, out DateTimeOffset normalized) + { + normalized = now.ToUniversalTime(); + return IsValidTimestamp(normalized); + } + + private static int NormalizeRetentionDays(int retentionDays) => + TryGetSupportedRetention(retentionDays, out var normalized) ? normalized : DefaultRetentionDays; + + private static bool TryGetSupportedRetention(int retentionDays, out int normalized) + { + normalized = retentionDays switch + { + 7 or 30 or 90 => retentionDays, + _ => 0, + }; + return normalized != 0; + } + + private static int FindLastIndex(List observations, ObservationKey key) + { + for (var index = observations.Count - 1; index >= 0; index--) + { + if (GetObservationKey(observations[index]) == key) + { + return index; + } + } + + return -1; + } + + private static ObservationKey GetObservationKey(UsageAggregatePoint observation) => new( + observation.RecordedAt.UtcTicks / ObservationBucket.Ticks, + observation.PrimaryResetsAt?.UtcTicks, + observation.WeeklyResetsAt?.UtcTicks); + + private static void AppendJsonObservation(StringBuilder output, UsageAggregatePoint observation) + { + output.Append(" {\"recordedAtUtc\": ") + .Append(Quote(FormatUtc(observation.RecordedAt))) + .Append(", \"primaryRemainingPercent\": "); + AppendJsonNumber(output, observation.PrimaryRemainingPercent); + output.Append(", \"weeklyRemainingPercent\": "); + AppendJsonNumber(output, observation.WeeklyRemainingPercent); + output.Append(", \"primaryResetsAtUtc\": "); + AppendJsonTimestamp(output, observation.PrimaryResetsAt); + output.Append(", \"weeklyResetsAtUtc\": "); + AppendJsonTimestamp(output, observation.WeeklyResetsAt); + output.Append(", \"source\": ").Append(Quote(observation.Source.ToString())).Append('}'); + } + + private static void AppendJsonNumber(StringBuilder output, double? value) => + output.Append(value is { } number ? number.ToString("R", CultureInfo.InvariantCulture) : "null"); + + private static void AppendJsonTimestamp(StringBuilder output, DateTimeOffset? value) => + output.Append(value is { } timestamp ? Quote(FormatUtc(timestamp)) : "null"); + + private static StringBuilder AppendCsvNumber(StringBuilder output, double? value) => + output.Append(value is { } number ? number.ToString("R", CultureInfo.InvariantCulture) : string.Empty); + + private static StringBuilder AppendCsvTimestamp(StringBuilder output, DateTimeOffset? value) => + output.Append(value is { } timestamp ? FormatUtc(timestamp) : string.Empty); + + private static string FormatUtc(DateTimeOffset value) => + value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", CultureInfo.InvariantCulture); + + private static string Quote(string value) => + "\"" + JsonEncodedText.Encode(value).ToString() + "\""; + + private void SetStorageError(string? error) + { + lock (_gate) + { + _storageError = error; + } + } + + private readonly record struct ObservationKey(long Bucket, long? PrimaryResetTicks, long? WeeklyResetTicks); +} + +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(UsageAggregateStoreDocument))] +[JsonSerializable(typeof(UsageAggregatePoint))] +internal sealed partial class UsageAggregateStoreJsonContext : JsonSerializerContext +{ +} diff --git a/CodexUsageDock/UsagePlanning.cs b/CodexUsageDock/UsagePlanning.cs new file mode 100644 index 0000000..2f5fb97 --- /dev/null +++ b/CodexUsageDock/UsagePlanning.cs @@ -0,0 +1,596 @@ +using System.Globalization; + +namespace CodexUsageDock; + +internal static class UsagePlanner +{ + internal const string Disclaimer = "Planning guidance only; it is not a guarantee."; + + internal static UsagePlanningResult Plan( + UsagePresentation presentation, + DateTimeOffset now, + TimeSpan refreshInterval, + DateTimeOffset desiredEnd, + int? remainingWorkdays = null) + { + ArgumentNullException.ThrowIfNull(presentation); + + if (desiredEnd <= now) + { + return Unavailable(desiredEnd, "Planning unavailable: the desired end must be after now.", remainingWorkdays); + } + + if (remainingWorkdays is < 1 or > 7) + { + return Unavailable( + desiredEnd, + "Planning unavailable: remaining workdays must be between 1 and 7.", + remainingWorkdays); + } + + var snapshot = presentation.Usage; + if (presentation.IsLoading) + { + return Unavailable(desiredEnd, "Planning paused while fresh usage data is loading.", remainingWorkdays); + } + + if (snapshot.Source != UsageDataSource.AppServer) + { + var sourceMessage = snapshot.Source switch + { + UsageDataSource.LastConfirmed => "Planning paused: only last confirmed usage is available; refresh live usage first.", + UsageDataSource.Unavailable => "Planning paused: live usage is unavailable.", + UsageDataSource.LocalSession => "Planning paused: local session usage is not a verified live app-server measurement.", + _ => "Planning paused until fresh live app-server usage is available.", + }; + return Unavailable(desiredEnd, sourceMessage, remainingWorkdays); + } + + if (string.IsNullOrWhiteSpace(snapshot.AccountKey)) + { + return Unavailable(desiredEnd, "Planning paused: the verified account identity is unavailable.", remainingWorkdays); + } + + var freshness = UsageFreshness.Classify(snapshot.UpdatedAt, now, refreshInterval); + if (freshness != UsageFreshnessState.Fresh) + { + var freshnessMessage = freshness switch + { + UsageFreshnessState.Stale => "Planning paused: usage data is stale; refresh before planning.", + UsageFreshnessState.Future => "Planning paused: the usage timestamp is in the future.", + UsageFreshnessState.Unknown => "Planning paused: usage freshness is unknown.", + _ => "Planning paused until fresh usage data is available.", + }; + return Unavailable(desiredEnd, freshnessMessage, remainingWorkdays); + } + + if (snapshot.OrdinaryUsageAllowed == false) + { + return Unavailable(desiredEnd, "Planning paused: ordinary usage is currently blocked.", remainingWorkdays); + } + + try + { + var maximumSampleAge = UsageFreshness.MaximumAge(refreshInterval); + var primary = CreateWindowPlan( + snapshot.Primary, + presentation.PrimaryHistory, + now, + desiredEnd, + remainingWorkdays, + maximumSampleAge, + adaptiveCycleCount: 0, + isPrimary: true); + var weekly = CreateWindowPlan( + snapshot.Secondary, + presentation.WeeklyHistory, + now, + desiredEnd, + remainingWorkdays, + maximumSampleAge, + CountAdaptiveCycles(presentation), + isPrimary: false); + + if (primary is null && weekly is null) + { + return Unavailable( + desiredEnd, + "Planning paused: no valid 5-hour or weekly allowance window is available.", + remainingWorkdays); + } + + var windows = new[] { primary, weekly }.Where(window => window is not null).Cast().ToArray(); + var status = windows.Length == 2 + ? $"Planning available for both allowance windows. {Disclaimer}" + : $"Planning available for one allowance window. {Disclaimer}"; + return new UsagePlanningResult( + true, + status, + desiredEnd, + remainingWorkdays, + primary, + weekly, + Disclaimer); + } + catch (ArgumentOutOfRangeException) + { + return Unavailable(desiredEnd, "Planning paused: the usage window timestamps are out of range.", remainingWorkdays); + } + catch (OverflowException) + { + return Unavailable(desiredEnd, "Planning paused: the usage window duration is out of range.", remainingWorkdays); + } + } + + private static UsagePlanningWindow? CreateWindowPlan( + RateLimitWindow? window, + IReadOnlyList history, + DateTimeOffset now, + DateTimeOffset desiredEnd, + int? requestedWorkdays, + TimeSpan maximumSampleAge, + int adaptiveCycleCount, + bool isPrimary) + { + if (!UsageFreshness.IsValidWindow(window, now)) + { + return null; + } + + var validWindow = window!; + var horizonEnd = validWindow.ResetsAt < desiredEnd ? validWindow.ResetsAt : desiredEnd; + var horizon = horizonEnd - now; + if (horizon <= TimeSpan.Zero) + { + return null; + } + + var workdays = isPrimary + ? 1 + : Math.Max(1, Math.Min(requestedWorkdays ?? 1, CalendarDaysUntilReset(validWindow.ResetsAt, now))); + var remaining = validWindow.RemainingPercent; + var pointsPerWorkday = remaining / workdays; + var pointsPerHour = pointsPerWorkday / horizon.TotalHours; + var displayLabel = FormatWindowDuration(validWindow.WindowMinutes); + var resetsBeforeDesiredEnd = validWindow.ResetsAt < desiredEnd; + var horizonMessage = resetsBeforeDesiredEnd + ? $"This {displayLabel} window resets before the desired end at {FormatUtc(validWindow.ResetsAt)}; planning stops at that reset." + : $"Planning runs until {FormatUtc(horizonEnd)}."; + + var forecast = CreateForecast( + validWindow, + history, + now, + maximumSampleAge); + var evidence = CreateEvidence( + validWindow, + history, + now, + maximumSampleAge, + adaptiveCycleCount); + var backtest = CreateBacktest( + validWindow, + history, + now, + maximumSampleAge); + + return new UsagePlanningWindow( + displayLabel, + remaining, + validWindow.WindowMinutes, + validWindow.ResetsAt, + horizonEnd, + workdays, + horizon, + pointsPerWorkday, + pointsPerHour, + resetsBeforeDesiredEnd, + horizonMessage, + forecast, + evidence, + backtest); + } + + private static UsagePlanningForecast CreateForecast( + RateLimitWindow window, + IReadOnlyList history, + DateTimeOffset now, + TimeSpan maximumSampleAge) + { + if (!TryGetWindowStart(window, out var windowStart)) + { + return UsagePlanningForecast.Unavailable("Forecast unavailable: the window duration is out of range."); + } + + try + { + var analysis = UsageTrendAnalyzer.Analyze( + history ?? Array.Empty(), + windowStart, + window.ResetsAt, + now, + dataAvailable: true, + maximumSampleAge, + adaptiveWeeklyForecastEnabled: false, + adaptiveWeeklyHistory: null); + if (analysis.Forecast is null) + { + return UsagePlanningForecast.Unavailable(analysis.ForecastStatus); + } + + return new UsagePlanningForecast(true, analysis.Forecast, analysis.ForecastStatus); + } + catch (ArgumentException) + { + return UsagePlanningForecast.Unavailable("Forecast unavailable: the measurements are not usable."); + } + catch (InvalidOperationException) + { + return UsagePlanningForecast.Unavailable("Forecast unavailable: the measurements are not usable."); + } + catch (OverflowException) + { + return UsagePlanningForecast.Unavailable("Forecast unavailable: the timestamps are out of range."); + } + } + + private static UsagePlanningEvidence CreateEvidence( + RateLimitWindow window, + IReadOnlyList history, + DateTimeOffset now, + TimeSpan maximumSampleAge, + int adaptiveCycleCount) + { + var samples = GetWindowSamples(history, window, now); + var segment = GetLatestSegment(history, window, now, maximumSampleAge); + var measurementSpan = GetSpan(samples); + var segmentSpan = GetSpan(segment); + var summary = $"{samples.Length.ToString(CultureInfo.InvariantCulture)} measurements over {FormatDuration(measurementSpan)}; " + + $"latest continuous segment: {segment.Length.ToString(CultureInfo.InvariantCulture)} measurements over {FormatDuration(segmentSpan)}; " + + $"adaptive weekly cycles: {adaptiveCycleCount.ToString(CultureInfo.InvariantCulture)}. " + + "This is descriptive evidence, not a calibrated reliability probability."; + return new UsagePlanningEvidence( + samples.Length, + measurementSpan, + segment.Length, + segmentSpan, + adaptiveCycleCount, + summary); + } + + private static UsagePlanningBacktest CreateBacktest( + RateLimitWindow window, + IReadOnlyList history, + DateTimeOffset now, + TimeSpan maximumSampleAge) + { + var segment = GetLatestSegment(history, window, now, maximumSampleAge); + if (segment.Length < 3) + { + return UsagePlanningBacktest.Unavailable( + "Backtest unavailable: at least three continuous measurements are required to hold out the latest one."); + } + + var heldOut = segment[^1]; + var training = segment[..^1]; + var first = training[0]; + var last = training[^1]; + var trainingSpan = last.RecordedAt - first.RecordedAt; + var holdoutInterval = heldOut.RecordedAt - last.RecordedAt; + if (training.Length < 2 + || trainingSpan <= TimeSpan.Zero + || holdoutInterval <= TimeSpan.Zero + || holdoutInterval > MaximumGap(maximumSampleAge)) + { + return UsagePlanningBacktest.Unavailable( + "Backtest unavailable: the held-out measurement does not follow a meaningful continuous pace."); + } + + var consumed = first.RemainingPercent - last.RemainingPercent; + var rate = consumed / trainingSpan.TotalMinutes; + if (!double.IsFinite(rate) || rate <= 0) + { + return UsagePlanningBacktest.Unavailable( + "Backtest unavailable: preceding measurements do not show a usable downward pace."); + } + + var predicted = Math.Clamp( + last.RemainingPercent - rate * holdoutInterval.TotalMinutes, + 0, + 100); + var error = Math.Abs(predicted - heldOut.RemainingPercent); + var status = $"Holdout at {FormatUtc(heldOut.RecordedAt)}: predicted {FormatPercent(predicted)}%, " + + $"actual {FormatPercent(heldOut.RemainingPercent)}%, absolute error {FormatPercent(error)} percentage points; " + + "the latest measurement was excluded from the preceding-pace calculation."; + return new UsagePlanningBacktest( + true, + training.Length, + heldOut.RecordedAt, + predicted, + heldOut.RemainingPercent, + error, + status); + } + + private static UsageHistoryEntry[] GetLatestSegment( + IReadOnlyList history, + RateLimitWindow window, + DateTimeOffset now, + TimeSpan maximumSampleAge) + { + if (!TryGetWindowStart(window, out var windowStart)) + { + return []; + } + + try + { + return UsageTrendHistory.LatestSegment( + history ?? Array.Empty(), + windowStart, + window.ResetsAt, + now, + MaximumGap(maximumSampleAge)); + } + catch (ArgumentException) + { + return []; + } + catch (InvalidOperationException) + { + return []; + } + catch (OverflowException) + { + return []; + } + } + + private static UsageHistoryEntry[] GetWindowSamples( + IReadOnlyList history, + RateLimitWindow window, + DateTimeOffset now) + { + if (!TryGetWindowStart(window, out var windowStart) || history is null) + { + return []; + } + + try + { + return history + .Where(sample => sample is not null + && double.IsFinite(sample.RemainingPercent) + && sample.RemainingPercent is >= 0 and <= 100 + && sample.RecordedAt >= windowStart + && sample.RecordedAt <= window.ResetsAt + && sample.RecordedAt <= now) + .OrderBy(sample => sample.RecordedAt) + .GroupBy(sample => sample.RecordedAt) + .Select(group => group.Last()) + .ToArray(); + } + catch (ArgumentException) + { + return []; + } + catch (InvalidOperationException) + { + return []; + } + catch (OverflowException) + { + return []; + } + } + + private static TimeSpan? GetSpan(UsageHistoryEntry[] samples) + { + if (samples.Length < 2) + { + return null; + } + + try + { + return samples[^1].RecordedAt - samples[0].RecordedAt; + } + catch (ArgumentOutOfRangeException) + { + return null; + } + } + + private static int CountAdaptiveCycles(UsagePresentation presentation) + { + var history = presentation.AdaptiveWeeklyHistory; + if (history is null) + { + return 0; + } + + var completed = history.CompletedCycles?.Count(cycle => cycle is not null) ?? 0; + return completed + (history.ActiveCycle is null ? 0 : 1); + } + + private static int CalendarDaysUntilReset(DateTimeOffset resetsAt, DateTimeOffset now) + { + var days = (resetsAt.Date - now.Date).Days; + if (days <= 1) + { + return 1; + } + + return days; + } + + private static TimeSpan MaximumGap(TimeSpan maximumSampleAge) + { + if (maximumSampleAge <= TimeSpan.Zero) + { + return TimeSpan.FromMinutes(15); + } + + return maximumSampleAge.Ticks > TimeSpan.MaxValue.Ticks / 3 + ? TimeSpan.MaxValue + : TimeSpan.FromTicks(maximumSampleAge.Ticks * 3); + } + + private static bool TryGetWindowStart(RateLimitWindow window, out DateTimeOffset start) + { + try + { + start = window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes); + return true; + } + catch (ArgumentOutOfRangeException) + { + start = default; + return false; + } + catch (OverflowException) + { + start = default; + return false; + } + } + + private static string FormatDuration(TimeSpan? duration) + { + if (duration is not { } value) + { + return "unknown span"; + } + + if (value.TotalMinutes < 1) + { + return $"{Math.Max(1, (int)Math.Round(value.TotalSeconds))} seconds"; + } + + if (value.TotalHours < 1) + { + return $"{Math.Round(value.TotalMinutes, 1).ToString("0.#", CultureInfo.InvariantCulture)} minutes"; + } + + if (value.TotalDays < 1) + { + return $"{Math.Round(value.TotalHours, 1).ToString("0.#", CultureInfo.InvariantCulture)} hours"; + } + + return $"{Math.Round(value.TotalDays, 1).ToString("0.#", CultureInfo.InvariantCulture)} days"; + } + + private static string FormatPercent(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); + + private static string FormatWindowDuration(int minutes) + { + if (minutes == (int)TimeSpan.FromHours(5).TotalMinutes) + { + return "5-hour"; + } + + if (minutes == (int)TimeSpan.FromDays(7).TotalMinutes) + { + return "weekly"; + } + + if (minutes > 0 && minutes % (int)TimeSpan.FromDays(1).TotalMinutes == 0) + { + return $"{minutes / (int)TimeSpan.FromDays(1).TotalMinutes}-day"; + } + + if (minutes > 0 && minutes % 60 == 0) + { + return $"{minutes / 60}-hour"; + } + + return $"{minutes}-minute"; + } + + private static string FormatUtc(DateTimeOffset value) => + value.ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture); + + private static UsagePlanningResult Unavailable( + DateTimeOffset desiredEnd, + string status, + int? remainingWorkdays) => + new(false, status, desiredEnd, remainingWorkdays, null, null, Disclaimer); +} + +internal sealed record UsagePlanningResult( + bool IsAvailable, + string Status, + DateTimeOffset DesiredEnd, + int? RequestedWorkdays, + UsagePlanningWindow? Primary, + UsagePlanningWindow? Weekly, + string Disclaimer) +{ + internal UsagePlanningWindow? FiveHour => Primary; + + internal IReadOnlyList Windows => + new[] { Primary, Weekly }.Where(window => window is not null).Cast().ToArray(); + + internal IReadOnlyList Evidence => + Windows.Select(window => window.Evidence).ToArray(); + + internal IReadOnlyList Backtests => + Windows.Select(window => window.Backtest).ToArray(); +} + +internal sealed record UsagePlanningWindow( + string Label, + double RemainingPercent, + int WindowMinutes, + DateTimeOffset ResetsAt, + DateTimeOffset HorizonEnd, + int Workdays, + TimeSpan Horizon, + double AvailablePointsPerWorkday, + double AvailablePointsPerHour, + bool ResetsBeforeDesiredEnd, + string HorizonMessage, + UsagePlanningForecast Forecast, + UsagePlanningEvidence Evidence, + UsagePlanningBacktest Backtest) +{ + internal double Remaining => RemainingPercent; + internal double QuotaPointsPerWorkday => AvailablePointsPerWorkday; + internal double QuotaPointsPerHour => AvailablePointsPerHour; +} + +internal sealed record UsagePlanningForecast( + bool IsAvailable, + UsageTrendForecast? Projection, + string Status) +{ + internal UsageTrendForecast? Forecast => Projection; + internal DateTimeOffset? EndsAt => Projection?.EndsAt; + internal bool ReachesLimitBeforeReset => Projection?.ReachesLimitBeforeReset ?? false; + + internal static UsagePlanningForecast Unavailable(string status) => new(false, null, status); +} + +internal sealed record UsagePlanningEvidence( + int MeasurementCount, + TimeSpan? MeasurementSpan, + int SegmentMeasurementCount, + TimeSpan? SegmentSpan, + int AdaptiveCycleCount, + string Summary) +{ + internal string Description => Summary; +} + +internal sealed record UsagePlanningBacktest( + bool IsAvailable, + int TrainingSampleCount, + DateTimeOffset? HeldOutAt, + double? PredictedRemainingPercent, + double? ActualRemainingPercent, + double? AbsoluteErrorPercentagePoints, + string Status) +{ + internal string Message => Status; + internal static UsagePlanningBacktest Unavailable(string status) => new(false, 0, null, null, null, null, status); +} diff --git a/PRIVACY.md b/PRIVACY.md index 31650cd..ac7cd4b 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -16,7 +16,7 @@ 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. +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 display, refresh, planning, retention, forecasting, and explicit source 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. @@ -24,6 +24,10 @@ Account-scoped history uses a one-way hash of the account identity supplied by C Optional source preferences store the user-selected executable and Codex home paths locally in settings. These paths are not included in diagnostic reports or logs. Account activity requests travel through the local Codex app-server and retain only aggregate daily token counts and optional totals in memory. Identity is checked before and after each request. Optional usage notifications contain a quota label and bounded status text, without account identifiers; their deduplication state remains in memory. Disabling account activity clears the visible account activity state and stops new optional reads. +Optional retained history stores at most 27,000 aggregate quota observations with UTC timestamps and reset times, separated by hashed account/category context. The selected retention is 7, 30, or 90 days; collection starts only after opting in. Explicit CSV/JSON exports create local files without account IDs or conversation content. Users control deletion of retained observations and exported copies separately. Neither retained observations nor exports contain prompts or task contents. + +An explicitly requested task-usage read passes the user-entered task ID through the local Codex app-server and keeps the resulting aggregate estimates only in memory. A confirmed earned-reset action sends a mutation through that server. Before sending, the extension stores a random request ID in the account's hashed local context. An unresolved ID is retained across restarts so a retry cannot accidentally become a separate redemption. Recovery records contain no authentication credentials or raw account identifiers and are separate from history deletion. + ## Permissions The Windows `runFullTrust` capability is required to run the packaged Command Palette COM server and to communicate with the locally installed Codex process. It is not used to bypass Windows security controls or access unrelated user data. diff --git a/README.md b/README.md index 791e29c..a333e1c 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,16 @@ Settings accepts an optional full path to a standalone `codex.exe` or `codex.cmd **Codex account activity** shows account-wide token summaries and up to 30 recent server-calendar days when `account/usage/read` is supported. It updates independently after quota data, at most every five minutes automatically; unsupported versions retry after 30 minutes. **Refresh now** on that page requests an immediate retry. Disable **Show account activity** to stop these optional reads. Account identity must match before and after the request. Missing days and fields are not zero usage, and the server's unspecified calendar time zone is kept separate from local calendar-day chart bars. No account activity is written to disk by this feature. +## History, planning, and optional account actions + +**Codex usage history** retains quota observations only when **Retain usage observations** is set to 7, 30, or 90 days. Observations are scoped to the identified account and default quota category, sampled in five-minute buckets, and capped at 27,000 rows. Reset changes within a bucket remain separate observations. Pausing collection keeps retained data; the history page offers confirmed deletion for the selected context. CSV and JSON export actions write files to the extension's local application data `exports` folder and show the resulting path. Exports contain quota percentages and UTC observation/reset times, without account IDs or conversation content. Exported copies are not deleted when retained history is cleared. + +**Codex workday planner** uses today's local **Workday end** and your chosen **Workdays remaining before weekly reset**. It divides remaining weekly allowance across those days and today's remaining hours; the five-hour allowance is budgeted independently. Planning stops at an earlier reset and pauses after today's chosen end. It requires fresh, identified live data. The page explains the measurement span, continuous segment, learned-cycle count, and a held-out latest-observation check where sufficient data exists. This is descriptive evidence, not a calibrated confidence percentage or a guarantee. Its recent-pace estimate is separate from the dashboard's optional adaptive weekly forecast. + +**Codex task usage and earned resets** accepts an explicit task ID for `account/usage/read` on compatible CLI versions. It shows server-estimated credits and optional USD, plus model/effort/speed and available input/cached/output token groups. These estimates are not invoices or conversions of quota percentages. Task reads verify account identity before and after, keep the most recently requested task, and retain results only in memory. + +The same page offers **Use or retry an earned reset**, which always asks for confirmation. It only uses an existing earned reset, never purchases one, and verifies the expected account before the mutation. A request ID is saved locally before sending. Unknown outcomes retain that exact ID across retries and extension restarts; concurrent clicks share the same attempt. An unreadable or unwritable recovery record stops the request. Only an unambiguous server outcome clears the pending record, and limits are refreshed afterward. This feature has synthetic protocol and service tests; no real credit was consumed while developing it. + ## Uninstall Remove **Codex Usage Dock** from **Windows Settings > Apps > Installed apps**. diff --git a/SPRINTS.md b/SPRINTS.md index c017faf..2518930 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -5,8 +5,8 @@ This series implements the recommended Codex-first roadmap, followed by a small, | Sprint | Feature branch | Scope | Status | | --- | --- | --- | --- | | 1 | `codex/sprint-1-reliable-usage` | Modern quota categories, consistent freshness, last confirmed data, account-scoped history, safe diagnostics, version communication | [PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18); 186 native ARM64 tests passed; x64/ARM64 Debug builds passed | -| 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | [PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19); x64/ARM64 compilation passed; GitHub test validation pending | -| 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | Planned | +| 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | [PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19); 235 x64 tests, both architecture builds, and package validation passed in CI | +| 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | Implemented; 314 native ARM64 tests and both architecture builds passed | | 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | Planned | Later sprints build on the previous feature branch so each PR can show only its own increment. Merge in sprint order and retarget dependent PRs to `main` after their base is merged. No merge is performed as part of this implementation request. @@ -30,4 +30,10 @@ The final sprint 1 head also passed [GitHub Actions](https://github.com/TheBeems ### Sprint 2 verification -The x64 and ARM64 application code compiles without warnings. Local ARM64 test execution was blocked while loading the assembly by Windows Application Control (`0x800711C7`), including a retry with elevated execution. This is an execution gap, not a passing test result. No Windows security policy was changed. The existing GitHub workflow will run the synthetic tests and both architecture/package checks on the pushed feature branch. Live account activity, notifications, individual pinning, and configuration changes still require Command Palette verification after an authorized installation. +The x64 and ARM64 application code compiles without warnings. Local ARM64 test execution was blocked while loading the assembly by Windows Application Control (`0x800711C7`), including a retry with elevated execution. No Windows security policy was changed. [GitHub validation](https://github.com/TheBeems/CodexUsageDock/actions/runs/34383095096) passed all 235 tests, both architecture builds, and package checks on head `d067295`. Live account activity, notifications, individual pinning, and configuration changes still require Command Palette verification after an authorized installation. + +### Sprint 3 verification + +Native ARM64 test execution was available again and passed 314 tests. Both application architecture builds passed without warnings. Tests cover retention/export scope, planner assumptions and held-out observations, optional protocol support, task-request ordering, and persistent reset idempotency across ambiguous results and restarts. No real reset was redeemed. Command Palette forms, confirmations, and real-account compatibility still need live verification after an authorized installation. The GitHub workflow validates the pushed branch separately. + +Both integration preflights passed source/generated manifest, identity, asset, and self-contained runtime checks. The registered ARM64 Store package was healthy and discoverable, but its process and registration do not point at the new Debug builds. These expected mismatches leave live verification of the new code open; no registration or installation was changed. From ebe7efe59719f3f75d78e055d6a0ed37121da48d Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:49:11 +0200 Subject: [PATCH 2/3] Link sprint 3 features to pull request 20 --- CHANGELOG.md | 6 +++--- SPRINTS.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3162838..9a71a60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,9 @@ Each entry links to the commit or pull request that introduced the change. ### Added -- Optional account-scoped quota history with 7/30/90-day retention, explicit CSV/JSON exports, and confirmed deletion. -- A workday planner with per-day and per-hour quota budgets, measurement evidence, and held-out recent-pace checks. -- Task-level server usage estimates and explicitly confirmed earned resets with account verification and persistent request IDs for safe retries. +- Optional account-scoped quota history with 7/30/90-day retention, explicit CSV/JSON exports, and confirmed deletion. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) +- A workday planner with per-day and per-hour quota budgets, measurement evidence, and held-out recent-pace checks. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) +- Task-level server usage estimates and explicitly confirmed earned resets with account verification and persistent request IDs for safe retries. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Optional quiet usage alerts from fresh, identified accounts, compact Dock labels, and separate pinnable quota and credit entries with stable identifiers. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Account-wide daily token activity on compatible Codex versions, with independent refresh and account-identity verification. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Explicit executable and Codex home settings, with invalid-source errors and protection against results from a previous profile. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) diff --git a/SPRINTS.md b/SPRINTS.md index 2518930..f8ad822 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -6,7 +6,7 @@ This series implements the recommended Codex-first roadmap, followed by a small, | --- | --- | --- | --- | | 1 | `codex/sprint-1-reliable-usage` | Modern quota categories, consistent freshness, last confirmed data, account-scoped history, safe diagnostics, version communication | [PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18); 186 native ARM64 tests passed; x64/ARM64 Debug builds passed | | 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | [PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19); 235 x64 tests, both architecture builds, and package validation passed in CI | -| 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | Implemented; 314 native ARM64 tests and both architecture builds passed | +| 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | [PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20); 314 native ARM64 tests and both architecture builds passed | | 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | Planned | Later sprints build on the previous feature branch so each PR can show only its own increment. Merge in sprint order and retarget dependent PRs to `main` after their base is merged. No merge is performed as part of this implementation request. From 7c63ff2ad0ae1543dac0677c050ce90938031216 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:02:02 +0200 Subject: [PATCH 3/3] fix: calculate planner budgets in the local time zone --- CHANGELOG.md | 1 + CodexUsageDock.Tests/UsagePlanningTests.cs | 19 +++++++++++++++++++ CodexUsageDock/UsagePlanning.cs | 18 +++++++++++------- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a71a60..c8132f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Each entry links to the commit or pull request that introduced the change. ### Fixed +- Calculate planner workday budgets from reset and current dates in the same local time zone. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) diff --git a/CodexUsageDock.Tests/UsagePlanningTests.cs b/CodexUsageDock.Tests/UsagePlanningTests.cs index d20d5ba..10fdcde 100644 --- a/CodexUsageDock.Tests/UsagePlanningTests.cs +++ b/CodexUsageDock.Tests/UsagePlanningTests.cs @@ -64,6 +64,25 @@ public void WeeklyWorkdayAssumptionAffectsDailyAndHourlyBudget() Assert.Equal(10, oneDayPlan.Weekly.AvailablePointsPerHour, precision: 8); } + [Theory] + [InlineData(2, 23, 3)] + [InlineData(-7, 1, 1)] + public void WeeklyBudgetCapsWorkdaysUsingLocalCalendarDates(int offsetHours, int resetHour, int expectedDays) + { + var timeZone = TimeZoneInfo.CreateCustomTimeZone("Planning test", TimeSpan.FromHours(offsetHours), "Planning test", "Planning test"); + var reset = new DateTimeOffset(2026, 9, 11, resetHour, 0, 0, TimeSpan.Zero); + var presentation = Presentation(primaryRemaining: null, secondaryRemaining: 60, secondaryReset: reset); + + var plan = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now.AddHours(5), 7, timeZone); + var equivalentOffsetPlan = UsagePlanner.Plan( + presentation, Now.ToOffset(TimeSpan.FromHours(14)), RefreshInterval, Now.AddHours(5), 7, timeZone); + + Assert.Equal(expectedDays, plan.Weekly!.Workdays); + Assert.Equal(60d / expectedDays, plan.Weekly.AvailablePointsPerWorkday, precision: 8); + Assert.Equal(60d / expectedDays / 5, plan.Weekly.AvailablePointsPerHour, precision: 8); + Assert.Equal(plan.Weekly.Workdays, equivalentOffsetPlan.Weekly!.Workdays); + } + [Fact] public void InvalidEndOrWorkdayCountReturnsAnExplanation() { diff --git a/CodexUsageDock/UsagePlanning.cs b/CodexUsageDock/UsagePlanning.cs index 2f5fb97..70fad83 100644 --- a/CodexUsageDock/UsagePlanning.cs +++ b/CodexUsageDock/UsagePlanning.cs @@ -11,7 +11,8 @@ internal static UsagePlanningResult Plan( DateTimeOffset now, TimeSpan refreshInterval, DateTimeOffset desiredEnd, - int? remainingWorkdays = null) + int? remainingWorkdays = null, + TimeZoneInfo? timeZone = null) { ArgumentNullException.ThrowIfNull(presentation); @@ -80,7 +81,8 @@ internal static UsagePlanningResult Plan( remainingWorkdays, maximumSampleAge, adaptiveCycleCount: 0, - isPrimary: true); + isPrimary: true, + timeZone ?? TimeZoneInfo.Local); var weekly = CreateWindowPlan( snapshot.Secondary, presentation.WeeklyHistory, @@ -89,7 +91,8 @@ internal static UsagePlanningResult Plan( remainingWorkdays, maximumSampleAge, CountAdaptiveCycles(presentation), - isPrimary: false); + isPrimary: false, + timeZone ?? TimeZoneInfo.Local); if (primary is null && weekly is null) { @@ -130,7 +133,8 @@ internal static UsagePlanningResult Plan( int? requestedWorkdays, TimeSpan maximumSampleAge, int adaptiveCycleCount, - bool isPrimary) + bool isPrimary, + TimeZoneInfo timeZone) { if (!UsageFreshness.IsValidWindow(window, now)) { @@ -147,7 +151,7 @@ internal static UsagePlanningResult Plan( var workdays = isPrimary ? 1 - : Math.Max(1, Math.Min(requestedWorkdays ?? 1, CalendarDaysUntilReset(validWindow.ResetsAt, now))); + : Math.Max(1, Math.Min(requestedWorkdays ?? 1, CalendarDaysUntilReset(validWindow.ResetsAt, now, timeZone))); var remaining = validWindow.RemainingPercent; var pointsPerWorkday = remaining / workdays; var pointsPerHour = pointsPerWorkday / horizon.TotalHours; @@ -413,9 +417,9 @@ private static int CountAdaptiveCycles(UsagePresentation presentation) return completed + (history.ActiveCycle is null ? 0 : 1); } - private static int CalendarDaysUntilReset(DateTimeOffset resetsAt, DateTimeOffset now) + private static int CalendarDaysUntilReset(DateTimeOffset resetsAt, DateTimeOffset now, TimeZoneInfo timeZone) { - var days = (resetsAt.Date - now.Date).Days; + var days = (TimeZoneInfo.ConvertTime(resetsAt, timeZone).Date - TimeZoneInfo.ConvertTime(now, timeZone).Date).Days; if (days <= 1) { return 1;