From 44337d4b94e7b8e1623c0b93dad2d8b13d6adf20 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 24 Aug 2026 13:41:21 +0200 Subject: [PATCH] feat(usage): serve model and task usage from the TokenTracker export --- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 141 +++++++++++++++++++++++++-- src/ClaudeDo.Worker/Program.cs | 8 ++ 2 files changed, 139 insertions(+), 10 deletions(-) diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index dfaed606..99df0591 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -19,6 +19,7 @@ using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.State; using ClaudeDo.Worker.Usage; using ClaudeDo.Worker.Usage.Interfaces; +using ClaudeDo.Worker.Usage.TokenTracker; using ClaudeDo.Worker.Worktrees; using System.Text.Json; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -166,7 +167,8 @@ public record ModelUsageRowDto( long OutputTokens, long CacheReadTokens, long CacheCreationTokens, - int Messages); + int Messages, + double? CostUsd = null); public record TaskUsageRowDto( string TaskId, @@ -176,7 +178,21 @@ public record TaskUsageRowDto( string? Model, int Runs, long TokensIn, - long TokensOut); + long TokensOut, + double? CostUsd = null, + int? Retries = null, + bool? Productive = null, + bool? OneShot = null); + +public record TokenTrackerStatusDto( + bool Installed, + string? Version, + bool NodeOk, + string? NodeVersion, + DateTime? LastFetchedUtc, + string? LastError, + int? FormatVersion, + int SessionCount); public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub { @@ -228,6 +244,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub private readonly UsageSnapshotBuilder? _usageSnapshotBuilder; private readonly ITranscriptUsageReader? _usageReader; private readonly UsageMonitorService? _usageMonitor; + private readonly TokenTrackerService? _tokenTracker; private readonly InteractiveReviewSubmissionService? _interactiveReviewSubmission; public WorkerHub( @@ -262,7 +279,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub UsageSnapshotBuilder? usageSnapshotBuilder = null, ITranscriptUsageReader? usageReader = null, UsageMonitorService? usageMonitor = null, - InteractiveReviewSubmissionService? interactiveReviewSubmission = null) + InteractiveReviewSubmissionService? interactiveReviewSubmission = null, + TokenTrackerService? tokenTracker = null) { _queue = queue; _waker = waker; @@ -296,6 +314,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub _usageReader = usageReader; _usageMonitor = usageMonitor; _interactiveReviewSubmission = interactiveReviewSubmission; + _tokenTracker = tokenTracker; } // Persistence boundary for the session_skills JSON-array columns (task/list/global). @@ -1177,18 +1196,108 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub return _usageMonitor.RefreshNowAsync(Context.ConnectionAborted); }); + public Task GetTokenTrackerStatus() => HubGuard(async () => + { + if (_tokenTracker is null) + return new TokenTrackerStatusDto(false, null, false, null, null, + "TokenTracker is not configured on this worker.", null, 0); + + var probe = await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted); + return BuildTokenTrackerStatus(probe); + }); + + public Task RefreshTokenTracker() => HubGuard(async () => + { + if (_tokenTracker is null) + throw new InvalidOperationException("TokenTracker is not configured."); + + await _tokenTracker.RefreshAsync(Context.ConnectionAborted); + return BuildTokenTrackerStatus(await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted)); + }); + + public Task InstallTokenTracker() => HubGuard(async () => + { + if (_tokenTracker is null) + throw new InvalidOperationException("TokenTracker is not configured."); + + // npm writes its progress to stderr; forwarding it line by line is what makes a + // multi-minute global install visible in the footer log strip instead of looking hung. + var progress = new Progress(line => + _ = _broadcaster.WorkerLog(line, WorkerLogLevel.Info, DateTime.UtcNow)); + + var result = await _tokenTracker.InstallAsync(progress, Context.ConnectionAborted); + if (!result.Ok) + await _broadcaster.WorkerLog( + result.Error ?? "TokenTracker install failed.", WorkerLogLevel.Error, DateTime.UtcNow); + + return BuildTokenTrackerStatus(await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted)); + }); + + private TokenTrackerStatusDto BuildTokenTrackerStatus(TokenTrackerProbe probe) + { + var state = _tokenTracker!.State; + return new TokenTrackerStatusDto( + probe.Installed, + probe.Version, + probe.NodeOk, + probe.NodeVersion, + state.Export?.FetchedAtUtc, + state.LastError ?? probe.Error, + state.Export?.FormatVersion, + state.Export?.Sessions.Count ?? 0); + } + + /// + /// Model breakdown from the cached TokenTracker export. With no export at all we wait for one + /// (the caller already shows a spinner); with a stale one we serve it and refresh behind the + /// caller's back, because blocking a range switch on a fetch is worse than showing a + /// timestamped older number. + /// public Task> GetModelUsage(DateOnly from, DateOnly to) => HubGuard(async () => { - if (_usageReader is null) - throw new InvalidOperationException("Transcript usage reader is not configured."); - var rows = await _usageReader.ReadAsync(from, to, Context.ConnectionAborted); - return (IReadOnlyList)rows + if (_tokenTracker is null) + return (IReadOnlyList)Array.Empty(); + + if (_tokenTracker.State.Export is null) + await _tokenTracker.EnsureFreshAsync(TimeSpan.Zero, Context.ConnectionAborted); + else + _ = _tokenTracker.EnsureFreshAsync(TimeSpan.FromMinutes(15), CancellationToken.None); + + var export = _tokenTracker.State.Export; + if (export is null) + return (IReadOnlyList)Array.Empty(); + + var claudeDoHashes = await ClaudeDoSessionHashesAsync(); + + return (IReadOnlyList)TokenTrackerAggregator + .ByModel(export.Sessions, from, to, claudeDoHashes) .Select(r => new ModelUsageRowDto( - r.Date, r.Model, r.Scope == UsageScope.ClaudeDo ? "claudedo" : "other", - r.InputTokens, r.OutputTokens, r.CacheReadTokens, r.CacheCreationTokens, r.Messages)) + r.Date, r.Model, r.Scope, + r.InputTokens, r.OutputTokens, r.CacheReadTokens, r.CacheCreationTokens, + r.Sessions, r.CostUsd)) .ToList(); }); + /// Hashes of every session one of our runs owns — the scope split rides on set + /// membership rather than the export's own project_key, which falls back to a bare + /// worktree GUID for exactly the sessions we care about. Note this is narrower than the old + /// cwd-based split: an interactive/planning session in a worktree has no task_runs row + /// and therefore counts as "other". + private async Task> ClaudeDoSessionHashesAsync() + { + await using var ctx = await _dbFactory.CreateDbContextAsync(Context.ConnectionAborted); + var sessionIds = await ctx.TaskRuns + .Where(r => r.SessionId != null) + .Select(r => r.SessionId!) + .Distinct() + .ToListAsync(Context.ConnectionAborted); + + return sessionIds + .Select(SessionHash.ForClaudeSession) + .Where(h => h is not null) + .ToHashSet()!; + } + public async Task> GetTaskUsage(DateOnly from, DateOnly to) { var fromDt = from.ToDateTime(TimeOnly.MinValue); @@ -1208,6 +1317,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var listIds = tasks.Select(t => t.ListId).Distinct().ToList(); var lists = await ctx.Lists.Where(l => listIds.Contains(l.Id)).ToDictionaryAsync(l => l.Id, Context.ConnectionAborted); + // Cost/retries are an enrichment, not a dependency: GetModelUsage is what triggers a + // refresh, so a missing export here just means the extras columns stay empty. + var export = _tokenTracker?.State.Export; + return runs .Where(r => taskById.ContainsKey(r.TaskId)) .GroupBy(r => r.TaskId) @@ -1216,6 +1329,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var task = taskById[g.Key]; var listName = lists.TryGetValue(task.ListId, out var list) ? list.Name : ""; var latestModel = g.OrderByDescending(r => r.StartedAt).First().Model; + var extras = export is null + ? null + : TokenTrackerAggregator.ForSessions(export.Sessions, g.Select(r => r.SessionId)); + return new TaskUsageRowDto( task.Id, task.Title, @@ -1224,7 +1341,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub latestModel, g.Count(), g.Sum(r => (long)(r.TokensIn ?? 0)), - g.Sum(r => (long)(r.TokensOut ?? 0))); + g.Sum(r => (long)(r.TokensOut ?? 0)), + extras?.CostUsd, + extras?.Retries, + extras?.Productive, + extras?.OneShot); }) .OrderByDescending(r => r.TokensIn + r.TokensOut) .Take(100) diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 2334fbc9..43cb445a 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -23,6 +23,8 @@ using ClaudeDo.Worker.Report.Interfaces; using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.Usage; using ClaudeDo.Worker.Usage.Interfaces; +using ClaudeDo.Worker.Usage.TokenTracker; +using ClaudeDo.Worker.Usage.TokenTracker.Interfaces; using ClaudeDo.Worker.Worktrees; using Microsoft.EntityFrameworkCore; using Serilog; @@ -143,6 +145,12 @@ builder.Services.AddSingleton(); // Usage builder.Services.AddSingleton(); +// TokenTracker — the analytics backend. Optional at runtime: every consumer degrades when the +// CLI is missing, so nothing here is a startup requirement. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + // Prime Claude builder.Services.AddSingleton(); builder.Services.AddSingleton();