diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/SessionHash.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/SessionHash.cs new file mode 100644 index 00000000..cf164c8e --- /dev/null +++ b/src/ClaudeDo.Worker/Usage/TokenTracker/SessionHash.cs @@ -0,0 +1,25 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ClaudeDo.Worker.Usage.TokenTracker; + +/// +/// Reproduces TokenTracker's session identity so its export rows can be joined onto our +/// task_runs.session_id. The formula is sha256(source + "\0" + id), hex, first +/// 24 chars (TokenTracker's lib/session-analytics.js, sessionHash()). The export +/// never carries the raw session id, so this is the only way to attribute a row to a task. +/// Verified against a real export on 2026-08-24 — the test vectors are the proof, don't touch them. +/// +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]; + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/SessionHashTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/SessionHashTests.cs new file mode 100644 index 00000000..9ee6cc93 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/SessionHashTests.cs @@ -0,0 +1,33 @@ +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)); + } +}