Merge task branch for: Worker: OAuth-Usage-Client + Poller (Usage/)
This commit is contained in:
@@ -21,6 +21,7 @@ Worker/
|
||||
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
|
||||
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
|
||||
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (OIDC discovery + refresh-token flow), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
|
||||
Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message); interface in Usage/Interfaces/ (IUsageClient)
|
||||
```
|
||||
|
||||
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
|
||||
@@ -180,6 +181,7 @@ Loaded from `~/.todo-app/worker.config.json`:
|
||||
- `poll_interval_seconds` (int, default 60)
|
||||
- `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` — used by `ZitadelAuthProvider` (OIDC discovery + refresh-token flow)
|
||||
- The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token`
|
||||
- `usage_poll_interval_seconds` (default 60, clamped to a minimum of 15 on load) — poll interval for `UsageMonitorService`
|
||||
|
||||
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order).
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ public sealed class WorkerConfig
|
||||
[JsonPropertyName("online_inbox")]
|
||||
public OnlineInboxConfig OnlineInbox { get; set; } = new();
|
||||
|
||||
/// <summary>Poll interval for the OAuth usage monitor. Clamped to a minimum of 15s on load.</summary>
|
||||
[JsonPropertyName("usage_poll_interval_seconds")]
|
||||
public int UsagePollIntervalSeconds { get; set; } = 60;
|
||||
|
||||
public static string DefaultConfigPath =>
|
||||
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||
|
||||
@@ -71,6 +75,7 @@ public sealed class WorkerConfig
|
||||
cfg.SandboxRoot = Paths.Expand(cfg.SandboxRoot);
|
||||
cfg.LogRoot = Paths.Expand(cfg.LogRoot);
|
||||
cfg.CentralWorktreeRoot = Paths.Expand(cfg.CentralWorktreeRoot);
|
||||
cfg.UsagePollIntervalSeconds = Math.Max(15, cfg.UsagePollIntervalSeconds);
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ using ClaudeDo.Worker.Refine;
|
||||
using ClaudeDo.Worker.Report;
|
||||
using ClaudeDo.Worker.Report.Interfaces;
|
||||
using ClaudeDo.Worker.Skills;
|
||||
using ClaudeDo.Worker.Usage;
|
||||
using ClaudeDo.Worker.Usage.Interfaces;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
@@ -193,6 +195,15 @@ if (cfg.OnlineInbox.Enabled)
|
||||
builder.Services.AddHostedService<OnlineSyncService>();
|
||||
}
|
||||
|
||||
// OAuth usage monitor: reads the access token Claude Code keeps fresh in
|
||||
// ~/.claude/.credentials.json and polls Anthropic's usage endpoint.
|
||||
builder.Services.AddSingleton<UsageState>();
|
||||
builder.Services.AddHttpClient<IUsageClient, ClaudeOAuthUsageClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
builder.Services.AddHostedService<UsageMonitorService>();
|
||||
|
||||
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
|
||||
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Worker.Usage.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Worker.Usage;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the current OAuth usage/limit snapshot from Anthropic using the access token
|
||||
/// Claude Code keeps fresh in <c>~/.claude/.credentials.json</c>. Never refreshes the token
|
||||
/// itself, and never logs or surfaces the token value.
|
||||
/// </summary>
|
||||
public sealed class ClaudeOAuthUsageClient : IUsageClient
|
||||
{
|
||||
private const string UsageUrl = "https://api.anthropic.com/api/oauth/usage";
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _credentialsPath;
|
||||
|
||||
public ClaudeOAuthUsageClient(HttpClient http)
|
||||
: this(http, Path.Combine(Paths.Expand("~/.claude"), ".credentials.json")) { }
|
||||
|
||||
internal ClaudeOAuthUsageClient(HttpClient http, string credentialsPath)
|
||||
{
|
||||
_http = http;
|
||||
_credentialsPath = credentialsPath;
|
||||
}
|
||||
|
||||
public async Task<UsageSnapshot> GetUsageAsync(CancellationToken ct = default)
|
||||
{
|
||||
var token = ReadAccessToken();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, UsageUrl);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
request.Headers.Add("anthropic-beta", "oauth-2025-04-20");
|
||||
|
||||
using var response = await _http.SendAsync(request, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException($"Usage request failed with status {(int)response.StatusCode}.");
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
return Parse(body);
|
||||
}
|
||||
|
||||
private string ReadAccessToken()
|
||||
{
|
||||
if (!File.Exists(_credentialsPath))
|
||||
throw new InvalidOperationException($"Claude credentials file not found at '{_credentialsPath}'.");
|
||||
|
||||
string json;
|
||||
try
|
||||
{
|
||||
json = File.ReadAllText(_credentialsPath);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to read Claude credentials file: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
JsonNode? root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(json);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to parse Claude credentials file: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
var token = root?["claudeAiOauth"]?["accessToken"]?.GetValue<string>();
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
throw new InvalidOperationException("Claude credentials file has no accessToken.");
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defensive parse of the usage response: missing/null buckets become null, missing
|
||||
/// <c>limits</c> becomes an empty list, and unknown fields are ignored. Only an
|
||||
/// unparsable body throws.
|
||||
/// </summary>
|
||||
internal static UsageSnapshot Parse(string json)
|
||||
{
|
||||
JsonNode? root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(json);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to parse usage response: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
if (root is not JsonObject obj)
|
||||
throw new InvalidOperationException("Usage response was not a JSON object.");
|
||||
|
||||
try
|
||||
{
|
||||
var fiveHour = ParseBucket(obj["five_hour"]);
|
||||
var sevenDay = ParseBucket(obj["seven_day"]);
|
||||
var limits = ParseLimits(obj["limits"] as JsonArray);
|
||||
|
||||
return new UsageSnapshot(fiveHour, sevenDay, limits, DateTime.UtcNow);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to parse usage response: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static UsageBucket? ParseBucket(JsonNode? node)
|
||||
{
|
||||
if (node is not JsonObject obj)
|
||||
return null;
|
||||
|
||||
var utilization = TryGetDouble(obj["utilization"]) ?? 0;
|
||||
var resetsAt = TryGetDateTimeOffset(obj["resets_at"]);
|
||||
return new UsageBucket(utilization, resetsAt);
|
||||
}
|
||||
|
||||
private static List<UsageLimitRow> ParseLimits(JsonArray? array)
|
||||
{
|
||||
var result = new List<UsageLimitRow>();
|
||||
if (array is null)
|
||||
return result;
|
||||
|
||||
foreach (var item in array)
|
||||
{
|
||||
if (item is not JsonObject row)
|
||||
continue;
|
||||
|
||||
var kind = TryGetString(row["kind"]) ?? "";
|
||||
var group = TryGetString(row["group"]) ?? "";
|
||||
var percent = TryGetDouble(row["percent"]) ?? 0;
|
||||
var severity = TryGetString(row["severity"]) ?? "";
|
||||
var resetsAt = TryGetDateTimeOffset(row["resets_at"]);
|
||||
var scopeModelDisplayName = TryGetString((row["scope"] as JsonObject)?["model"]?["display_name"]);
|
||||
var isActive = TryGetBool(row["is_active"]) ?? false;
|
||||
|
||||
result.Add(new UsageLimitRow(kind, group, percent, severity, resetsAt, scopeModelDisplayName, isActive));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? TryGetString(JsonNode? node) =>
|
||||
node is JsonValue value && value.TryGetValue(out string? s) ? s : null;
|
||||
|
||||
private static double? TryGetDouble(JsonNode? node) =>
|
||||
node is JsonValue value && value.TryGetValue(out double d) ? d : null;
|
||||
|
||||
private static bool? TryGetBool(JsonNode? node) =>
|
||||
node is JsonValue value && value.TryGetValue(out bool b) ? b : null;
|
||||
|
||||
private static DateTimeOffset? TryGetDateTimeOffset(JsonNode? node) =>
|
||||
node is JsonValue value && value.TryGetValue(out string? s) && DateTimeOffset.TryParse(s, out var dto)
|
||||
? dto
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ClaudeDo.Worker.Usage.Interfaces;
|
||||
|
||||
public interface IUsageClient
|
||||
{
|
||||
Task<UsageSnapshot> GetUsageAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ClaudeDo.Worker.Usage;
|
||||
|
||||
public sealed record UsageBucket(double Utilization, DateTimeOffset? ResetsAt);
|
||||
|
||||
public sealed record UsageLimitRow(
|
||||
string Kind,
|
||||
string Group,
|
||||
double Percent,
|
||||
string Severity,
|
||||
DateTimeOffset? ResetsAt,
|
||||
string? ScopeModelDisplayName,
|
||||
bool IsActive);
|
||||
|
||||
public sealed record UsageSnapshot(
|
||||
UsageBucket? FiveHour,
|
||||
UsageBucket? SevenDay,
|
||||
IReadOnlyList<UsageLimitRow> Limits,
|
||||
DateTime FetchedAtUtc);
|
||||
@@ -0,0 +1,68 @@
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Usage.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Worker.Usage;
|
||||
|
||||
/// <summary>
|
||||
/// Polls <see cref="IUsageClient"/> on <see cref="WorkerConfig.UsagePollIntervalSeconds"/> and keeps
|
||||
/// <see cref="UsageState"/> current. Polls once immediately at startup. A failure is logged as a
|
||||
/// warning at most once per distinct error message, to avoid log spam on a persistent outage.
|
||||
/// </summary>
|
||||
public sealed class UsageMonitorService : BackgroundService
|
||||
{
|
||||
private readonly IUsageClient _client;
|
||||
private readonly UsageState _state;
|
||||
private readonly WorkerConfig _config;
|
||||
private readonly ILogger<UsageMonitorService> _logger;
|
||||
private string? _lastLoggedError;
|
||||
|
||||
public UsageMonitorService(
|
||||
IUsageClient client, UsageState state, WorkerConfig config, ILogger<UsageMonitorService> logger)
|
||||
{
|
||||
_client = client;
|
||||
_state = state;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await TickAsync(stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(_config.UsagePollIntervalSeconds), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task TickAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = await _client.GetUsageAsync(ct);
|
||||
_state.ReportSuccess(snapshot);
|
||||
_lastLoggedError = null;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_state.ReportFailure(ex.Message, DateTime.UtcNow);
|
||||
|
||||
if (_lastLoggedError != ex.Message)
|
||||
{
|
||||
_logger.LogWarning(ex, "UsageMonitorService: failed to fetch usage: {Message}", ex.Message);
|
||||
_lastLoggedError = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace ClaudeDo.Worker.Usage;
|
||||
|
||||
/// <summary>
|
||||
/// Threadsafe holder for the last successful usage snapshot. A failed poll never overwrites
|
||||
/// a good snapshot — it only records <see cref="LastError"/>, leaving the existing snapshot stale.
|
||||
/// </summary>
|
||||
public sealed class UsageState
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private UsageSnapshot? _snapshot;
|
||||
private DateTime? _lastAttemptUtc;
|
||||
private string? _lastError;
|
||||
|
||||
public UsageSnapshot? Snapshot
|
||||
{
|
||||
get { lock (_lock) return _snapshot; }
|
||||
}
|
||||
|
||||
public DateTime? LastAttemptUtc
|
||||
{
|
||||
get { lock (_lock) return _lastAttemptUtc; }
|
||||
}
|
||||
|
||||
public string? LastError
|
||||
{
|
||||
get { lock (_lock) return _lastError; }
|
||||
}
|
||||
|
||||
public void ReportSuccess(UsageSnapshot snapshot)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_snapshot = snapshot;
|
||||
_lastAttemptUtc = snapshot.FetchedAtUtc;
|
||||
_lastError = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void ReportFailure(string error, DateTime attemptedAtUtc)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_lastAttemptUtc = attemptedAtUtc;
|
||||
_lastError = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user