2680 lines
94 KiB
Markdown
2680 lines
94 KiB
Markdown
# TokenTracker Usage-Analytics Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Die Token-Auswertung (Breakdown pro Modell und pro Task, Total Tokens, neu auch Kosten in USD) kommt aus dem lokalen `tokentracker sessions`-Export statt aus unserem eigenen Transkript-Scanner; Limits, Gate und Throttle bleiben unverändert bei unserem OAuth-Poll.
|
||
|
||
**Architecture:** Ein neuer Ordner `src/ClaudeDo.Worker/Usage/TokenTracker/` kapselt Prozess-Aufruf, Parsing, Caching und Aggregation. Der Kern ist bewusst pure: `SessionHash`, `TokenTrackerArgs`, `TokenTrackerExportParser` und `TokenTrackerAggregator` haben keine I/O und sind voll testbar; nur `TokenTrackerClient` (Prozess) und `TokenTrackerService` (Cache-Koordination) berühren die Außenwelt. `WorkerHub.GetModelUsage` speist sich aus dem Cache, `GetTaskUsage` bleibt `task_runs`-basiert und wird über den `session_hash`-Join angereichert. Die UI bekommt Kostenspalten plus eine Hinweis-Karte mit Install-Button, wenn TokenTracker fehlt.
|
||
|
||
**Tech Stack:** .NET 8, xUnit, SignalR, Avalonia 12 / CommunityToolkit.Mvvm, `System.Text.Json`, `ClaudeDo.Data.Environment.ExecutableResolver`.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-08-24-tokentracker-usage-analytics-design.md`
|
||
|
||
---
|
||
|
||
## Vorbemerkungen für die umsetzende Person
|
||
|
||
Ein paar Dinge, die im Code nicht sichtbar sind und die dieser Plan voraussetzt:
|
||
|
||
- **Das externe CLI wird in keinem Test gestartet.** Genau wie beim `claude`-CLI ist das eine harte Projektregel. Alle Tests laufen gegen `ITokenTrackerClient`-Fakes oder gegen pure Funktionen.
|
||
- **Fail-open ist das oberste Prinzip im ganzen `Usage/`-Bereich.** Fehlt TokenTracker, bricht der Export ab oder liefert Müll, dann zeigt die App weniger an — sie wirft nie, und ein Fehler überschreibt nie ein zuletzt erfolgreiches Ergebnis.
|
||
- **Das Fixture ist ein C#-Raw-String-Literal, keine Datei.** Grund: `ClaudeDo.Worker.Tests` müsste sonst eine `CopyToOutputDirectory`-Regel in der csproj bekommen, und Dateipfade in Tests sind eine unnötige Fehlerquelle. Ein `const string` in `TokenTrackerFixtures.cs` ist genauso eingecheckt.
|
||
- **Build auf dieser Maschine:** `dotnet build ClaudeDo.slnx` scheitert (braucht .NET 9). Immer einzelne csproj mit `-c Release` bauen — ein laufender Worker sperrt das `Debug`-Output.
|
||
- **Beim Committen niemals `git add -A`.** Das Repo wird von parallelen Sessions benutzt; immer explizit die Pfade angeben, wie in den Commit-Schritten unten geschrieben.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**Neu — Worker:**
|
||
|
||
| Datei | Verantwortung |
|
||
|---|---|
|
||
| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerModels.cs` | `TokenTrackerSession`, `TokenTrackerExport`, `TokenTrackerProbe`, `TokenTrackerRunResult`, `TokenTrackerModelRow`, `TokenTrackerTaskExtras` |
|
||
| `src/ClaudeDo.Worker/Usage/TokenTracker/SessionHash.cs` | Nur die Hash-Formel: Claude-Session-ID → TokenTracker-`session_hash` |
|
||
| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerArgs.cs` | Baut die CLI-Argumentlisten (pure) |
|
||
| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerExportParser.cs` | JSON → `TokenTrackerExport?`, defensiv |
|
||
| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerAggregator.cs` | Range-Filter + Aggregation nach Modell und pro Task (pure) |
|
||
| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerState.cs` | Threadsafe Halter des letzten guten Exports |
|
||
| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs` | Prozess-Aufruf (Export, Probe, npm-Install) |
|
||
| `src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs` | Die Naht für Tests |
|
||
| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerService.cs` | Cache-Koordination: Probe-Cache, Single-Flight-Refresh, 90-Tage-Fenster |
|
||
|
||
**Neu — Tests:**
|
||
|
||
`tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/` mit `TokenTrackerFixtures.cs`,
|
||
`SessionHashTests.cs`, `TokenTrackerArgsTests.cs`, `TokenTrackerExportParserTests.cs`,
|
||
`TokenTrackerAggregatorTests.cs`, `TokenTrackerStateTests.cs`, `TokenTrackerServiceTests.cs`,
|
||
`FakeTokenTrackerClient.cs`.
|
||
|
||
**Geändert:**
|
||
|
||
| Datei | Änderung |
|
||
|---|---|
|
||
| `src/ClaudeDo.Worker/Hub/WorkerHub.cs` | `ModelUsageRowDto`/`TaskUsageRowDto` erweitert, `GetModelUsage` umgestellt, `GetTaskUsage` angereichert, drei neue Methoden |
|
||
| `src/ClaudeDo.Worker/Program.cs` | DI für die drei neuen Singletons |
|
||
| `src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs` | `ReadAsync` + Aggregations-Innereien entfernt |
|
||
| `src/ClaudeDo.Worker/Usage/Interfaces/ITranscriptUsageReader.cs` | Auf `ReadSessionTotalsAsync` reduziert |
|
||
| `src/ClaudeDo.Worker/Usage/UsageModels.cs` | `UsageAggregateRow` + `UsageScope` entfernt |
|
||
| `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` | Drei neue Methoden |
|
||
| `src/ClaudeDo.Ui/Services/WorkerClient.cs` | Implementierung + DTOs |
|
||
| `src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs` | Status, Kosten, Install-/Refresh-Command |
|
||
| `src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml` | Kostenspalten, Kopfzeile, Hinweis-Karte |
|
||
| `src/ClaudeDo.Localization/locales/{en,de}.json` | Neue Keys in Parität |
|
||
| `tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs` | `ReadAsync`-Tests entfernen |
|
||
| Fakes in `tests/ClaudeDo.Ui.Tests` | `IWorkerClient`-Erweiterung nachziehen |
|
||
| `docs/explore-notes/usage-monitoring.md`, `src/ClaudeDo.Worker/CLAUDE.md`, `src/ClaudeDo.Ui/CLAUDE.md` | Doku |
|
||
|
||
---
|
||
|
||
## Task 1: SessionHash
|
||
|
||
Die Brücke zwischen unseren `task_runs.session_id` und TokenTrackers `session_hash`.
|
||
Formel aus `lib/session-analytics.js:88`: `sha256(source + "\0" + sessionId)`, Hex, erste 24 Zeichen.
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/SessionHash.cs`
|
||
- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/SessionHashTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Die drei Vektoren sind gegen den echten Export auf der Entwicklungsmaschine verifiziert
|
||
(2026-08-24) — nicht ändern, sie sind der Beweis, dass die Formel stimmt.
|
||
|
||
```csharp
|
||
using ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
|
||
|
||
public sealed class SessionHashTests
|
||
{
|
||
[Theory]
|
||
[InlineData("77470328-94f7-49d3-b378-a562d1501b5f", "b24babcbf2b615730459773f")]
|
||
[InlineData("20ebc1bc-3e6a-4c09-a4b3-19472dffa841", "3e2a08fe8f2b941817fb554f")]
|
||
[InlineData("559e7ebb-7215-4112-9faf-be86bece9613", "03c30f4bc0bfe82c3fbb17c1")]
|
||
public void ForClaudeSession_MatchesTokenTrackerHash(string sessionId, string expected)
|
||
{
|
||
Assert.Equal(expected, SessionHash.ForClaudeSession(sessionId));
|
||
}
|
||
|
||
[Fact]
|
||
public void ForClaudeSession_IsLowercaseHexOf24Chars()
|
||
{
|
||
var hash = SessionHash.ForClaudeSession("any-session-id");
|
||
|
||
Assert.Equal(24, hash.Length);
|
||
Assert.All(hash, c => Assert.Contains(c, "0123456789abcdef"));
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(null)]
|
||
[InlineData("")]
|
||
[InlineData(" ")]
|
||
public void ForClaudeSession_BlankId_ReturnsNull(string? sessionId)
|
||
{
|
||
Assert.Null(SessionHash.ForClaudeSession(sessionId));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~SessionHashTests
|
||
```
|
||
|
||
Expected: Build-Fehler `CS0246: The type or namespace name 'SessionHash' could not be found`.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
```csharp
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
|
||
namespace ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
/// <summary>
|
||
/// Reproduces TokenTracker's session identity so its export rows can be joined onto our
|
||
/// <c>task_runs.session_id</c>. The formula is <c>sha256(source + "\0" + id)</c>, hex, first
|
||
/// 24 chars (TokenTracker's <c>lib/session-analytics.js</c>, <c>sessionHash()</c>). The export
|
||
/// never carries the raw session id, so this is the only way to attribute a row to a task.
|
||
/// </summary>
|
||
public static class SessionHash
|
||
{
|
||
private const int HashLength = 24;
|
||
|
||
public static string? ForClaudeSession(string? sessionId)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(sessionId)) return null;
|
||
|
||
var bytes = Encoding.UTF8.GetBytes($"claude\0{sessionId}");
|
||
var hash = SHA256.HashData(bytes);
|
||
return Convert.ToHexString(hash).ToLowerInvariant()[..HashLength];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~SessionHashTests
|
||
```
|
||
|
||
Expected: PASS, 5 Tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Usage/TokenTracker/SessionHash.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/SessionHashTests.cs
|
||
git commit -m "feat(usage): reproduce TokenTracker session_hash for task attribution"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Modelle und Fixture
|
||
|
||
Reine Datenträger plus das Fixture, das ab Task 3 jeder Test benutzt.
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerModels.cs`
|
||
- Create: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerFixtures.cs`
|
||
|
||
- [ ] **Step 1: Write the models**
|
||
|
||
```csharp
|
||
namespace ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
/// <summary>One row of <c>tokentracker sessions --format json</c>. Field names mirror the
|
||
/// export's <c>snake_case</c> keys: <c>cached_input_tokens</c> is the cache *read* count,
|
||
/// <c>cache_creation_input_tokens</c> the write count.</summary>
|
||
public sealed record TokenTrackerSession(
|
||
string SessionHash,
|
||
string Source,
|
||
string Model,
|
||
DateTimeOffset StartedAt,
|
||
long InputTokens,
|
||
long OutputTokens,
|
||
long CacheReadTokens,
|
||
long CacheCreationTokens,
|
||
double CostUsd,
|
||
int Turns,
|
||
int RetryTurns,
|
||
bool Productive,
|
||
bool OneShot);
|
||
|
||
/// <summary>A parsed export plus when we fetched it. <paramref name="FormatVersion"/> is the
|
||
/// <c>version</c> field of the session rows — the only compatibility signal the export gives us.</summary>
|
||
public sealed record TokenTrackerExport(
|
||
int FormatVersion,
|
||
IReadOnlyList<TokenTrackerSession> Sessions,
|
||
DateTime FetchedAtUtc);
|
||
|
||
public sealed record TokenTrackerProbe(
|
||
bool Installed,
|
||
string? Version,
|
||
bool NodeOk,
|
||
string? NodeVersion,
|
||
string? Error)
|
||
{
|
||
public static TokenTrackerProbe Unknown { get; } = new(false, null, false, null, null);
|
||
}
|
||
|
||
/// <summary>Outcome of one CLI invocation. <paramref name="Ok"/> false always carries an
|
||
/// <paramref name="Error"/> — callers turn that into state, never into an exception.</summary>
|
||
public sealed record TokenTrackerRunResult(bool Ok, string StdOut, string? Error);
|
||
|
||
/// <summary>Aggregated per date/model/scope. <c>Scope</c> is <c>"claudedo"</c> or <c>"other"</c>,
|
||
/// decided by whether the session hash belongs to one of our runs. <c>Sessions</c> replaces the
|
||
/// old assistant-message count — the export has no message granularity.</summary>
|
||
public sealed record TokenTrackerModelRow(
|
||
DateOnly Date,
|
||
string Model,
|
||
string Scope,
|
||
long InputTokens,
|
||
long OutputTokens,
|
||
long CacheReadTokens,
|
||
long CacheCreationTokens,
|
||
int Sessions,
|
||
double CostUsd);
|
||
|
||
/// <summary>What the export adds on top of what <c>task_runs</c> already knows about a task.</summary>
|
||
public sealed record TokenTrackerTaskExtras(
|
||
double CostUsd,
|
||
int Retries,
|
||
bool Productive,
|
||
bool OneShot);
|
||
```
|
||
|
||
- [ ] **Step 2: Write the fixture**
|
||
|
||
Vier Sessions: zwei ClaudeDo-Opus-Sessions am 2026-08-20, eine fremde Sonnet-Session am selben
|
||
Tag, eine Opus-Session am 2026-08-25 (außerhalb des Testbereichs). Die beiden ClaudeDo-Hashes
|
||
sind exakt die aus Task 1, damit der Join-Test echte IDs verwenden kann.
|
||
|
||
```csharp
|
||
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
|
||
|
||
public static class TokenTrackerFixtures
|
||
{
|
||
/// <summary>Session id whose hash is the fixture's first ClaudeDo session.</summary>
|
||
public const string ClaudeDoSessionIdA = "77470328-94f7-49d3-b378-a562d1501b5f";
|
||
|
||
/// <summary>Session id whose hash is the fixture's second ClaudeDo session.</summary>
|
||
public const string ClaudeDoSessionIdB = "20ebc1bc-3e6a-4c09-a4b3-19472dffa841";
|
||
|
||
/// <summary>Shape copied from a real `tokentracker sessions --format json` run
|
||
/// (2026-08-24), trimmed to four rows and stripped of everything we don't read.</summary>
|
||
public const string SessionsJson = """
|
||
{
|
||
"available": true,
|
||
"sessions": [
|
||
{
|
||
"version": 11,
|
||
"session_hash": "b24babcbf2b615730459773f",
|
||
"source": "claude",
|
||
"project_key": "ClaudeDo",
|
||
"model": "claude-opus-5",
|
||
"started_at": "2026-08-20T10:00:00.000Z",
|
||
"ended_at": "2026-08-20T10:10:00.000Z",
|
||
"turns": 4,
|
||
"edit_turns": 2,
|
||
"retry_turns": 1,
|
||
"tokens": {
|
||
"input_tokens": 100,
|
||
"cached_input_tokens": 900,
|
||
"cache_creation_input_tokens": 50,
|
||
"output_tokens": 400,
|
||
"reasoning_output_tokens": 0,
|
||
"total_tokens": 1450
|
||
},
|
||
"total_tokens": 1450,
|
||
"cost_usd": 2.5,
|
||
"productive": true,
|
||
"first_pass": true,
|
||
"one_shot": true
|
||
},
|
||
{
|
||
"version": 11,
|
||
"session_hash": "3e2a08fe8f2b941817fb554f",
|
||
"source": "claude",
|
||
"project_key": "02f6746f-d67b-4217-a581-f2f911f6ff0d",
|
||
"model": "claude-opus-5",
|
||
"started_at": "2026-08-20T12:00:00.000Z",
|
||
"ended_at": "2026-08-20T12:30:00.000Z",
|
||
"turns": 6,
|
||
"edit_turns": 0,
|
||
"retry_turns": 2,
|
||
"tokens": {
|
||
"input_tokens": 10,
|
||
"cached_input_tokens": 90,
|
||
"cache_creation_input_tokens": 5,
|
||
"output_tokens": 40,
|
||
"reasoning_output_tokens": 0,
|
||
"total_tokens": 145
|
||
},
|
||
"total_tokens": 145,
|
||
"cost_usd": 1.0,
|
||
"productive": false,
|
||
"first_pass": false,
|
||
"one_shot": false
|
||
},
|
||
{
|
||
"version": 11,
|
||
"session_hash": "ffffffffffffffffffffffff",
|
||
"source": "claude",
|
||
"project_key": "SomeOtherRepo",
|
||
"model": "claude-sonnet-5",
|
||
"started_at": "2026-08-20T14:00:00.000Z",
|
||
"ended_at": "2026-08-20T14:05:00.000Z",
|
||
"turns": 2,
|
||
"edit_turns": 1,
|
||
"retry_turns": 0,
|
||
"tokens": {
|
||
"input_tokens": 7,
|
||
"cached_input_tokens": 3,
|
||
"cache_creation_input_tokens": 1,
|
||
"output_tokens": 9,
|
||
"reasoning_output_tokens": 0,
|
||
"total_tokens": 20
|
||
},
|
||
"total_tokens": 20,
|
||
"cost_usd": 0.25,
|
||
"productive": true,
|
||
"first_pass": true,
|
||
"one_shot": true
|
||
},
|
||
{
|
||
"version": 11,
|
||
"session_hash": "aaaaaaaaaaaaaaaaaaaaaaaa",
|
||
"source": "claude",
|
||
"project_key": "ClaudeDo",
|
||
"model": "claude-opus-5",
|
||
"started_at": "2026-08-25T10:00:00.000Z",
|
||
"ended_at": "2026-08-25T10:20:00.000Z",
|
||
"turns": 3,
|
||
"edit_turns": 1,
|
||
"retry_turns": 0,
|
||
"tokens": {
|
||
"input_tokens": 1000,
|
||
"cached_input_tokens": 2000,
|
||
"cache_creation_input_tokens": 300,
|
||
"output_tokens": 4000,
|
||
"reasoning_output_tokens": 0,
|
||
"total_tokens": 7300
|
||
},
|
||
"total_tokens": 7300,
|
||
"cost_usd": 99.0,
|
||
"productive": true,
|
||
"first_pass": true,
|
||
"one_shot": true
|
||
}
|
||
]
|
||
}
|
||
""";
|
||
|
||
/// <summary>Same as <see cref="SessionsJson"/> but with a future format version.</summary>
|
||
public const string UnsupportedVersionJson = """
|
||
{
|
||
"available": true,
|
||
"sessions": [
|
||
{
|
||
"version": 99,
|
||
"session_hash": "b24babcbf2b615730459773f",
|
||
"source": "claude",
|
||
"model": "claude-opus-5",
|
||
"started_at": "2026-08-20T10:00:00.000Z",
|
||
"tokens": { "input_tokens": 1, "output_tokens": 2 },
|
||
"cost_usd": 0.1
|
||
}
|
||
]
|
||
}
|
||
""";
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify it compiles**
|
||
|
||
```bash
|
||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||
```
|
||
|
||
Expected: Build succeeded, 0 Errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerModels.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerFixtures.cs
|
||
git commit -m "feat(usage): add TokenTracker export models and test fixture"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Export-Parser
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerExportParser.cs`
|
||
- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerExportParserTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
using ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
|
||
|
||
public sealed class TokenTrackerExportParserTests
|
||
{
|
||
private static readonly DateTime FetchedAt = new(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc);
|
||
|
||
[Fact]
|
||
public void Parse_ReadsAllSessions()
|
||
{
|
||
var export = TokenTrackerExportParser.Parse(TokenTrackerFixtures.SessionsJson, FetchedAt);
|
||
|
||
Assert.NotNull(export);
|
||
Assert.Equal(4, export!.Sessions.Count);
|
||
Assert.Equal(11, export.FormatVersion);
|
||
Assert.Equal(FetchedAt, export.FetchedAtUtc);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_MapsTokenFieldsAndCost()
|
||
{
|
||
var export = TokenTrackerExportParser.Parse(TokenTrackerFixtures.SessionsJson, FetchedAt)!;
|
||
var first = export.Sessions.Single(s => s.SessionHash == "b24babcbf2b615730459773f");
|
||
|
||
Assert.Equal("claude-opus-5", first.Model);
|
||
Assert.Equal(100, first.InputTokens);
|
||
Assert.Equal(400, first.OutputTokens);
|
||
Assert.Equal(900, first.CacheReadTokens);
|
||
Assert.Equal(50, first.CacheCreationTokens);
|
||
Assert.Equal(2.5, first.CostUsd);
|
||
Assert.Equal(4, first.Turns);
|
||
Assert.Equal(1, first.RetryTurns);
|
||
Assert.True(first.Productive);
|
||
Assert.True(first.OneShot);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_UnsupportedVersion_StillParsesButReportsIt()
|
||
{
|
||
var export = TokenTrackerExportParser.Parse(TokenTrackerFixtures.UnsupportedVersionJson, FetchedAt);
|
||
|
||
Assert.NotNull(export);
|
||
Assert.Equal(99, export!.FormatVersion);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_MissingFields_DefaultToZero()
|
||
{
|
||
const string json = """
|
||
{ "sessions": [ { "version": 11, "session_hash": "abc", "source": "claude",
|
||
"model": "claude-opus-5", "started_at": "2026-08-20T10:00:00.000Z" } ] }
|
||
""";
|
||
|
||
var session = TokenTrackerExportParser.Parse(json, FetchedAt)!.Sessions.Single();
|
||
|
||
Assert.Equal(0, session.InputTokens);
|
||
Assert.Equal(0, session.CostUsd);
|
||
Assert.Equal(0, session.RetryTurns);
|
||
Assert.False(session.Productive);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_SkipsRowsWithoutHashOrTimestamp()
|
||
{
|
||
const string json = """
|
||
{ "sessions": [ { "version": 11, "source": "claude", "model": "m" },
|
||
{ "version": 11, "session_hash": "abc", "source": "claude", "model": "m" },
|
||
{ "version": 11, "session_hash": "def", "source": "claude", "model": "m",
|
||
"started_at": "2026-08-20T10:00:00.000Z" } ] }
|
||
""";
|
||
|
||
var export = TokenTrackerExportParser.Parse(json, FetchedAt);
|
||
|
||
Assert.Single(export!.Sessions);
|
||
Assert.Equal("def", export.Sessions[0].SessionHash);
|
||
}
|
||
|
||
[Fact]
|
||
public void Parse_SkipsNonClaudeSources()
|
||
{
|
||
const string json = """
|
||
{ "sessions": [ { "version": 11, "session_hash": "abc", "source": "codex", "model": "gpt",
|
||
"started_at": "2026-08-20T10:00:00.000Z" } ] }
|
||
""";
|
||
|
||
Assert.Empty(TokenTrackerExportParser.Parse(json, FetchedAt)!.Sessions);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("")]
|
||
[InlineData(" ")]
|
||
[InlineData("not json at all")]
|
||
[InlineData("[1,2,3]")]
|
||
[InlineData("{\"available\": false}")]
|
||
public void Parse_Garbage_ReturnsNull(string json)
|
||
{
|
||
Assert.Null(TokenTrackerExportParser.Parse(json, FetchedAt));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerExportParserTests
|
||
```
|
||
|
||
Expected: Build-Fehler `CS0103: The name 'TokenTrackerExportParser' does not exist`.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
```csharp
|
||
using System.Text.Json;
|
||
|
||
namespace ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
/// <summary>
|
||
/// Parses <c>tokentracker sessions --format json</c>. The export is not a promised API, so every
|
||
/// field is read defensively: a missing number becomes 0, an unreadable row is skipped, and
|
||
/// unparseable output returns null rather than throwing into the caller's face.
|
||
/// </summary>
|
||
public static class TokenTrackerExportParser
|
||
{
|
||
/// <summary>The <c>version</c> value this parser was written against.</summary>
|
||
public const int SupportedFormatVersion = 11;
|
||
|
||
public static TokenTrackerExport? Parse(string? json, DateTime fetchedAtUtc)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||
|
||
JsonDocument doc;
|
||
try { doc = JsonDocument.Parse(json); }
|
||
catch (JsonException) { return null; }
|
||
|
||
using (doc)
|
||
{
|
||
var root = doc.RootElement;
|
||
if (root.ValueKind != JsonValueKind.Object) return null;
|
||
if (!root.TryGetProperty("sessions", out var sessions) ||
|
||
sessions.ValueKind != JsonValueKind.Array) return null;
|
||
|
||
var parsed = new List<TokenTrackerSession>();
|
||
var version = 0;
|
||
|
||
foreach (var row in sessions.EnumerateArray())
|
||
{
|
||
if (row.ValueKind != JsonValueKind.Object) continue;
|
||
|
||
version = Math.Max(version, (int)GetLong(row, "version"));
|
||
|
||
var hash = GetString(row, "session_hash");
|
||
if (string.IsNullOrWhiteSpace(hash)) continue;
|
||
|
||
var source = GetString(row, "source") ?? "";
|
||
if (!string.Equals(source, "claude", StringComparison.OrdinalIgnoreCase)) continue;
|
||
|
||
if (!DateTimeOffset.TryParse(GetString(row, "started_at"), out var startedAt)) continue;
|
||
|
||
var tokens = row.TryGetProperty("tokens", out var t) && t.ValueKind == JsonValueKind.Object
|
||
? t
|
||
: default;
|
||
|
||
parsed.Add(new TokenTrackerSession(
|
||
SessionHash: hash!,
|
||
Source: source,
|
||
Model: GetString(row, "model") ?? "",
|
||
StartedAt: startedAt,
|
||
InputTokens: GetLong(tokens, "input_tokens"),
|
||
OutputTokens: GetLong(tokens, "output_tokens"),
|
||
CacheReadTokens: GetLong(tokens, "cached_input_tokens"),
|
||
CacheCreationTokens: GetLong(tokens, "cache_creation_input_tokens"),
|
||
CostUsd: GetDouble(row, "cost_usd"),
|
||
Turns: (int)GetLong(row, "turns"),
|
||
RetryTurns: (int)GetLong(row, "retry_turns"),
|
||
Productive: GetBool(row, "productive"),
|
||
OneShot: GetBool(row, "one_shot")));
|
||
}
|
||
|
||
return new TokenTrackerExport(version, parsed, fetchedAtUtc);
|
||
}
|
||
}
|
||
|
||
private static string? GetString(JsonElement obj, string prop) =>
|
||
obj.ValueKind == JsonValueKind.Object &&
|
||
obj.TryGetProperty(prop, out var el) &&
|
||
el.ValueKind == JsonValueKind.String
|
||
? el.GetString()
|
||
: null;
|
||
|
||
private static long GetLong(JsonElement obj, string prop) =>
|
||
obj.ValueKind == JsonValueKind.Object &&
|
||
obj.TryGetProperty(prop, out var el) &&
|
||
el.ValueKind == JsonValueKind.Number &&
|
||
el.TryGetInt64(out var v)
|
||
? v
|
||
: 0;
|
||
|
||
private static double GetDouble(JsonElement obj, string prop) =>
|
||
obj.ValueKind == JsonValueKind.Object &&
|
||
obj.TryGetProperty(prop, out var el) &&
|
||
el.ValueKind == JsonValueKind.Number &&
|
||
el.TryGetDouble(out var v)
|
||
? v
|
||
: 0;
|
||
|
||
private static bool GetBool(JsonElement obj, string prop) =>
|
||
obj.ValueKind == JsonValueKind.Object &&
|
||
obj.TryGetProperty(prop, out var el) &&
|
||
el.ValueKind == JsonValueKind.True;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerExportParserTests
|
||
```
|
||
|
||
Expected: PASS, 11 Tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerExportParser.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerExportParserTests.cs
|
||
git commit -m "feat(usage): parse the TokenTracker sessions export defensively"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Aggregator
|
||
|
||
Der Ersatz für die Aggregations-Innereien von `TranscriptUsageReader.ReadAsync`. Pure, kein I/O.
|
||
|
||
Das Datum einer Session ist ihr **lokales** Startdatum (`StartedAt.LocalDateTime`), passend zu den
|
||
lokalen Datumspickern in der UI und zum Verhalten des alten Readers.
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerAggregator.cs`
|
||
- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerAggregatorTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
using ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
|
||
|
||
public sealed class TokenTrackerAggregatorTests
|
||
{
|
||
private static readonly DateOnly RangeStart = new(2026, 8, 20);
|
||
private static readonly DateOnly RangeEnd = new(2026, 8, 21);
|
||
|
||
private static IReadOnlyList<TokenTrackerSession> Sessions() =>
|
||
TokenTrackerExportParser.Parse(
|
||
TokenTrackerFixtures.SessionsJson,
|
||
new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc))!.Sessions;
|
||
|
||
private static HashSet<string> ClaudeDoHashes() =>
|
||
[
|
||
SessionHash.ForClaudeSession(TokenTrackerFixtures.ClaudeDoSessionIdA)!,
|
||
SessionHash.ForClaudeSession(TokenTrackerFixtures.ClaudeDoSessionIdB)!,
|
||
];
|
||
|
||
[Fact]
|
||
public void ByModel_ExcludesSessionsOutsideTheRange()
|
||
{
|
||
var rows = TokenTrackerAggregator.ByModel(Sessions(), RangeStart, RangeEnd, ClaudeDoHashes());
|
||
|
||
Assert.All(rows, r => Assert.InRange(r.Date, RangeStart, RangeEnd));
|
||
Assert.DoesNotContain(rows, r => r.CostUsd == 99.0);
|
||
}
|
||
|
||
[Fact]
|
||
public void ByModel_SplitsClaudeDoFromOther()
|
||
{
|
||
var rows = TokenTrackerAggregator.ByModel(Sessions(), RangeStart, RangeEnd, ClaudeDoHashes());
|
||
|
||
var opus = rows.Single(r => r.Model == "claude-opus-5" && r.Scope == "claudedo");
|
||
Assert.Equal(110, opus.InputTokens);
|
||
Assert.Equal(440, opus.OutputTokens);
|
||
Assert.Equal(990, opus.CacheReadTokens);
|
||
Assert.Equal(55, opus.CacheCreationTokens);
|
||
Assert.Equal(2, opus.Sessions);
|
||
Assert.Equal(3.5, opus.CostUsd, 6);
|
||
|
||
var sonnet = rows.Single(r => r.Model == "claude-sonnet-5");
|
||
Assert.Equal("other", sonnet.Scope);
|
||
Assert.Equal(0.25, sonnet.CostUsd, 6);
|
||
}
|
||
|
||
[Fact]
|
||
public void ByModel_UnknownHash_CountsAsOther()
|
||
{
|
||
var rows = TokenTrackerAggregator.ByModel(Sessions(), RangeStart, RangeEnd, new HashSet<string>());
|
||
|
||
Assert.All(rows, r => Assert.Equal("other", r.Scope));
|
||
}
|
||
|
||
[Fact]
|
||
public void ByModel_EmptyInput_ReturnsEmpty()
|
||
{
|
||
Assert.Empty(TokenTrackerAggregator.ByModel([], RangeStart, RangeEnd, new HashSet<string>()));
|
||
}
|
||
|
||
[Fact]
|
||
public void ForSessions_SumsCostAndRetriesOfMatchingRows()
|
||
{
|
||
var extras = TokenTrackerAggregator.ForSessions(
|
||
Sessions(),
|
||
[TokenTrackerFixtures.ClaudeDoSessionIdA, TokenTrackerFixtures.ClaudeDoSessionIdB]);
|
||
|
||
Assert.NotNull(extras);
|
||
Assert.Equal(3.5, extras!.CostUsd, 6);
|
||
Assert.Equal(3, extras.Retries);
|
||
Assert.True(extras.Productive); // A is productive
|
||
Assert.False(extras.OneShot); // B is not one-shot
|
||
}
|
||
|
||
[Fact]
|
||
public void ForSessions_SingleOneShotSession_ReportsOneShot()
|
||
{
|
||
var extras = TokenTrackerAggregator.ForSessions(
|
||
Sessions(), [TokenTrackerFixtures.ClaudeDoSessionIdA]);
|
||
|
||
Assert.True(extras!.OneShot);
|
||
Assert.Equal(2.5, extras.CostUsd, 6);
|
||
}
|
||
|
||
[Fact]
|
||
public void ForSessions_NoMatch_ReturnsNull()
|
||
{
|
||
Assert.Null(TokenTrackerAggregator.ForSessions(Sessions(), ["11111111-2222-3333-4444-555555555555"]));
|
||
}
|
||
|
||
[Fact]
|
||
public void ForSessions_BlankIds_ReturnsNull()
|
||
{
|
||
Assert.Null(TokenTrackerAggregator.ForSessions(Sessions(), [null, "", " "]));
|
||
}
|
||
|
||
[Fact]
|
||
public void Totals_SumsTokensAndCostInRange()
|
||
{
|
||
var totals = TokenTrackerAggregator.Totals(Sessions(), RangeStart, RangeEnd);
|
||
|
||
Assert.Equal(1615, totals.TotalTokens); // 1450 + 145 + 20
|
||
Assert.Equal(3.75, totals.CostUsd, 6);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerAggregatorTests
|
||
```
|
||
|
||
Expected: Build-Fehler `CS0103: The name 'TokenTrackerAggregator' does not exist`.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
`TokenTrackerTotals` gehört fachlich zu den Aggregaten und wird nur hier gebraucht — nach der
|
||
Projektkonvention (kleine Single-Consumer-Typen liegen bei ihrem Konsumenten) steht es in dieser
|
||
Datei, nicht in `TokenTrackerModels.cs`.
|
||
|
||
```csharp
|
||
namespace ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
public sealed record TokenTrackerTotals(long TotalTokens, double CostUsd, int Sessions);
|
||
|
||
/// <summary>
|
||
/// Pure aggregation over a parsed export. One fetch covers a wide window and every view filters
|
||
/// it here, so switching the range in the UI costs nothing.
|
||
/// </summary>
|
||
public static class TokenTrackerAggregator
|
||
{
|
||
public const string ScopeClaudeDo = "claudedo";
|
||
public const string ScopeOther = "other";
|
||
|
||
public static IReadOnlyList<TokenTrackerModelRow> ByModel(
|
||
IReadOnlyList<TokenTrackerSession> sessions,
|
||
DateOnly from,
|
||
DateOnly to,
|
||
ISet<string> claudeDoHashes)
|
||
{
|
||
var buckets = new Dictionary<(DateOnly Date, string Model, string Scope), Accumulator>();
|
||
|
||
foreach (var session in InRange(sessions, from, to))
|
||
{
|
||
var key = (
|
||
DateOf(session),
|
||
session.Model,
|
||
claudeDoHashes.Contains(session.SessionHash) ? ScopeClaudeDo : ScopeOther);
|
||
|
||
if (!buckets.TryGetValue(key, out var acc))
|
||
{
|
||
acc = new Accumulator();
|
||
buckets[key] = acc;
|
||
}
|
||
|
||
acc.Input += session.InputTokens;
|
||
acc.Output += session.OutputTokens;
|
||
acc.CacheRead += session.CacheReadTokens;
|
||
acc.CacheCreation += session.CacheCreationTokens;
|
||
acc.Cost += session.CostUsd;
|
||
acc.Sessions++;
|
||
}
|
||
|
||
return buckets
|
||
.Select(kv => new TokenTrackerModelRow(
|
||
kv.Key.Date, kv.Key.Model, kv.Key.Scope,
|
||
kv.Value.Input, kv.Value.Output, kv.Value.CacheRead, kv.Value.CacheCreation,
|
||
kv.Value.Sessions, kv.Value.Cost))
|
||
.OrderBy(r => r.Date).ThenBy(r => r.Model).ThenBy(r => r.Scope)
|
||
.ToList();
|
||
}
|
||
|
||
/// <summary>Cost and efficiency for one task, given the session ids of its runs. Null when
|
||
/// none of them appear in the export — the caller then shows tokens without cost.</summary>
|
||
public static TokenTrackerTaskExtras? ForSessions(
|
||
IReadOnlyList<TokenTrackerSession> sessions,
|
||
IEnumerable<string?> sessionIds)
|
||
{
|
||
var wanted = sessionIds
|
||
.Select(SessionHash.ForClaudeSession)
|
||
.Where(h => h is not null)
|
||
.ToHashSet()!;
|
||
|
||
if (wanted.Count == 0) return null;
|
||
|
||
var matched = sessions.Where(s => wanted.Contains(s.SessionHash)).ToList();
|
||
if (matched.Count == 0) return null;
|
||
|
||
return new TokenTrackerTaskExtras(
|
||
CostUsd: matched.Sum(s => s.CostUsd),
|
||
Retries: matched.Sum(s => s.RetryTurns),
|
||
Productive: matched.Any(s => s.Productive),
|
||
OneShot: matched.All(s => s.OneShot));
|
||
}
|
||
|
||
public static TokenTrackerTotals Totals(
|
||
IReadOnlyList<TokenTrackerSession> sessions, DateOnly from, DateOnly to)
|
||
{
|
||
var inRange = InRange(sessions, from, to).ToList();
|
||
return new TokenTrackerTotals(
|
||
TotalTokens: inRange.Sum(s =>
|
||
s.InputTokens + s.OutputTokens + s.CacheReadTokens + s.CacheCreationTokens),
|
||
CostUsd: inRange.Sum(s => s.CostUsd),
|
||
Sessions: inRange.Count);
|
||
}
|
||
|
||
private static IEnumerable<TokenTrackerSession> InRange(
|
||
IReadOnlyList<TokenTrackerSession> sessions, DateOnly from, DateOnly to) =>
|
||
sessions.Where(s => DateOf(s) >= from && DateOf(s) <= to);
|
||
|
||
/// <summary>Local start date — the UI's pickers and the old transcript reader both work in
|
||
/// local time, so a UTC-based date would shift rows across midnight for the user.</summary>
|
||
private static DateOnly DateOf(TokenTrackerSession session) =>
|
||
DateOnly.FromDateTime(session.StartedAt.LocalDateTime);
|
||
|
||
private sealed class Accumulator
|
||
{
|
||
public long Input, Output, CacheRead, CacheCreation;
|
||
public double Cost;
|
||
public int Sessions;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerAggregatorTests
|
||
```
|
||
|
||
Expected: PASS, 9 Tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerAggregator.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerAggregatorTests.cs
|
||
git commit -m "feat(usage): aggregate TokenTracker sessions by model and per task"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: TokenTrackerState
|
||
|
||
Gleiches Muster wie `UsageState` — ein Fehler darf ein gutes Ergebnis nie überschreiben.
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerState.cs`
|
||
- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerStateTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
using ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
|
||
|
||
public sealed class TokenTrackerStateTests
|
||
{
|
||
private static TokenTrackerExport Export(DateTime fetchedAt) =>
|
||
new(11, [], fetchedAt);
|
||
|
||
[Fact]
|
||
public void FreshState_IsEmpty()
|
||
{
|
||
var state = new TokenTrackerState();
|
||
|
||
Assert.Null(state.Export);
|
||
Assert.Null(state.LastAttemptUtc);
|
||
Assert.Null(state.LastError);
|
||
}
|
||
|
||
[Fact]
|
||
public void ReportSuccess_StoresExportAndClearsError()
|
||
{
|
||
var state = new TokenTrackerState();
|
||
var at = new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc);
|
||
state.ReportFailure("boom", at.AddMinutes(-1));
|
||
|
||
state.ReportSuccess(Export(at));
|
||
|
||
Assert.NotNull(state.Export);
|
||
Assert.Equal(at, state.LastAttemptUtc);
|
||
Assert.Null(state.LastError);
|
||
}
|
||
|
||
[Fact]
|
||
public void ReportFailure_KeepsPreviousExport()
|
||
{
|
||
var state = new TokenTrackerState();
|
||
var good = new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc);
|
||
state.ReportSuccess(Export(good));
|
||
|
||
state.ReportFailure("tokentracker exited with code 1", good.AddMinutes(30));
|
||
|
||
Assert.NotNull(state.Export);
|
||
Assert.Equal(good, state.Export!.FetchedAtUtc);
|
||
Assert.Equal("tokentracker exited with code 1", state.LastError);
|
||
Assert.Equal(good.AddMinutes(30), state.LastAttemptUtc);
|
||
}
|
||
|
||
[Fact]
|
||
public void IsOlderThan_NoExport_IsTrue()
|
||
{
|
||
Assert.True(new TokenTrackerState().IsOlderThan(TimeSpan.FromMinutes(15),
|
||
new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc)));
|
||
}
|
||
|
||
[Fact]
|
||
public void IsOlderThan_ComparesAgainstFetchTime()
|
||
{
|
||
var state = new TokenTrackerState();
|
||
var fetched = new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc);
|
||
state.ReportSuccess(Export(fetched));
|
||
|
||
Assert.False(state.IsOlderThan(TimeSpan.FromMinutes(15), fetched.AddMinutes(14)));
|
||
Assert.True(state.IsOlderThan(TimeSpan.FromMinutes(15), fetched.AddMinutes(16)));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerStateTests
|
||
```
|
||
|
||
Expected: Build-Fehler `CS0246: The type or namespace name 'TokenTrackerState' could not be found`.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
```csharp
|
||
namespace ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
/// <summary>
|
||
/// Threadsafe holder for the last successful export. Mirrors <see cref="UsageState"/>: a failed
|
||
/// fetch never overwrites a good export, it only records <see cref="LastError"/>.
|
||
/// </summary>
|
||
public sealed class TokenTrackerState
|
||
{
|
||
private readonly object _lock = new();
|
||
private TokenTrackerExport? _export;
|
||
private DateTime? _lastAttemptUtc;
|
||
private string? _lastError;
|
||
|
||
public TokenTrackerExport? Export
|
||
{
|
||
get { lock (_lock) return _export; }
|
||
}
|
||
|
||
public DateTime? LastAttemptUtc
|
||
{
|
||
get { lock (_lock) return _lastAttemptUtc; }
|
||
}
|
||
|
||
public string? LastError
|
||
{
|
||
get { lock (_lock) return _lastError; }
|
||
}
|
||
|
||
public void ReportSuccess(TokenTrackerExport export)
|
||
{
|
||
lock (_lock)
|
||
{
|
||
_export = export;
|
||
_lastAttemptUtc = export.FetchedAtUtc;
|
||
_lastError = null;
|
||
}
|
||
}
|
||
|
||
public void ReportFailure(string error, DateTime attemptedAtUtc)
|
||
{
|
||
lock (_lock)
|
||
{
|
||
_lastAttemptUtc = attemptedAtUtc;
|
||
_lastError = error;
|
||
}
|
||
}
|
||
|
||
public bool IsOlderThan(TimeSpan maxAge, DateTime nowUtc)
|
||
{
|
||
lock (_lock)
|
||
{
|
||
return _export is null || nowUtc - _export.FetchedAtUtc > maxAge;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerStateTests
|
||
```
|
||
|
||
Expected: PASS, 5 Tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerState.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerStateTests.cs
|
||
git commit -m "feat(usage): hold the last good TokenTracker export fail-open"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: CLI-Argumente
|
||
|
||
Die Argumentlisten pure und getestet, damit der Prozess-Wrapper in Task 7 nichts Testbares mehr enthält.
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerArgs.cs`
|
||
- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerArgsTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```csharp
|
||
using ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
|
||
|
||
public sealed class TokenTrackerArgsTests
|
||
{
|
||
[Fact]
|
||
public void Export_UsesIsoDatesAndDisablesGitAttribution()
|
||
{
|
||
var args = TokenTrackerArgs.Export(new DateOnly(2026, 5, 26), new DateOnly(2026, 8, 24));
|
||
|
||
Assert.Equal(
|
||
new[] { "sessions", "--from", "2026-05-26", "--to", "2026-08-24", "--no-git", "--format", "json" },
|
||
args);
|
||
}
|
||
|
||
[Fact]
|
||
public void Export_IsCultureInvariant()
|
||
{
|
||
var previous = Thread.CurrentThread.CurrentCulture;
|
||
Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE");
|
||
try
|
||
{
|
||
var args = TokenTrackerArgs.Export(new DateOnly(2026, 1, 2), new DateOnly(2026, 1, 3));
|
||
Assert.Equal("2026-01-02", args[2]);
|
||
Assert.Equal("2026-01-03", args[4]);
|
||
}
|
||
finally { Thread.CurrentThread.CurrentCulture = previous; }
|
||
}
|
||
|
||
[Fact]
|
||
public void Version_AsksTheCliForItsVersion()
|
||
{
|
||
Assert.Equal(new[] { "-v" }, TokenTrackerArgs.Version());
|
||
}
|
||
|
||
[Fact]
|
||
public void Install_InstallsTheCliPackageGlobally()
|
||
{
|
||
Assert.Equal(new[] { "i", "-g", "tokentracker-cli" }, TokenTrackerArgs.Install());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerArgsTests
|
||
```
|
||
|
||
Expected: Build-Fehler `CS0103: The name 'TokenTrackerArgs' does not exist`.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
```csharp
|
||
using System.Globalization;
|
||
|
||
namespace ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
/// <summary>
|
||
/// The exact CLI invocations we rely on. <c>--no-git</c> matters: without it TokenTracker runs
|
||
/// <c>git log</c> inside every session's working directory, which is both slower and touches
|
||
/// repositories we have no business touching. We never invoke <c>init</c> — that would write
|
||
/// hooks into the user's global <c>~/.claude/settings.json</c> and switch on cloud sync.
|
||
/// </summary>
|
||
public static class TokenTrackerArgs
|
||
{
|
||
public const string Command = "tokentracker";
|
||
public const string NpmCommand = "npm";
|
||
public const string PackageName = "tokentracker-cli";
|
||
|
||
public static string[] Export(DateOnly from, DateOnly to) =>
|
||
[
|
||
"sessions",
|
||
"--from", from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||
"--to", to.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||
"--no-git",
|
||
"--format", "json",
|
||
];
|
||
|
||
public static string[] Version() => ["-v"];
|
||
|
||
public static string[] Install() => ["i", "-g", PackageName];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerArgsTests
|
||
```
|
||
|
||
Expected: PASS, 4 Tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerArgs.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerArgsTests.cs
|
||
git commit -m "feat(usage): pin the TokenTracker CLI invocations"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: TokenTrackerClient (Prozess-Wrapper)
|
||
|
||
Der einzige Typ, der Prozesse startet. Nicht per Unit-Test abgedeckt — die pure Logik steckt in
|
||
Task 6, und ein Test, der das echte CLI startet, ist ausdrücklich verboten. Er wird über
|
||
`ITokenTrackerClient` gefakt.
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs`
|
||
- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs`
|
||
|
||
- [ ] **Step 1: Write the interface**
|
||
|
||
```csharp
|
||
namespace ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
|
||
|
||
public interface ITokenTrackerClient
|
||
{
|
||
/// <summary>Checks whether the CLI and a usable Node runtime are present. Never throws.</summary>
|
||
Task<TokenTrackerProbe> ProbeAsync(CancellationToken ct = default);
|
||
|
||
/// <summary>Runs the sessions export. Never throws — a failure comes back as
|
||
/// <see cref="TokenTrackerRunResult.Ok"/> false plus an error message.</summary>
|
||
Task<TokenTrackerRunResult> ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
|
||
|
||
/// <summary>Runs <c>npm i -g tokentracker-cli</c>, reporting each output line.</summary>
|
||
Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default);
|
||
}
|
||
```
|
||
|
||
Der `using ClaudeDo.Worker.Usage.TokenTracker;` fehlt hier absichtlich nicht — die Records liegen
|
||
im Elternnamespace, den das Interface über den Ordner-Namespace nicht automatisch sieht. Setze
|
||
oben `using ClaudeDo.Worker.Usage.TokenTracker;` **nicht** ein, sondern verlasse dich darauf, dass
|
||
`ClaudeDo.Worker.Usage.TokenTracker.Interfaces` den Elternnamespace `…Usage.TokenTracker`
|
||
implizit auflöst — das ist C#-Namespace-Regel und in diesem Projekt bei
|
||
`Usage/Interfaces/IUsageClient.cs` genauso gelöst.
|
||
|
||
- [ ] **Step 2: Write the client**
|
||
|
||
```csharp
|
||
using System.Diagnostics;
|
||
using System.Globalization;
|
||
using ClaudeDo.Data.Environment;
|
||
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
|
||
|
||
namespace ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
/// <summary>
|
||
/// Starts the TokenTracker CLI. Resolution goes through <see cref="ExecutableResolver"/> because
|
||
/// an npm-installed CLI is a <c>.cmd</c> shim, which <c>UseShellExecute = false</c> cannot exec
|
||
/// directly. We deliberately never fall back to <c>npx</c> — that would silently download a
|
||
/// package behind the user's back.
|
||
/// </summary>
|
||
public sealed class TokenTrackerClient : ITokenTrackerClient
|
||
{
|
||
private static readonly TimeSpan ExportTimeout = TimeSpan.FromSeconds(120);
|
||
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(20);
|
||
private static readonly TimeSpan InstallTimeout = TimeSpan.FromMinutes(5);
|
||
private const int MinimumNodeMajor = 20;
|
||
|
||
public async Task<TokenTrackerProbe> ProbeAsync(CancellationToken ct = default)
|
||
{
|
||
var node = await RunAsync("node", ["--version"], ProbeTimeout, null, ct);
|
||
var nodeVersion = node.Ok ? node.StdOut.Trim() : null;
|
||
var nodeOk = NodeMajor(nodeVersion) >= MinimumNodeMajor;
|
||
|
||
var cli = await RunAsync(TokenTrackerArgs.Command, TokenTrackerArgs.Version(), ProbeTimeout, null, ct);
|
||
return cli.Ok
|
||
? new TokenTrackerProbe(true, cli.StdOut.Trim(), nodeOk, nodeVersion, null)
|
||
: new TokenTrackerProbe(false, null, nodeOk, nodeVersion, cli.Error);
|
||
}
|
||
|
||
public Task<TokenTrackerRunResult> ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default) =>
|
||
RunAsync(TokenTrackerArgs.Command, TokenTrackerArgs.Export(from, to), ExportTimeout, null, ct);
|
||
|
||
public Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default) =>
|
||
RunAsync(TokenTrackerArgs.NpmCommand, TokenTrackerArgs.Install(), InstallTimeout, output, ct);
|
||
|
||
/// <summary>Visible for the probe's version parsing; returns 0 when unreadable.</summary>
|
||
internal static int NodeMajor(string? version)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(version)) return 0;
|
||
|
||
var trimmed = version.Trim().TrimStart('v', 'V');
|
||
var major = trimmed.Split('.', 2)[0];
|
||
return int.TryParse(major, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : 0;
|
||
}
|
||
|
||
private static async Task<TokenTrackerRunResult> RunAsync(
|
||
string command,
|
||
IReadOnlyList<string> arguments,
|
||
TimeSpan timeout,
|
||
IProgress<string>? output,
|
||
CancellationToken ct)
|
||
{
|
||
var resolved = ExecutableResolver.Resolve(command);
|
||
if (resolved is null)
|
||
return new TokenTrackerRunResult(false, "", $"'{command}' not found on PATH.");
|
||
|
||
var startInfo = new ProcessStartInfo
|
||
{
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
UseShellExecute = false,
|
||
CreateNoWindow = true,
|
||
};
|
||
|
||
if (resolved.IsShim)
|
||
{
|
||
var shim = ExecutableResolver.BuildShimStartInfo(resolved.Path, arguments);
|
||
startInfo.FileName = shim.FileName;
|
||
startInfo.Arguments = shim.Arguments;
|
||
}
|
||
else
|
||
{
|
||
startInfo.FileName = resolved.Path;
|
||
foreach (var arg in arguments) startInfo.ArgumentList.Add(arg);
|
||
}
|
||
|
||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||
timeoutCts.CancelAfter(timeout);
|
||
|
||
try
|
||
{
|
||
using var process = Process.Start(startInfo);
|
||
if (process is null)
|
||
return new TokenTrackerRunResult(false, "", $"Could not start '{command}'.");
|
||
|
||
// Both pipes are drained concurrently — reading one to the end first deadlocks as
|
||
// soon as the other fills its buffer, and a 90-day export is several MB of stdout.
|
||
var stdoutTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
|
||
var stderrTask = ReadLinesAsync(process, output, timeoutCts.Token);
|
||
|
||
await process.WaitForExitAsync(timeoutCts.Token);
|
||
var stdout = await stdoutTask;
|
||
var stderr = await stderrTask;
|
||
|
||
return process.ExitCode == 0
|
||
? new TokenTrackerRunResult(true, stdout, null)
|
||
: new TokenTrackerRunResult(false, stdout,
|
||
$"'{command}' exited with code {process.ExitCode}. {Head(stderr)}".TrimEnd());
|
||
}
|
||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||
{
|
||
return new TokenTrackerRunResult(false, "", $"'{command}' timed out after {timeout.TotalSeconds:0}s.");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return new TokenTrackerRunResult(false, "", $"'{command}' failed: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private static async Task<string> ReadLinesAsync(Process process, IProgress<string>? output, CancellationToken ct)
|
||
{
|
||
var lines = new List<string>();
|
||
while (await process.StandardError.ReadLineAsync(ct) is { } line)
|
||
{
|
||
lines.Add(line);
|
||
output?.Report(line);
|
||
}
|
||
return string.Join(System.Environment.NewLine, lines);
|
||
}
|
||
|
||
private static string Head(string text)
|
||
{
|
||
var trimmed = text.Trim();
|
||
return trimmed.Length <= 300 ? trimmed : trimmed[..300] + "…";
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify it compiles**
|
||
|
||
```bash
|
||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||
```
|
||
|
||
Expected: Build succeeded, 0 Errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs
|
||
git commit -m "feat(usage): run the TokenTracker CLI through the shared executable resolver"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: TokenTrackerService
|
||
|
||
Koordiniert Probe-Cache, Single-Flight-Refresh und Versionsprüfung.
|
||
|
||
**Verhalten, das der Test festnagelt:**
|
||
- `EnsureFreshAsync` holt neu, wenn kein Export da ist oder er älter als `maxAge` ist; sonst nichts.
|
||
- Zwei parallele Refreshs führen zu **einem** CLI-Aufruf.
|
||
- Eine nicht unterstützte Format-Version wird wie ein Fehler behandelt (Klartext in `LastError`,
|
||
kein Ersetzen des letzten guten Exports).
|
||
- Ein fehlgeschlagener Aufruf lässt den letzten guten Export stehen.
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerService.cs`
|
||
- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/FakeTokenTrackerClient.cs`
|
||
- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerServiceTests.cs`
|
||
|
||
- [ ] **Step 1: Write the fake**
|
||
|
||
```csharp
|
||
using ClaudeDo.Worker.Usage.TokenTracker;
|
||
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
|
||
|
||
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
|
||
|
||
public sealed class FakeTokenTrackerClient : ITokenTrackerClient
|
||
{
|
||
private readonly SemaphoreSlim _gate = new(0);
|
||
|
||
public TokenTrackerProbe Probe { get; set; } = new(true, "1.2.3", true, "v22.1.0", null);
|
||
public TokenTrackerRunResult ExportResult { get; set; } =
|
||
new(true, TokenTrackerFixtures.SessionsJson, null);
|
||
public TokenTrackerRunResult InstallResult { get; set; } = new(true, "added 1 package", null);
|
||
|
||
public int ExportCalls;
|
||
public int ProbeCalls;
|
||
public int InstallCalls;
|
||
public DateOnly? LastFrom;
|
||
public DateOnly? LastTo;
|
||
|
||
/// <summary>When true, <see cref="ExportAsync"/> blocks until <see cref="Release"/> is called.</summary>
|
||
public bool BlockExport { get; set; }
|
||
|
||
public void Release() => _gate.Release();
|
||
|
||
public Task<TokenTrackerProbe> ProbeAsync(CancellationToken ct = default)
|
||
{
|
||
Interlocked.Increment(ref ProbeCalls);
|
||
return Task.FromResult(Probe);
|
||
}
|
||
|
||
public async Task<TokenTrackerRunResult> ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default)
|
||
{
|
||
Interlocked.Increment(ref ExportCalls);
|
||
LastFrom = from;
|
||
LastTo = to;
|
||
if (BlockExport) await _gate.WaitAsync(ct);
|
||
return ExportResult;
|
||
}
|
||
|
||
public Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default)
|
||
{
|
||
Interlocked.Increment(ref InstallCalls);
|
||
output?.Report("added 1 package");
|
||
return Task.FromResult(InstallResult);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
```csharp
|
||
using ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
|
||
|
||
public sealed class TokenTrackerServiceTests
|
||
{
|
||
private static readonly DateTime Now = new(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc);
|
||
|
||
private static (TokenTrackerService Service, FakeTokenTrackerClient Client) Build(DateTime? now = null)
|
||
{
|
||
var client = new FakeTokenTrackerClient();
|
||
var clock = now ?? Now;
|
||
return (new TokenTrackerService(client, new TokenTrackerState(), () => clock), client);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task RefreshAsync_StoresParsedExport()
|
||
{
|
||
var (service, client) = Build();
|
||
|
||
await service.RefreshAsync();
|
||
|
||
Assert.Equal(1, client.ExportCalls);
|
||
Assert.Equal(4, service.State.Export!.Sessions.Count);
|
||
Assert.Null(service.State.LastError);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task RefreshAsync_RequestsTheConfiguredWindow()
|
||
{
|
||
var (service, client) = Build();
|
||
|
||
await service.RefreshAsync();
|
||
|
||
Assert.Equal(DateOnly.FromDateTime(Now.ToLocalTime()), client.LastTo);
|
||
Assert.Equal(
|
||
DateOnly.FromDateTime(Now.ToLocalTime()).AddDays(-TokenTrackerService.WindowDays),
|
||
client.LastFrom);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task RefreshAsync_Failure_KeepsLastGoodExport()
|
||
{
|
||
var (service, client) = Build();
|
||
await service.RefreshAsync();
|
||
|
||
client.ExportResult = new TokenTrackerRunResult(false, "", "tokentracker exited with code 1");
|
||
await service.RefreshAsync();
|
||
|
||
Assert.Equal(4, service.State.Export!.Sessions.Count);
|
||
Assert.Equal("tokentracker exited with code 1", service.State.LastError);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task RefreshAsync_UnparseableOutput_IsAnError()
|
||
{
|
||
var (service, client) = Build();
|
||
client.ExportResult = new TokenTrackerRunResult(true, "not json", null);
|
||
|
||
await service.RefreshAsync();
|
||
|
||
Assert.Null(service.State.Export);
|
||
Assert.NotNull(service.State.LastError);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task RefreshAsync_UnsupportedFormatVersion_IsAnErrorAndKeepsNoData()
|
||
{
|
||
var (service, client) = Build();
|
||
client.ExportResult = new TokenTrackerRunResult(true, TokenTrackerFixtures.UnsupportedVersionJson, null);
|
||
|
||
await service.RefreshAsync();
|
||
|
||
Assert.Null(service.State.Export);
|
||
Assert.Contains("99", service.State.LastError);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task EnsureFreshAsync_FreshExport_DoesNotFetchAgain()
|
||
{
|
||
var (service, client) = Build();
|
||
await service.RefreshAsync();
|
||
|
||
await service.EnsureFreshAsync(TimeSpan.FromMinutes(15));
|
||
|
||
Assert.Equal(1, client.ExportCalls);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task EnsureFreshAsync_NoExportYet_Fetches()
|
||
{
|
||
var (service, client) = Build();
|
||
|
||
await service.EnsureFreshAsync(TimeSpan.FromMinutes(15));
|
||
|
||
Assert.Equal(1, client.ExportCalls);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ConcurrentRefresh_RunsTheCliOnce()
|
||
{
|
||
var (service, client) = Build();
|
||
client.BlockExport = true;
|
||
|
||
var first = service.RefreshAsync();
|
||
var second = service.RefreshAsync();
|
||
client.Release();
|
||
await Task.WhenAll(first, second);
|
||
|
||
Assert.Equal(1, client.ExportCalls);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ProbeAsync_CachesUntilForced()
|
||
{
|
||
var (service, client) = Build();
|
||
|
||
await service.ProbeAsync();
|
||
await service.ProbeAsync();
|
||
Assert.Equal(1, client.ProbeCalls);
|
||
|
||
await service.ProbeAsync(force: true);
|
||
Assert.Equal(2, client.ProbeCalls);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task InstallAsync_ReprobesAfterwards()
|
||
{
|
||
var (service, client) = Build();
|
||
await service.ProbeAsync();
|
||
|
||
var result = await service.InstallAsync();
|
||
|
||
Assert.True(result.Ok);
|
||
Assert.Equal(1, client.InstallCalls);
|
||
Assert.Equal(2, client.ProbeCalls);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify it fails**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerServiceTests
|
||
```
|
||
|
||
Expected: Build-Fehler `CS0246: The type or namespace name 'TokenTrackerService' could not be found`.
|
||
|
||
- [ ] **Step 4: Write minimal implementation**
|
||
|
||
```csharp
|
||
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
|
||
|
||
namespace ClaudeDo.Worker.Usage.TokenTracker;
|
||
|
||
/// <summary>
|
||
/// Owns the cached export. One fetch covers <see cref="WindowDays"/> days because
|
||
/// <c>buildSessionAnalytics</c> walks the whole history regardless of <c>--from</c>/<c>--to</c>
|
||
/// (measured 11.5 s), so narrowing the request would buy nothing while making every range switch
|
||
/// pay the cost again.
|
||
/// </summary>
|
||
public sealed class TokenTrackerService
|
||
{
|
||
public const int WindowDays = 90;
|
||
|
||
private readonly ITokenTrackerClient _client;
|
||
private readonly Func<DateTime> _utcNow;
|
||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||
private TokenTrackerProbe? _probe;
|
||
|
||
public TokenTrackerService(ITokenTrackerClient client, TokenTrackerState state, Func<DateTime>? utcNow = null)
|
||
{
|
||
_client = client;
|
||
State = state;
|
||
_utcNow = utcNow ?? (() => DateTime.UtcNow);
|
||
}
|
||
|
||
public TokenTrackerState State { get; }
|
||
|
||
public async Task<TokenTrackerProbe> ProbeAsync(bool force = false, CancellationToken ct = default)
|
||
{
|
||
if (!force && _probe is { } cached) return cached;
|
||
|
||
var probe = await _client.ProbeAsync(ct);
|
||
_probe = probe;
|
||
return probe;
|
||
}
|
||
|
||
/// <summary>Fetches only when the cached export is missing or older than
|
||
/// <paramref name="maxAge"/>. Safe to call on every modal open.</summary>
|
||
public Task EnsureFreshAsync(TimeSpan maxAge, CancellationToken ct = default) =>
|
||
State.IsOlderThan(maxAge, _utcNow()) ? RefreshAsync(ct) : Task.CompletedTask;
|
||
|
||
public async Task RefreshAsync(CancellationToken ct = default)
|
||
{
|
||
// A refresh already in flight is the refresh this caller wanted — waiting for it and
|
||
// returning is correct, and it stops a burst of range switches from spawning processes.
|
||
if (!await _refreshLock.WaitAsync(0, ct))
|
||
{
|
||
await _refreshLock.WaitAsync(ct);
|
||
_refreshLock.Release();
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
var now = _utcNow();
|
||
var to = DateOnly.FromDateTime(now.ToLocalTime());
|
||
var from = to.AddDays(-WindowDays);
|
||
|
||
var run = await _client.ExportAsync(from, to, ct);
|
||
if (!run.Ok)
|
||
{
|
||
State.ReportFailure(run.Error ?? "TokenTracker export failed.", now);
|
||
return;
|
||
}
|
||
|
||
var export = TokenTrackerExportParser.Parse(run.StdOut, now);
|
||
if (export is null)
|
||
{
|
||
State.ReportFailure("TokenTracker returned output we could not parse.", now);
|
||
return;
|
||
}
|
||
|
||
if (export.FormatVersion != TokenTrackerExportParser.SupportedFormatVersion)
|
||
{
|
||
State.ReportFailure(
|
||
$"Unexpected TokenTracker export format v{export.FormatVersion} " +
|
||
$"(supported: v{TokenTrackerExportParser.SupportedFormatVersion}).",
|
||
now);
|
||
return;
|
||
}
|
||
|
||
State.ReportSuccess(export);
|
||
}
|
||
finally
|
||
{
|
||
_refreshLock.Release();
|
||
}
|
||
}
|
||
|
||
public async Task<TokenTrackerRunResult> InstallAsync(
|
||
IProgress<string>? output = null, CancellationToken ct = default)
|
||
{
|
||
var result = await _client.InstallAsync(output, ct);
|
||
await ProbeAsync(force: true, ct);
|
||
return result;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run test to verify it passes**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerServiceTests
|
||
```
|
||
|
||
Expected: PASS, 10 Tests.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerService.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/FakeTokenTrackerClient.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerServiceTests.cs
|
||
git commit -m "feat(usage): cache the TokenTracker export with single-flight refresh"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Hub-DTOs und Status-Methoden
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||
- Modify: `src/ClaudeDo.Worker/Program.cs`
|
||
|
||
- [ ] **Step 1: Extend the DTOs**
|
||
|
||
In `src/ClaudeDo.Worker/Hub/WorkerHub.cs` die beiden Records ersetzen (aktuell Zeile 161–179).
|
||
Die neuen Felder stehen hinten und sind nullable, damit SignalR-Clients ohne sie nicht brechen.
|
||
|
||
```csharp
|
||
public record ModelUsageRowDto(
|
||
DateOnly Date,
|
||
string Model,
|
||
string Scope,
|
||
long InputTokens,
|
||
long OutputTokens,
|
||
long CacheReadTokens,
|
||
long CacheCreationTokens,
|
||
int Messages,
|
||
double? CostUsd = null);
|
||
|
||
public record TaskUsageRowDto(
|
||
string TaskId,
|
||
string TaskTitle,
|
||
string ListId,
|
||
string ListName,
|
||
string? Model,
|
||
int Runs,
|
||
long TokensIn,
|
||
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);
|
||
```
|
||
|
||
- [ ] **Step 2: Add the service to the hub constructor**
|
||
|
||
`_tokenTracker` als Feld neben `_usageReader` (bei Zeile 229) ergänzen:
|
||
|
||
```csharp
|
||
private readonly ITranscriptUsageReader? _usageReader;
|
||
private readonly UsageMonitorService? _usageMonitor;
|
||
private readonly TokenTrackerService? _tokenTracker;
|
||
```
|
||
|
||
Im Konstruktor den optionalen Parameter **hinten** anhängen (nach `usageMonitor`, bei Zeile 263)
|
||
und zuweisen (bei Zeile 297):
|
||
|
||
```csharp
|
||
TokenTrackerService? tokenTracker = null)
|
||
```
|
||
```csharp
|
||
_tokenTracker = tokenTracker;
|
||
```
|
||
|
||
Oben `using ClaudeDo.Worker.Usage.TokenTracker;` ergänzen.
|
||
|
||
- [ ] **Step 3: Add the three hub methods**
|
||
|
||
Direkt vor `GetModelUsage` (Zeile 1180) einfügen:
|
||
|
||
```csharp
|
||
public Task<TokenTrackerStatusDto> 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<TokenTrackerStatusDto> 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<TokenTrackerStatusDto> InstallTokenTracker() => HubGuard(async () =>
|
||
{
|
||
if (_tokenTracker is null)
|
||
throw new InvalidOperationException("TokenTracker is not configured.");
|
||
|
||
var progress = new Progress<string>(line => _broadcaster.WorkerLog("Information", line));
|
||
var result = await _tokenTracker.InstallAsync(progress, Context.ConnectionAborted);
|
||
if (!result.Ok)
|
||
_broadcaster.WorkerLog("Error", result.Error ?? "TokenTracker install failed.");
|
||
|
||
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);
|
||
}
|
||
```
|
||
|
||
⚠️ Prüfe vor dem Bauen den echten Namen des Broadcaster-Feldes und die Signatur von
|
||
`WorkerLog` — grep `_broadcaster.WorkerLog` in `WorkerHub.cs` und übernimm die dort verwendete
|
||
Form. Ebenso die genaue Form von `HubGuard` (ob es `Task<T>` oder `Func<Task<T>>` nimmt) aus
|
||
`GetModelUsage` direkt darunter.
|
||
|
||
- [ ] **Step 4: Register in DI**
|
||
|
||
In `src/ClaudeDo.Worker/Program.cs` bei den anderen Usage-Registrierungen (nach Zeile 236)
|
||
einfügen und oben `using ClaudeDo.Worker.Usage.TokenTracker;` plus
|
||
`using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;` ergänzen:
|
||
|
||
```csharp
|
||
// 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<ITokenTrackerClient, TokenTrackerClient>();
|
||
builder.Services.AddSingleton<TokenTrackerState>();
|
||
builder.Services.AddSingleton<TokenTrackerService>();
|
||
```
|
||
|
||
- [ ] **Step 5: Build**
|
||
|
||
```bash
|
||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||
```
|
||
|
||
Expected: Build succeeded, 0 Errors.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/Program.cs
|
||
git commit -m "feat(usage): expose TokenTracker status, refresh and install on the hub"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: GetModelUsage und GetTaskUsage umstellen
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||
|
||
- [ ] **Step 1: Replace GetModelUsage**
|
||
|
||
Die bestehende Methode (Zeile 1180–1190) komplett ersetzen:
|
||
|
||
```csharp
|
||
/// <summary>
|
||
/// 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 a fetch takes ~11 s and blocking a range switch on that is worse
|
||
/// than showing a timestamped older number.
|
||
/// </summary>
|
||
public Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsage(DateOnly from, DateOnly to) => HubGuard(async () =>
|
||
{
|
||
if (_tokenTracker is null)
|
||
return (IReadOnlyList<ModelUsageRowDto>)Array.Empty<ModelUsageRowDto>();
|
||
|
||
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<ModelUsageRowDto>)Array.Empty<ModelUsageRowDto>();
|
||
|
||
var claudeDoHashes = await ClaudeDoSessionHashesAsync();
|
||
|
||
return (IReadOnlyList<ModelUsageRowDto>)TokenTrackerAggregator
|
||
.ByModel(export.Sessions, from, to, claudeDoHashes)
|
||
.Select(r => new ModelUsageRowDto(
|
||
r.Date, r.Model, r.Scope,
|
||
r.InputTokens, r.OutputTokens, r.CacheReadTokens, r.CacheCreationTokens,
|
||
r.Sessions, r.CostUsd))
|
||
.ToList();
|
||
});
|
||
|
||
/// <summary>Hashes of every session one of our runs owns — the scope split rides on set
|
||
/// membership rather than the export's own <c>project_key</c>, which falls back to a bare
|
||
/// worktree GUID for exactly the sessions we care about.</summary>
|
||
private async Task<HashSet<string>> 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()!;
|
||
}
|
||
```
|
||
|
||
⚠️ Prüfe den echten Property-Namen von `TaskRunEntity.SessionId` (grep `SessionId` in
|
||
`src/ClaudeDo.Data/Models/`) und passe an, falls er anders heißt.
|
||
|
||
- [ ] **Step 2: Enrich GetTaskUsage**
|
||
|
||
In der bestehenden Methode (Zeile 1192–1232) die `Select`-Projektion um die Extras erweitern.
|
||
Vor dem `return` einfügen:
|
||
|
||
```csharp
|
||
var export = _tokenTracker?.State.Export;
|
||
```
|
||
|
||
und die Projektion ersetzen:
|
||
|
||
```csharp
|
||
.Select(g =>
|
||
{
|
||
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,
|
||
task.ListId,
|
||
listName,
|
||
latestModel,
|
||
g.Count(),
|
||
g.Sum(r => (long)(r.TokensIn ?? 0)),
|
||
g.Sum(r => (long)(r.TokensOut ?? 0)),
|
||
extras?.CostUsd,
|
||
extras?.Retries,
|
||
extras?.Productive,
|
||
extras?.OneShot);
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Build**
|
||
|
||
```bash
|
||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||
```
|
||
|
||
Expected: Build succeeded, 0 Errors.
|
||
|
||
- [ ] **Step 4: Run the whole worker suite**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||
```
|
||
|
||
Expected: alle Tests grün außer denen aus `TranscriptUsageReaderTests`, die `ReadAsync` betreffen
|
||
— die räumt Task 11 auf. Falls dort noch alles grün ist, ist das auch in Ordnung; `ReadAsync`
|
||
existiert an dieser Stelle noch.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
|
||
git commit -m "feat(usage): serve model and task usage from the TokenTracker export"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: TranscriptUsageReader abspecken
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs`
|
||
- Modify: `src/ClaudeDo.Worker/Usage/Interfaces/ITranscriptUsageReader.cs`
|
||
- Modify: `src/ClaudeDo.Worker/Usage/UsageModels.cs`
|
||
- Modify: `tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs`
|
||
|
||
- [ ] **Step 1: Shrink the interface**
|
||
|
||
`src/ClaudeDo.Worker/Usage/Interfaces/ITranscriptUsageReader.cs` vollständig ersetzen:
|
||
|
||
```csharp
|
||
namespace ClaudeDo.Worker.Usage.Interfaces;
|
||
|
||
public interface ITranscriptUsageReader
|
||
{
|
||
/// <summary>Cumulative raw token totals for one session's transcript file
|
||
/// (located by <c>{sessionId}.jsonl</c> under the projects root), or null when
|
||
/// no matching transcript file can be found or read.</summary>
|
||
Task<SessionUsageTotals?> ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Remove the aggregation path from the reader**
|
||
|
||
In `src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs` entfernen:
|
||
|
||
- die Methode `ReadAsync` (Zeilen 23–69)
|
||
- `ResolveScope`, `IsUnderRoot`, `NormalizePath` (Zeilen 159–182)
|
||
- die Klasse `Accumulator` (Zeilen 184–188)
|
||
- die Felder `_centralRoot` und `_sandboxRoot` samt ihrer Zuweisungen im Konstruktor
|
||
|
||
Der Konstruktor wird damit zu:
|
||
|
||
```csharp
|
||
public TranscriptUsageReader(WorkerConfig cfg, string? projectsRoot = null)
|
||
{
|
||
_projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects");
|
||
}
|
||
```
|
||
|
||
Der `cfg`-Parameter bleibt erhalten, damit die DI-Registrierung unverändert funktioniert; setze
|
||
darüber einen Kommentar:
|
||
|
||
```csharp
|
||
// cfg is unused since the model/scope aggregation moved to TokenTracker, but the parameter
|
||
// stays so the DI registration and every existing call site keep working.
|
||
```
|
||
|
||
`UsageMessageRecord` verliert Datum und Scope, weil `ReadSessionTotalsAsync` beides nicht liest:
|
||
|
||
```csharp
|
||
private sealed record UsageMessageRecord(
|
||
long InputTokens, long OutputTokens, long CacheReadTokens, long CacheCreationTokens,
|
||
string DedupeKey);
|
||
```
|
||
|
||
Und in `ReadFile` entfällt entsprechend die Datums-/Scope-Berechnung; der Push wird:
|
||
|
||
```csharp
|
||
records.Add(new UsageMessageRecord(input, output, cacheRead, cacheCreation, dedupeKey));
|
||
```
|
||
|
||
Die `timestamp`- und `model`-Prüfungen **bleiben** stehen — sie filtern Nicht-Assistant-Zeilen und
|
||
`<synthetic>`-Modelle, was für die Session-Summen weiterhin richtig ist. Die lokale Variable
|
||
`date` und `var cwd = …` fallen weg (sonst Compiler-Warnung „unused").
|
||
|
||
- [ ] **Step 3: Drop the dead models**
|
||
|
||
In `src/ClaudeDo.Worker/Usage/UsageModels.cs` `UsageScope` (Zeilen 20–24) und
|
||
`UsageAggregateRow` (Zeilen 26–34) löschen.
|
||
|
||
- [ ] **Step 4: Prune the reader tests**
|
||
|
||
In `tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs` jeden Test löschen, der
|
||
`ReadAsync` aufruft, `UsageAggregateRow` oder `UsageScope` verwendet. Die Tests für
|
||
`ReadSessionTotalsAsync` bleiben unverändert. Ist danach keine Testmethode mehr übrig, lösche die
|
||
Datei ganz.
|
||
|
||
- [ ] **Step 5: Build and run the suite**
|
||
|
||
```bash
|
||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||
```
|
||
|
||
Expected: Build succeeded; alle Tests grün.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs src/ClaudeDo.Worker/Usage/Interfaces/ITranscriptUsageReader.cs src/ClaudeDo.Worker/Usage/UsageModels.cs tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs
|
||
git commit -m "refactor(usage): drop the transcript aggregation path in favour of TokenTracker"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: UI-Client-Oberfläche
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
||
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||
|
||
- [ ] **Step 1: Extend the interface**
|
||
|
||
Nach `GetTaskUsageAsync` (Zeile 209) einfügen:
|
||
|
||
```csharp
|
||
Task<TokenTrackerStatusDto?> GetTokenTrackerStatusAsync();
|
||
Task<TokenTrackerStatusDto?> RefreshTokenTrackerAsync();
|
||
Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync();
|
||
```
|
||
|
||
- [ ] **Step 2: Implement them**
|
||
|
||
In `src/ClaudeDo.Ui/Services/WorkerClient.cs` nach `GetTaskUsageAsync` (Zeile 690) einfügen:
|
||
|
||
```csharp
|
||
public Task<TokenTrackerStatusDto?> GetTokenTrackerStatusAsync()
|
||
=> TryInvokeAsync<TokenTrackerStatusDto>("GetTokenTrackerStatus");
|
||
|
||
public Task<TokenTrackerStatusDto?> RefreshTokenTrackerAsync()
|
||
=> TryInvokeAsync<TokenTrackerStatusDto>("RefreshTokenTracker");
|
||
|
||
public Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync()
|
||
=> TryInvokeAsync<TokenTrackerStatusDto>("InstallTokenTracker");
|
||
```
|
||
|
||
- [ ] **Step 3: Extend the DTOs**
|
||
|
||
`ModelUsageRowDto` (Zeile 841) und `TaskUsageRowDto` (Zeile 851) müssen die Worker-Records
|
||
spiegeln. Ergänze in beiden dieselben neuen, optionalen Felder wie in Task 9 Schritt 1, und
|
||
füge danach den neuen Record an:
|
||
|
||
```csharp
|
||
public sealed record TokenTrackerStatusDto(
|
||
bool Installed,
|
||
string? Version,
|
||
bool NodeOk,
|
||
string? NodeVersion,
|
||
DateTime? LastFetchedUtc,
|
||
string? LastError,
|
||
int? FormatVersion,
|
||
int SessionCount);
|
||
```
|
||
|
||
- [ ] **Step 4: Build**
|
||
|
||
```bash
|
||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||
```
|
||
|
||
Expected: Fehler in `ClaudeDo.Ui.Tests`-Fakes sind hier noch nicht sichtbar (anderes Projekt);
|
||
`ClaudeDo.App` muss grün bauen.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs
|
||
git commit -m "feat(ui): add TokenTracker status, refresh and install to the worker client"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Localization-Keys
|
||
|
||
Beide Dateien müssen in Parität bleiben — `Localization.Tests` erzwingt das.
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Localization/locales/en.json`
|
||
- Modify: `src/ClaudeDo.Localization/locales/de.json`
|
||
|
||
- [ ] **Step 1: Add the English keys**
|
||
|
||
In `en.json` im Block `"usageMonitor"` (endet Zeile 532 mit `"columnTotal": "TOTAL"`) das Komma
|
||
ergänzen und anhängen:
|
||
|
||
```json
|
||
"columnTotal": "TOTAL",
|
||
"columnCost": "COST",
|
||
"columnRetries": "RETRIES",
|
||
"analyticsStampFormat": "Analytics as of {0}",
|
||
"analyticsNever": "Analytics not loaded yet",
|
||
"analyticsRefresh": "Refresh analytics",
|
||
"analyticsTotalsFormat": "{0} tokens · ${1}",
|
||
"ttMissingTitle": "Token analytics need TokenTracker",
|
||
"ttMissingBody": "TokenTracker (MIT) reads your local Claude transcripts and never sees prompts. ClaudeDo only calls its local export — it does not run `init`, installs no hooks and enables no cloud sync.",
|
||
"ttInstall": "Install TokenTracker",
|
||
"ttNodeHintFormat": "Node 20 or newer is required (found: {0}).",
|
||
"ttNodeMissing": "Node 20 or newer is required, but no Node runtime was found.",
|
||
"ttErrorFormat": "TokenTracker problem: {0}"
|
||
```
|
||
|
||
Im Block `vm.usageMonitor` (Zeile 735–738) nach `thresholdSaveFailed` ergänzen:
|
||
|
||
```json
|
||
"thresholdSaveFailed": "Couldn't save the threshold: {0}",
|
||
"installStarted": "Installing TokenTracker — watch the log strip for progress.",
|
||
"installFailed": "Couldn't install TokenTracker: {0}",
|
||
"analyticsRefreshFailed": "Couldn't refresh the analytics: {0}"
|
||
```
|
||
|
||
- [ ] **Step 2: Add the German keys**
|
||
|
||
In `de.json` an denselben zwei Stellen:
|
||
|
||
```json
|
||
"columnTotal": "GESAMT",
|
||
"columnCost": "KOSTEN",
|
||
"columnRetries": "RETRIES",
|
||
"analyticsStampFormat": "Auswertung: Stand {0}",
|
||
"analyticsNever": "Auswertung noch nicht geladen",
|
||
"analyticsRefresh": "Auswertung aktualisieren",
|
||
"analyticsTotalsFormat": "{0} Tokens · {1} $",
|
||
"ttMissingTitle": "Die Token-Auswertung braucht TokenTracker",
|
||
"ttMissingBody": "TokenTracker (MIT) liest deine lokalen Claude-Transkripte und sieht keine Prompts. ClaudeDo ruft nur den lokalen Export auf — kein `init`, keine Hooks, kein Cloud-Sync.",
|
||
"ttInstall": "TokenTracker installieren",
|
||
"ttNodeHintFormat": "Node 20 oder neuer wird benötigt (gefunden: {0}).",
|
||
"ttNodeMissing": "Node 20 oder neuer wird benötigt, es wurde aber kein Node gefunden.",
|
||
"ttErrorFormat": "TokenTracker-Problem: {0}"
|
||
```
|
||
|
||
```json
|
||
"thresholdSaveFailed": "Der Schwellwert konnte nicht gespeichert werden: {0}",
|
||
"installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.",
|
||
"installFailed": "TokenTracker konnte nicht installiert werden: {0}",
|
||
"analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}"
|
||
```
|
||
|
||
- [ ] **Step 3: Run the parity test**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||
```
|
||
|
||
Expected: PASS. Bei einem Parity-Fail nennt die Meldung den fehlenden Key — den in der anderen
|
||
Datei nachtragen.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
|
||
git commit -m "feat(i18n): add TokenTracker analytics strings in en and de"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Usage-Monitor-ViewModel
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs`
|
||
|
||
- [ ] **Step 1: Extend the display rows**
|
||
|
||
Am Dateiende (Zeilen 492–517) die zwei Records ersetzen:
|
||
|
||
```csharp
|
||
public sealed record ModelUsageDisplayRow(
|
||
string Model,
|
||
long ClaudeDoInputTokens,
|
||
long ClaudeDoOutputTokens,
|
||
long ClaudeDoCacheTokens,
|
||
long OtherInputTokens,
|
||
long OtherOutputTokens,
|
||
long OtherCacheTokens,
|
||
double CostUsd)
|
||
{
|
||
public double SharePercent { get; init; }
|
||
public long ClaudeDoTotal => ClaudeDoInputTokens + ClaudeDoOutputTokens + ClaudeDoCacheTokens;
|
||
public long OtherTotal => OtherInputTokens + OtherOutputTokens + OtherCacheTokens;
|
||
public long GrandTotal => ClaudeDoTotal + OtherTotal;
|
||
public string CostText => CostUsd > 0 ? CostUsd.ToString("0.00") : "—";
|
||
}
|
||
|
||
public sealed record TaskUsageDisplayRow(
|
||
string TaskId,
|
||
string TaskTitle,
|
||
string ListName,
|
||
string? Model,
|
||
int Runs,
|
||
long TokensIn,
|
||
long TokensOut,
|
||
double? CostUsd,
|
||
int? Retries)
|
||
{
|
||
public long TotalTokens => TokensIn + TokensOut;
|
||
public string CostText => CostUsd is > 0 ? CostUsd.Value.ToString("0.00") : "—";
|
||
public string RetriesText => Retries?.ToString() ?? "—";
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add the TokenTracker state to the VM**
|
||
|
||
Nach `_taskRows` (Zeile 51) einfügen:
|
||
|
||
```csharp
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(TokenTrackerMissing))]
|
||
[NotifyPropertyChangedFor(nameof(ShowInstallButton))]
|
||
[NotifyPropertyChangedFor(nameof(NodeHintText))]
|
||
[NotifyPropertyChangedFor(nameof(TokenTrackerErrorText))]
|
||
[NotifyPropertyChangedFor(nameof(HasTokenTrackerError))]
|
||
[NotifyPropertyChangedFor(nameof(AnalyticsStampText))]
|
||
private TokenTrackerStatusDto? _tokenTracker;
|
||
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(TotalsText))]
|
||
private double _totalCostUsd;
|
||
|
||
[ObservableProperty] private bool _isInstalling;
|
||
```
|
||
|
||
Nach `TasksEmpty` (Zeile 54) die abgeleiteten Properties:
|
||
|
||
```csharp
|
||
public bool TokenTrackerMissing => TokenTracker is { Installed: false };
|
||
public bool ShowInstallButton => TokenTracker is { Installed: false, NodeOk: true } && !IsInstalling;
|
||
|
||
public string NodeHintText => TokenTracker switch
|
||
{
|
||
{ NodeOk: true } => "",
|
||
{ NodeVersion: { Length: > 0 } v } => Loc.T("modals.usageMonitor.ttNodeHintFormat", v),
|
||
_ => Loc.T("modals.usageMonitor.ttNodeMissing"),
|
||
};
|
||
|
||
public bool HasTokenTrackerError => TokenTracker?.LastError is { Length: > 0 };
|
||
public string TokenTrackerErrorText => TokenTracker?.LastError is { Length: > 0 } e
|
||
? Loc.T("modals.usageMonitor.ttErrorFormat", e)
|
||
: "";
|
||
|
||
public string AnalyticsStampText => TokenTracker?.LastFetchedUtc is { } t
|
||
? Loc.T("modals.usageMonitor.analyticsStampFormat", t.ToLocalTime().ToString("HH:mm"))
|
||
: Loc.T("modals.usageMonitor.analyticsNever");
|
||
|
||
public string TotalsText => Loc.T(
|
||
"modals.usageMonitor.analyticsTotalsFormat",
|
||
ModelRows.Sum(r => r.GrandTotal).ToString("N0"),
|
||
TotalCostUsd.ToString("0.00"));
|
||
```
|
||
|
||
`ModelRows` muss `TotalsText` mit auffrischen — ergänze bei `_modelRows` (Zeile 45–47) ein
|
||
weiteres Attribut:
|
||
|
||
```csharp
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(ModelsEmpty))]
|
||
[NotifyPropertyChangedFor(nameof(TotalsText))]
|
||
private IReadOnlyList<ModelUsageDisplayRow> _modelRows = Array.Empty<ModelUsageDisplayRow>();
|
||
```
|
||
|
||
Und `IsInstalling` muss den Button-Zustand mitziehen:
|
||
|
||
```csharp
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(ShowInstallButton))]
|
||
private bool _isInstalling;
|
||
```
|
||
|
||
(also die obige `_isInstalling`-Deklaration durch diese ersetzen).
|
||
|
||
- [ ] **Step 3: Load the status and carry the cost through**
|
||
|
||
`LoadUsageDataAsync` (Zeile 175–196) ersetzen:
|
||
|
||
```csharp
|
||
private async Task LoadUsageDataAsync()
|
||
{
|
||
if (!RangeValid) return;
|
||
IsBusy = true;
|
||
try
|
||
{
|
||
var from = DateOnly.FromDateTime(StartDate!.Value);
|
||
var to = DateOnly.FromDateTime(EndDate!.Value);
|
||
var modelRows = await _worker.GetModelUsageAsync(from, to);
|
||
var taskRows = await _worker.GetTaskUsageAsync(from, to);
|
||
ModelRows = BuildModelDisplayRows(modelRows);
|
||
TotalCostUsd = modelRows.Sum(r => r.CostUsd ?? 0);
|
||
TaskRows = taskRows
|
||
.Select(r => new TaskUsageDisplayRow(
|
||
r.TaskId, r.TaskTitle, r.ListName, r.Model, r.Runs, r.TokensIn, r.TokensOut,
|
||
r.CostUsd, r.Retries))
|
||
.OrderByDescending(r => r.TotalTokens)
|
||
.ToList();
|
||
TokenTracker = await _worker.GetTokenTrackerStatusAsync();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message));
|
||
}
|
||
finally { IsBusy = false; }
|
||
}
|
||
```
|
||
|
||
`BuildModelDisplayRows` (Zeile 300–326) um die Kosten erweitern: `double cost = 0;` neben den
|
||
sechs Token-Summen deklarieren, in der Schleife `cost += row.CostUsd ?? 0;` addieren und den
|
||
Konstruktoraufruf zu
|
||
|
||
```csharp
|
||
built.Add(new ModelUsageDisplayRow(group.Key, cdIn, cdOut, cdCache, otIn, otOut, otCache, cost));
|
||
```
|
||
|
||
ändern.
|
||
|
||
- [ ] **Step 4: Add the two commands**
|
||
|
||
Nach `SetPreset30Days` (Zeile 155) einfügen:
|
||
|
||
```csharp
|
||
[RelayCommand]
|
||
private async Task RefreshAnalytics()
|
||
{
|
||
try
|
||
{
|
||
TokenTracker = await _worker.RefreshTokenTrackerAsync();
|
||
await LoadUsageDataAsync();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.analyticsRefreshFailed", ex.Message));
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task InstallTokenTracker()
|
||
{
|
||
IsInstalling = true;
|
||
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.installStarted"));
|
||
try
|
||
{
|
||
TokenTracker = await _worker.InstallTokenTrackerAsync();
|
||
if (TokenTracker is { Installed: true }) await RefreshAnalytics();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.installFailed", ex.Message));
|
||
}
|
||
finally { IsInstalling = false; }
|
||
}
|
||
```
|
||
|
||
`ErrorReported` ist der Kanal, den die Shell in `FlashFooterError` hängt — deshalb geht auch die
|
||
Erfolgsmeldung „Installation läuft" dort durch, statt in einem eigenen Banner zu landen.
|
||
|
||
- [ ] **Step 5: Build**
|
||
|
||
```bash
|
||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||
```
|
||
|
||
Expected: Build succeeded, 0 Errors.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs
|
||
git commit -m "feat(ui): surface TokenTracker status and cost in the usage monitor"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Usage-Monitor-View
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml`
|
||
|
||
- [ ] **Step 1: Add the hint card and the analytics header**
|
||
|
||
Direkt vor `<TabControl …>` (Zeile 150) einfügen:
|
||
|
||
```xml
|
||
<!-- TokenTracker hint: shown instead of the analytics header while the CLI is missing -->
|
||
<Border DockPanel.Dock="Top" Margin="20,12,20,0"
|
||
IsVisible="{Binding TokenTrackerMissing}"
|
||
Background="{DynamicResource DeepBrush}"
|
||
BorderBrush="{DynamicResource StatusReviewBrush}"
|
||
BorderThickness="1" CornerRadius="6" Padding="12,10">
|
||
<StackPanel Spacing="6">
|
||
<TextBlock Classes="title" Text="{loc:Tr modals.usageMonitor.ttMissingTitle}"/>
|
||
<TextBlock Classes="meta" TextWrapping="Wrap"
|
||
Text="{loc:Tr modals.usageMonitor.ttMissingBody}"/>
|
||
<TextBlock Classes="meta" TextWrapping="Wrap"
|
||
IsVisible="{Binding !ShowInstallButton}"
|
||
Text="{Binding NodeHintText}"/>
|
||
<TextBlock Classes="meta" TextWrapping="Wrap"
|
||
IsVisible="{Binding HasTokenTrackerError}"
|
||
Text="{Binding TokenTrackerErrorText}"/>
|
||
<Button Classes="btn" HorizontalAlignment="Left"
|
||
IsVisible="{Binding ShowInstallButton}"
|
||
Content="{loc:Tr modals.usageMonitor.ttInstall}"
|
||
Command="{Binding InstallTokenTrackerCommand}"/>
|
||
<Ellipse Classes="spinner" Width="14" Height="14" HorizontalAlignment="Left"
|
||
IsVisible="{Binding IsInstalling}"/>
|
||
</StackPanel>
|
||
</Border>
|
||
|
||
<!-- Analytics header: totals, freshness stamp, manual refresh -->
|
||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="12"
|
||
Margin="20,12,20,0" VerticalAlignment="Center"
|
||
IsVisible="{Binding !TokenTrackerMissing}">
|
||
<TextBlock Classes="title" VerticalAlignment="Center" Text="{Binding TotalsText}"/>
|
||
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding AnalyticsStampText}"/>
|
||
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.analyticsRefresh}"
|
||
Command="{Binding RefreshAnalyticsCommand}"/>
|
||
</StackPanel>
|
||
```
|
||
|
||
- [ ] **Step 2: Add the cost column to the model table**
|
||
|
||
Im Models-Tab die drei `ColumnDefinitions="*,80,80,80,80,80,80,60"` (Zeilen 158 und 172) jeweils
|
||
auf `"*,80,80,80,80,80,80,60,80"` ändern, im Header nach der SHARE-Spalte ergänzen:
|
||
|
||
```xml
|
||
<TextBlock Grid.Column="8" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnCost}"/>
|
||
```
|
||
|
||
und in der Zeilenvorlage nach der Share-Zelle:
|
||
|
||
```xml
|
||
<TextBlock Grid.Column="8" Classes="meta" Text="{Binding CostText}"/>
|
||
```
|
||
|
||
- [ ] **Step 3: Add cost and retries to the task table**
|
||
|
||
Im Tasks-Tab beide `ColumnDefinitions="*,110,90,60,80,80,80"` (Zeilen 195 und 208) auf
|
||
`"*,110,90,60,80,80,80,80,70"` ändern, im Header nach TOTAL ergänzen:
|
||
|
||
```xml
|
||
<TextBlock Grid.Column="7" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnCost}"/>
|
||
<TextBlock Grid.Column="8" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnRetries}"/>
|
||
```
|
||
|
||
und in der Zeilenvorlage nach der Total-Zelle:
|
||
|
||
```xml
|
||
<TextBlock Grid.Column="7" Classes="meta" Text="{Binding CostText}"/>
|
||
<TextBlock Grid.Column="8" Classes="meta" Text="{Binding RetriesText}"/>
|
||
```
|
||
|
||
- [ ] **Step 4: Build**
|
||
|
||
```bash
|
||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||
```
|
||
|
||
Expected: Build succeeded. Compiled bindings sind streng — ein Tippfehler in einem Property-Namen
|
||
bricht hier den Build, nicht erst zur Laufzeit.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml
|
||
git commit -m "feat(ui): show cost columns and the TokenTracker hint card in the usage monitor"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Fakes nachziehen und alles bauen
|
||
|
||
Das Erweitern von `IWorkerClient` bricht die handgeschriebenen Fakes. Diese Aufgabe schließt das
|
||
und stellt sicher, dass alle sechs Testprojekte grün sind.
|
||
|
||
**Files:**
|
||
- Modify: Fakes in `tests/ClaudeDo.Ui.Tests/` (Pfade in Schritt 1 ermitteln)
|
||
|
||
- [ ] **Step 1: Find the fakes**
|
||
|
||
```bash
|
||
grep -rln "IWorkerClient" tests/
|
||
```
|
||
|
||
Jede Datei, die `IWorkerClient` implementiert, braucht die drei neuen Methoden.
|
||
|
||
- [ ] **Step 2: Add the members to every fake**
|
||
|
||
In jedem gefundenen Fake ergänzen (Rückgabe „nicht installiert", damit bestehende Tests den
|
||
Hinweis-Pfad sehen und nicht in Nullreferenzen laufen):
|
||
|
||
```csharp
|
||
public Task<TokenTrackerStatusDto?> GetTokenTrackerStatusAsync() =>
|
||
Task.FromResult<TokenTrackerStatusDto?>(new TokenTrackerStatusDto(
|
||
Installed: false, Version: null, NodeOk: true, NodeVersion: "v22.0.0",
|
||
LastFetchedUtc: null, LastError: null, FormatVersion: null, SessionCount: 0));
|
||
|
||
public Task<TokenTrackerStatusDto?> RefreshTokenTrackerAsync() => GetTokenTrackerStatusAsync();
|
||
|
||
public Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync() => GetTokenTrackerStatusAsync();
|
||
```
|
||
|
||
- [ ] **Step 3: Build everything**
|
||
|
||
```bash
|
||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||
```
|
||
|
||
Expected: beide Build succeeded, 0 Errors.
|
||
|
||
- [ ] **Step 4: Run all six suites**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
|
||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||
dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj -c Release
|
||
dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj -c Release
|
||
```
|
||
|
||
Expected: alle grün. **Wichtig:** `Ui.Tests` hat eine bekannte Reihenfolge-Abhängigkeit — schlägt
|
||
dort ein Test fehl, lauf ihn einzeln nach (`--filter FullyQualifiedName~<TestName>`). Grün im
|
||
Alleingang heißt Flakiness, nicht Regression; melde es als solches statt es zu „fixen".
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/
|
||
git commit -m "test(ui): teach the worker-client fakes about TokenTracker status"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Dokumentation
|
||
|
||
**Files:**
|
||
- Modify: `docs/explore-notes/usage-monitoring.md`
|
||
- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
|
||
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
|
||
|
||
- [ ] **Step 1: Update the explore note**
|
||
|
||
In `docs/explore-notes/usage-monitoring.md`:
|
||
|
||
- Kopfzeile „Last verified against commit" auf den aktuellen HEAD setzen
|
||
(`git rev-parse --short HEAD`) und das Datum auf den Umsetzungstag.
|
||
- Im Abschnitt *Components (`Usage/`)* die Zeile zu `TranscriptUsageReader` auf
|
||
„Reads one session's cumulative totals for per-run accounting; **no** aggregation" ändern.
|
||
- Einen neuen Abschnitt **„Analytics via TokenTracker"** ergänzen mit: Datenquelle
|
||
(`tokentracker sessions --no-git --format json`), dem `session_hash`-Join
|
||
(`sha256("claude\0"+id)[0..24]`), dem 90-Tage-Fetch mit lokaler Range-Filterung, der
|
||
15-Minuten-Frische-Regel, dem Verhalten bei fehlendem CLI, der Versionsprüfung gegen
|
||
`SupportedFormatVersion`, und der ausdrücklichen Feststellung, dass Limits/Gate/Throttle
|
||
**nicht** von TokenTracker kommen.
|
||
- Im Abschnitt *Per-run token accounting* klarstellen, dass `ReadSessionTotalsAsync` bleibt.
|
||
- Im Abschnitt *Hub surface* `GetModelUsage`/`GetTaskUsage` neu beschreiben und die drei neuen
|
||
Methoden ergänzen.
|
||
|
||
- [ ] **Step 2: Update the Worker CLAUDE.md**
|
||
|
||
Im Abschnitt *Folder Layout* die `Usage/`-Zeile erweitern:
|
||
|
||
```
|
||
Usage/ — OAuth usage monitor, gate, throttle, per-session token reader;
|
||
TokenTracker/ = the external analytics backend (cost + per-model/per-task breakdown)
|
||
```
|
||
|
||
- [ ] **Step 3: Update the Ui CLAUDE.md**
|
||
|
||
In der Modal-VM-Tabelle die `UsageMonitorModalViewModel`-Zeile um einen Satz ergänzen: der
|
||
Analytics-Teil (Modelle/Tasks/Kosten) kommt aus dem TokenTracker-Export, zeigt bei fehlendem CLI
|
||
eine Hinweis-Karte mit Install-Button, und Gauges/Gate bleiben davon unberührt.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add docs/explore-notes/usage-monitoring.md src/ClaudeDo.Worker/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md
|
||
git commit -m "docs(usage): document TokenTracker as the analytics backend"
|
||
```
|
||
|
||
---
|
||
|
||
## Offene Verifikationspunkte (nicht Teil der Tasks)
|
||
|
||
Diese drei Punkte kann kein Test abdecken — sie gehören in die Übergabe an den Nutzer:
|
||
|
||
1. **Visueller Pass** über den umgebauten Analytics-Block, die Kostenspalten und die Hinweis-Karte,
|
||
in **beiden** Zuständen (TokenTracker installiert / nicht installiert). Nie behaupten, die UI
|
||
funktioniere, ohne sie gesehen zu haben.
|
||
2. **Echter Durchlauf ohne TokenTracker:** Probe → Hinweis-Karte → Install-Button → Re-Probe →
|
||
erste Auswertung. Zum Nachstellen `npm rm -g tokentracker-cli` auf einer Maschine, auf der es
|
||
niemandem wehtut — **nicht** auf der Entwicklungsmaschine, dort ist das Tool in Benutzung.
|
||
3. **Plausibilitätsvergleich:** einmal einen 7-Tage-Range in der App gegen `tokentracker sessions`
|
||
von Hand vergleichen und prüfen, dass der ClaudeDo/Other-Split und die Kostensumme passen.
|