Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

1572 lines
51 KiB
Go

// internal/db/session_stats.go
package db
import (
"context"
"database/sql"
"fmt"
"log"
"sort"
"strconv"
"strings"
"time"
"go.kenn.io/agentsview/internal/db/git"
"go.kenn.io/agentsview/internal/export"
"go.kenn.io/agentsview/internal/timeutil"
)
// StatsFilter mirrors the service-layer StatsFilter but lives in db
// because db functions take typed filters without cross-package deps.
type StatsFilter struct {
Since string
Until string
Agent string
ApplyDefaultVisibility bool
IncludeOneShot bool
IncludeAutomated bool
IncludeProjects []string
ExcludeProjects []string
Timezone string
IncludeGitOutcomes bool
IncludeGitHubOutcomes bool
GHToken string
}
// StatsInputError marks invalid user-supplied stats filters so HTTP
// transports can return 400 instead of treating validation failures as server
// faults.
type StatsInputError struct{ Msg string }
func (e *StatsInputError) Error() string { return e.Msg }
// GetSessionStats computes the v1 session-stats JSON response.
// Sections are populated in order so each step can reuse the per-session
// rows (and derived sessionIDs) loaded once by loadSessionsInWindow.
func (db *DB) GetSessionStats(
ctx context.Context, f StatsFilter,
) (*SessionStats, error) {
tz, err := resolveTimezone(f.Timezone)
if err != nil {
return nil, &StatsInputError{Msg: "invalid timezone: " + f.Timezone}
}
from, to, days, err := windowBounds(f, time.Now())
if err != nil {
return nil, &StatsInputError{Msg: err.Error()}
}
// Root-only rows drive every consumer (distributions, velocity,
// timing, the human/automation split, archetypes, outcomes), so
// short signal-less subagents do not skew shape or per-session
// metrics. A second, subagent-inclusive load supplies the additive
// token/session totals, where subagent spend is real and belongs in
// the headline numbers.
rows, err := db.loadSessionsInWindow(ctx, f, from, to, false)
if err != nil {
return nil, err
}
rowsWithSubagents, err := db.loadSessionsInWindow(ctx, f, from, to, true)
if err != nil {
return nil, err
}
stats := &SessionStats{
SchemaVersion: 1,
Window: StatsWindow{
Since: from.UTC().Format(time.RFC3339),
Until: to.UTC().Format(time.RFC3339),
Days: days,
},
Filters: StatsFilters{
Agent: orDefault(f.Agent, "all"),
ProjectsIncluded: f.IncludeProjects,
ProjectsExcluded: nonNilSlice(f.ExcludeProjects),
Timezone: tz.String(),
},
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
computeTotalsAndArchetypes(stats, rows)
// Override the additive totals to count subagents. Archetypes and
// the human/automation split computed above stay root-only: a
// subagent is not a human or automation session and carries no
// shape signal. SessionsAll counts every row in the inclusive set;
// the message totals sum across it. SessionsHuman/SessionsAutomation
// are intentionally left as computeTotalsAndArchetypes set them.
applySubagentInclusiveTotals(stats, rowsWithSubagents)
computeDistributions(stats, rows)
// Root-only IDs drive the distribution surfaces (velocity, temporal).
// The inclusive IDs drive the additive all-session aggregates
// (tool_mix, model_mix.by_tokens) so they count subagent spend and
// reconcile with the subagent-inclusive Totals.
sessionIDs := make([]string, 0, len(rows))
for _, r := range rows {
sessionIDs = append(sessionIDs, r.id)
}
sessionIDsAll := make([]string, 0, len(rowsWithSubagents))
for _, r := range rowsWithSubagents {
sessionIDsAll = append(sessionIDsAll, r.id)
}
accum, err := populateVelocityAccumulator(ctx, db, sessionIDs, tz)
if err != nil {
return nil, fmt.Errorf("populating velocity accumulator: %w", err)
}
computeVelocity(stats, accum)
if err := db.computeToolAndModelMix(
ctx, stats, sessionIDsAll,
); err != nil {
return nil, fmt.Errorf(
"computing tool/model mix: %w", err,
)
}
computeAgentPortfolio(stats, rowsWithSubagents, rows)
if err := db.computeCacheEconomics(ctx, stats, rows); err != nil {
return nil, fmt.Errorf(
"computing cache economics: %w", err,
)
}
if err := db.computeTemporal(
ctx, stats, f, from, to, sessionIDs,
); err != nil {
return nil, fmt.Errorf("computing temporal: %w", err)
}
computeOutcomes(stats, rows)
if err := db.computeAdoption(ctx, stats, rows); err != nil {
return nil, fmt.Errorf("computing adoption: %w", err)
}
if f.IncludeGitOutcomes || f.IncludeGitHubOutcomes {
if err := db.computeOutcomeStats(ctx, stats, f, from, to, rows); err != nil {
return nil, fmt.Errorf("computing outcome stats: %w", err)
}
}
return stats, nil
}
// computeToolAndModelMix fills stats.ToolMix and stats.ModelMix from
// tool_calls and messages attached to sessionIDs. The session-level
// window and agent/project filters are already applied in
// loadSessionsInWindow — restricting to sessionIDs inherits those
// predicates without re-running the WHERE clause.
//
// Both mix maps are always non-nil so the JSON output keeps stable
// keys when the window contains no sessions.
func (db *DB) computeToolAndModelMix(
ctx context.Context, stats *SessionStats, sessionIDs []string,
) error {
stats.ToolMix.ByCategory = map[string]int{}
stats.ModelMix.ByTokens = map[string]int64{}
if len(sessionIDs) == 0 {
return nil
}
if err := queryChunked(sessionIDs,
func(chunk []string) error {
return db.accumulateToolMix(ctx, stats, chunk)
}); err != nil {
return err
}
return queryChunked(sessionIDs,
func(chunk []string) error {
return db.accumulateModelMix(ctx, stats, chunk)
})
}
// accumulateToolMix folds one chunk of session IDs into
// stats.ToolMix. Each row in tool_calls increments the matching
// category bucket and the total counter; empty-string categories are
// silently grouped under "" so the total stays consistent with
// GetAnalyticsTools.
func (db *DB) accumulateToolMix(
ctx context.Context, stats *SessionStats, sessionIDs []string,
) error {
ph, args := inPlaceholders(sessionIDs)
q := `SELECT category, COUNT(*)
FROM tool_calls
WHERE session_id IN ` + ph + `
GROUP BY category`
rows, err := db.getReader().QueryContext(ctx, q, args...)
if err != nil {
return fmt.Errorf("querying tool_calls mix: %w", err)
}
defer rows.Close()
for rows.Next() {
var category string
var count int
if err := rows.Scan(&category, &count); err != nil {
return fmt.Errorf("scanning tool_calls mix: %w", err)
}
stats.ToolMix.ByCategory[category] += count
stats.ToolMix.TotalCalls += count
}
return rows.Err()
}
// accumulateModelMix folds one chunk of session IDs into
// stats.ModelMix. Token contribution is messages.output_tokens summed
// per model — the per-message cost column, matching the spec's
// "model_mix.by_tokens reflects total output tokens per model".
//
// Eligibility mirrors usageMessageEligibility (internal/db/usage.go):
// rows without parsed token_usage and rows tagged as "<synthetic>" are
// excluded so model_mix never disagrees with the dollar/usage views.
// Messages with zero output_tokens are also dropped since they cannot
// move the by_tokens distribution.
func (db *DB) accumulateModelMix(
ctx context.Context, stats *SessionStats, sessionIDs []string,
) error {
ph, args := inPlaceholders(sessionIDs)
q := `SELECT model, COALESCE(SUM(output_tokens), 0)
FROM messages
WHERE session_id IN ` + ph + `
AND token_usage != ''
AND model != ''
AND model != '<synthetic>'
GROUP BY model`
rows, err := db.getReader().QueryContext(ctx, q, args...)
if err != nil {
return fmt.Errorf("querying model mix: %w", err)
}
defer rows.Close()
for rows.Next() {
var model string
var total int64
if err := rows.Scan(&model, &total); err != nil {
return fmt.Errorf("scanning model mix: %w", err)
}
if total == 0 {
continue
}
stats.ModelMix.ByTokens[model] += total
}
return rows.Err()
}
// computeVelocity fills SessionStats.Velocity from an already-populated
// accumulator. The mean fields are computed over the same turnCycles
// and firstResponses samples as the percentiles, so the two move
// together — no extra filtering, no hidden sample drift.
func computeVelocity(s *SessionStats, accum *velocityAccumulator) {
ov := accum.computeOverview()
s.Velocity.TurnCycleSeconds = StatsPercentiles{
P50: ov.TurnCycleSec.P50,
P90: ov.TurnCycleSec.P90,
Mean: accum.turnCycleMean(),
}
s.Velocity.FirstResponseSeconds = StatsPercentiles{
P50: ov.FirstResponseSec.P50,
P90: ov.FirstResponseSec.P90,
Mean: accum.firstResponseMean(),
}
if accum.activeMinutes > 0 {
s.Velocity.MessagesPerActiveHour =
float64(accum.totalMsgs) / (accum.activeMinutes / 60.0)
}
}
// resolveTimezone loads an IANA zone name, defaulting to UTC when
// empty. Unknown zones are an error — silently falling back would
// hide typos in user input.
func resolveTimezone(name string) (*time.Location, error) {
if name == "" {
return time.UTC, nil
}
loc, err := time.LoadLocation(name)
if err != nil {
return nil, fmt.Errorf(
"loading timezone %q: %w", name, err,
)
}
return loc, nil
}
// windowBounds resolves Since/Until into absolute time bounds.
// Supported inputs: "Nd" (days), "Nh" (hours), or "YYYY-MM-DD".
// Until defaults to now; Since defaults to 28 days before Until.
// Returned days is the calendar-style span in whole days, rounded
// up when Since is a non-integer-day duration (e.g. "48h" → 2).
func windowBounds(
f StatsFilter, now time.Time,
) (from, to time.Time, days int, err error) {
to = now
if f.Until != "" {
to, err = ParseWindowPoint(f.Until, now)
if err != nil {
return time.Time{}, time.Time{}, 0,
fmt.Errorf("parsing until %q: %w", f.Until, err)
}
}
from = to.Add(-28 * 24 * time.Hour)
if f.Since != "" {
// Durations anchor relative to Until; dates stand alone.
if d, ok := parseDurationShort(f.Since); ok {
from = to.Add(-d)
} else {
from, err = ParseWindowPoint(f.Since, now)
if err != nil {
return time.Time{}, time.Time{}, 0,
fmt.Errorf(
"parsing since %q: %w",
f.Since, err,
)
}
}
}
if !from.Before(to) {
return time.Time{}, time.Time{}, 0, fmt.Errorf(
"window since (%s) must precede until (%s)",
from.Format(time.RFC3339),
to.Format(time.RFC3339),
)
}
span := to.Sub(from)
days = int(span / (24 * time.Hour))
if span%(24*time.Hour) != 0 {
days++
}
return from, to, days, nil
}
// ParseWindowPoint resolves a single window bound — a compact
// duration-relative-to-now form ("28d", "12h") or an absolute YYYY-MM-DD
// date (the start of that UTC day) — to an instant. A duration anchors at
// now; passing a resolved bound as now lets a caller anchor a duration
// against it (as usage daily anchors --since to --until). Shared by stats'
// windowBounds and the usage CLI.
func ParseWindowPoint(s string, now time.Time) (time.Time, error) {
if d, ok := parseDurationShort(s); ok {
return now.Add(-d), nil
}
if t, err := time.Parse("2006-01-02", s); err == nil {
return t.UTC(), nil
}
return time.Time{}, fmt.Errorf(
"expected Nd, Nh, or YYYY-MM-DD, got %q", s,
)
}
// parseDurationShort recognises the compact "Nd" / "Nh" forms the
// stats CLI advertises. Returns ok=false when s is not a compact
// duration so callers can try the date path.
func parseDurationShort(s string) (time.Duration, bool) {
if len(s) < 2 {
return 0, false
}
unit := s[len(s)-1]
num, err := strconv.Atoi(s[:len(s)-1])
if err != nil || num <= 0 {
return 0, false
}
switch unit {
case 'd':
return time.Duration(num) * 24 * time.Hour, true
case 'h':
return time.Duration(num) * time.Hour, true
default:
return 0, false
}
}
// sessionStatsRow is the compact per-session projection used by all
// stats sections. Only the columns this task reads are populated;
// later tasks extend the struct (and loadSessionsInWindow's SELECT)
// in place rather than duplicating the scan.
type sessionStatsRow struct {
id string
agent string
project string
startedAt time.Time
endedAt sql.NullTime
messageCount int
userMessageCount int
totalOutputTokens int64
hasTotalOutputTokens bool
peakContextTokens int64
hasPeakContext bool
totalToolCalls int
assistantTurns int
// Outcome-section fields. Populated from the sessions table via
// loadSessionsInWindow; consumed by computeOutcomes. Empty strings
// for outcome/healthGrade denote "no signal recorded yet".
outcome string
healthGrade string
toolRetryCount int
compactionCount int
editChurnCount int
// cwd is the working directory recorded on the session. Consumed by
// computeOutcomeStats to resolve enclosing git repositories; empty
// string indicates the session had no recorded cwd and is skipped.
cwd string
// isAutomated mirrors sessions.is_automated. Consumed by
// computeTotalsAndArchetypes, computeDistributions, and
// computeAgentPortfolio as the single source of truth for
// whether a session is automated.
isAutomated bool
}
// loadSessionsInWindow returns the rows the stats pipeline needs.
// Matches the analytics.go convention: exclude subagent/fork rows
// and soft-deleted rows, require non-empty message_count, and bound
// by started_at within [from, to).
func (db *DB) loadSessionsInWindow(
ctx context.Context, f StatsFilter, from, to time.Time,
includeSubagents bool,
) ([]sessionStatsRow, error) {
// Use the same COALESCE(NULLIF(started_at, ''), created_at)
// expression as the rest of the analytics code so sessions whose
// started_at is missing (parser couldn't infer a start time) are
// still attributed to the window via their created_at fallback.
//
// includeSubagents selects which row set this is: the root-only set
// (default, drives distributions/shape/velocity and the human vs
// automation split) or the subagent-inclusive set (drives the
// additive token/session totals). Fork rows stay excluded in both
// because their tokens overlap their root session. The predicate is
// the same one the analytics builders use, via the shared helper, so
// the two paths can't drift.
preds := []string{
"message_count > 0",
RelationshipExclusionSQL(includeSubagents, false, ""),
"deleted_at IS NULL",
"COALESCE(NULLIF(started_at, ''), created_at) >= ?",
"COALESCE(NULLIF(started_at, ''), created_at) < ?",
}
args := []any{
from.UTC().Format(time.RFC3339Nano),
to.UTC().Format(time.RFC3339Nano),
}
if f.ApplyDefaultVisibility {
visibilityBuilder := NewQueryBuilder(SQLiteQueryDialect(), len(args))
preds, _ = appendSessionVisibilityPredicates(
preds,
SessionFilter{
ExcludeOneShot: !f.IncludeOneShot,
ExcludeAutomated: !f.IncludeAutomated,
},
visibilityBuilder,
func(col string) string { return "s." + col },
)
args = append(args, visibilityBuilder.Args()...)
}
if f.Agent != "" {
agents := csvFilterValues(f.Agent)
if len(agents) == 1 {
preds = append(preds, "agent = ?")
args = append(args, agents[0])
} else if len(agents) > 1 {
ph := make([]string, len(agents))
for i, a := range agents {
ph[i] = "?"
args = append(args, a)
}
preds = append(preds,
"agent IN ("+strings.Join(ph, ",")+")")
}
}
if len(f.IncludeProjects) > 0 {
ph, inArgs := inPlaceholders(f.IncludeProjects)
preds = append(preds, "project IN "+ph)
args = append(args, inArgs...)
}
if len(f.ExcludeProjects) > 0 {
ph, inArgs := inPlaceholders(f.ExcludeProjects)
preds = append(preds, "project NOT IN "+ph)
args = append(args, inArgs...)
}
// The tool-call / assistant-turn subqueries keep the per-session
// projection self-contained: one row per session, no separate
// merge step. Correlated subqueries are cheap here because
// idx_tool_calls_session and idx_messages_session_role already
// narrow the scan to the session's rows.
// Project the started_at the rest of the pipeline reads (with
// the created_at fallback baked in) so downstream code never has
// to revisit the COALESCE. assistant_turns excludes system rows
// (Claude compact-boundary summaries, etc.) so they don't inflate
// the denominator of the tools-per-turn distribution.
// has_total_output_tokens is projected so agent_portfolio's
// by_tokens accumulator can guard against zeroed-out token rows.
query := `SELECT s.id, s.agent, s.project,
COALESCE(NULLIF(s.started_at, ''), s.created_at) AS effective_started_at,
s.ended_at,
s.message_count, s.user_message_count,
s.total_output_tokens, s.has_total_output_tokens,
s.peak_context_tokens, s.has_peak_context_tokens,
COALESCE((SELECT COUNT(*) FROM tool_calls tc
WHERE tc.session_id = s.id), 0) AS total_tool_calls,
COALESCE((SELECT COUNT(*) FROM messages m
WHERE m.session_id = s.id
AND m.role = 'assistant'
AND m.is_system = 0),
0) AS assistant_turns,
s.outcome, COALESCE(s.health_grade, ''),
s.tool_retry_count, s.compaction_count, s.edit_churn_count,
COALESCE(s.cwd, ''),
s.is_automated
FROM sessions s WHERE ` + strings.Join(preds, " AND ")
sqlRows, err := db.getReader().QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf(
"querying sessions for stats window: %w", err,
)
}
defer sqlRows.Close()
var out []sessionStatsRow
for sqlRows.Next() {
var r sessionStatsRow
var startedAt string
var endedAt sql.NullString
var hasTotalTokens, hasPeak, isAutomated int
if err := sqlRows.Scan(
&r.id, &r.agent, &r.project,
&startedAt, &endedAt,
&r.messageCount, &r.userMessageCount,
&r.totalOutputTokens, &hasTotalTokens,
&r.peakContextTokens, &hasPeak,
&r.totalToolCalls, &r.assistantTurns,
&r.outcome, &r.healthGrade,
&r.toolRetryCount, &r.compactionCount, &r.editChurnCount,
&r.cwd,
&isAutomated,
); err != nil {
return nil, fmt.Errorf(
"scanning session stats row: %w", err,
)
}
t, err := parseTimestamp(startedAt)
if err != nil {
return nil, fmt.Errorf(
"session %s: parsing started_at %q: %w",
r.id, startedAt, err,
)
}
r.startedAt = t
if endedAt.Valid && endedAt.String != "" {
et, err := parseTimestamp(endedAt.String)
if err != nil {
return nil, fmt.Errorf(
"session %s: parsing ended_at %q: %w",
r.id, endedAt.String, err,
)
}
r.endedAt = sql.NullTime{Time: et, Valid: true}
}
r.hasTotalOutputTokens = hasTotalTokens == 1
r.hasPeakContext = hasPeak == 1
r.isAutomated = isAutomated == 1
out = append(out, r)
}
if err := sqlRows.Err(); err != nil {
return nil, fmt.Errorf(
"iterating session stats rows: %w", err,
)
}
return out, nil
}
// parseTimestamp accepts RFC3339 and RFC3339Nano — the two forms
// the session table writes via timeutil.Format / Ptr.
func parseTimestamp(s string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, nil
}
return time.Parse(time.RFC3339, s)
}
// sessionShapeLabel classifies a *non-automated* session by its
// user_message_count. Automated sessions are handled upstream (the
// caller assigns "automation" based on sessions.is_automated) and
// never pass through this helper, so the lower band starts at 0
// rather than 1. Boundaries are inclusive on both sides of each band.
func sessionShapeLabel(userMsgs int) string {
switch {
case userMsgs <= 5:
return "quick"
case userMsgs <= 15:
return "standard"
case userMsgs <= 50:
return "deep"
default:
return "marathon"
}
}
// computeTotalsAndArchetypes fills SessionStats.Totals and
// .Archetypes in a single pass over rows.
func computeTotalsAndArchetypes(
s *SessionStats, rows []sessionStatsRow,
) {
archMax := map[string]int{}
humanMax := map[string]int{}
for _, r := range rows {
s.Totals.SessionsAll++
s.Totals.MessagesTotal += r.messageCount
s.Totals.UserMessagesTotal += r.userMessageCount
var label string
if r.isAutomated {
label = "automation"
s.Archetypes.Automation++
s.Totals.SessionsAutomation++
} else {
label = sessionShapeLabel(r.userMessageCount)
s.Totals.SessionsHuman++
switch label {
case "quick":
s.Archetypes.Quick++
case "standard":
s.Archetypes.Standard++
case "deep":
s.Archetypes.Deep++
case "marathon":
s.Archetypes.Marathon++
}
humanMax[label]++
}
archMax[label]++
}
s.Archetypes.Primary = pickMaxLabel(archMax, []string{
"automation", "marathon", "deep", "standard", "quick",
})
s.Archetypes.PrimaryHuman = pickMaxLabel(humanMax, []string{
"marathon", "deep", "standard", "quick",
})
}
// applySubagentInclusiveTotals overrides the additive token/session
// totals with sums over the subagent-inclusive row set. It is called
// after computeTotalsAndArchetypes (which ran on the root-only rows) so
// the archetypes and the human/automation split stay root-only while
// the headline totals count subagent spend. Only the strictly additive
// fields are overridden; SessionsHuman and SessionsAutomation are not,
// since a subagent is neither.
func applySubagentInclusiveTotals(
s *SessionStats, rows []sessionStatsRow,
) {
var sessions, messages, userMessages int
for _, r := range rows {
sessions++
messages += r.messageCount
userMessages += r.userMessageCount
}
s.Totals.SessionsAll = sessions
s.Totals.MessagesTotal = messages
s.Totals.UserMessagesTotal = userMessages
// SessionsHuman and SessionsAutomation were set from the root-only
// rows and exclude subagents. The remainder is the subagent count,
// which keeps the partition sessions_all == human + automation +
// subagent intact.
s.Totals.SessionsSubagent =
sessions - s.Totals.SessionsHuman - s.Totals.SessionsAutomation
}
// pickMaxLabel returns the key with the strictly highest count.
// Ties are broken by iterating priority in order — the earlier
// priority entry wins. Returns "" when counts is empty or every
// candidate count is zero, so empty windows do not fabricate a
// "primary" label.
func pickMaxLabel(counts map[string]int, priority []string) string {
best := ""
bestN := 0
for _, k := range priority {
if counts[k] > bestN {
best = k
bestN = counts[k]
}
}
return best
}
func orDefault(v, d string) string {
if v == "" {
return d
}
return v
}
func nonNilSlice(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// scopedAccumulator collects values for one scope of one metric: a
// bucket slice plus the running sum/n needed for the arithmetic mean.
// Kept as a plain struct so computeDistributions can wire up one pair
// per metric without bespoke variables per scope.
type scopedAccumulator struct {
buckets []DistributionBucketV1
edges []float64
sum float64
n int
}
func newAccumulator(edges []float64) scopedAccumulator {
return scopedAccumulator{
buckets: buildEmptyBuckets(edges),
edges: edges,
}
}
func (a *scopedAccumulator) add(v float64) {
addBucket(a.buckets, a.edges, v)
a.sum += v
a.n++
}
func (a *scopedAccumulator) finalize() ScopedDistribution {
return ScopedDistribution{
Buckets: a.buckets,
Mean: safeMean(a.sum, a.n),
}
}
// computeDistributions populates the four scope-aware histograms on
// SessionStats. Scope rules:
//
// - ScopeAll includes every row in the window.
// - ScopeHuman excludes any row where is_automated is set. This
// aligns scope_human with the single authority for automation
// classification; the old userMessageCount >= 2 heuristic is
// gone.
//
// Per-metric filters excluded from both scopes:
//
// - DurationMinutes: only rows with endedAt set (r.endedAt.Valid);
// sessions without an end timestamp have no meaningful duration.
// - ToolsPerTurn: only rows with assistantTurns > 0; a zero-turn
// session has no meaningful turn rate and would otherwise bias
// bucket 0 toward the zero ratio.
//
// Per-metric filters excluded from scope_human only:
//
// - UserMessages: rows with userMessageCount < 2 are excluded from
// the human mean and buckets because the v1 human bucket shape
// starts at 2. ScopeAll keeps the [0,2) bucket for short sessions.
//
// PeakContextTokens includes every row with hasPeakContext data,
// regardless of agent: the metric used to be Claude-only, but the
// hermes/kimi/forge/zed parsers populate it now (#646). Rows without
// the data are tallied in NullCount — but only for agents that report
// the metric at least once in the window, so agents that never track
// peak context stay outside the metric entirely instead of inflating
// the null tally.
func computeDistributions(s *SessionStats, rows []sessionStatsRow) {
durAll := newAccumulator(durationMinutesEdges)
durHuman := newAccumulator(durationMinutesEdges)
umAll := newAccumulator(userMessagesEdgesAll)
umHuman := newAccumulator(userMessagesEdgesHuman)
pcAll := newAccumulator(peakContextEdges)
pcHuman := newAccumulator(peakContextEdges)
tptAll := newAccumulator(toolsPerTurnEdges)
tptHuman := newAccumulator(toolsPerTurnEdges)
var pcNull int
// Agents with at least one peak-context-bearing row in the window;
// only their data-less rows count toward NullCount.
peakAgents := map[string]bool{}
for _, r := range rows {
if r.hasPeakContext {
peakAgents[r.agent] = true
}
}
for _, r := range rows {
human := !r.isAutomated
if r.endedAt.Valid {
dur := r.endedAt.Time.Sub(r.startedAt).Minutes()
// Drop clock-skewed / malformed sessions whose ended_at
// precedes started_at: negative durations would distort
// the mean and have no matching bucket. assignBucket
// already drops them from the histogram, so excluding
// them here keeps the mean and bucket totals consistent.
if dur >= 0 {
durAll.add(dur)
if human {
durHuman.add(dur)
}
}
}
umv := float64(r.userMessageCount)
umAll.add(umv)
if human && r.userMessageCount >= 2 {
umHuman.add(umv)
}
if r.hasPeakContext {
pv := float64(r.peakContextTokens)
pcAll.add(pv)
if human {
pcHuman.add(pv)
}
} else if peakAgents[r.agent] {
pcNull++
}
if r.assistantTurns > 0 {
tpt := float64(r.totalToolCalls) / float64(r.assistantTurns)
tptAll.add(tpt)
if human {
tptHuman.add(tpt)
}
}
}
s.Distributions.DurationMinutes = ScopedDistributionPair{
ScopeAll: durAll.finalize(),
ScopeHuman: durHuman.finalize(),
}
s.Distributions.UserMessages = ScopedDistributionPair{
ScopeAll: umAll.finalize(),
ScopeHuman: umHuman.finalize(),
}
s.Distributions.PeakContextTokens = PeakContextDistribution{
ScopeAll: pcAll.finalize(),
ScopeHuman: pcHuman.finalize(),
NullCount: pcNull,
ClaudeOnly: false,
}
s.Distributions.ToolsPerTurn = ScopedDistributionPair{
ScopeAll: tptAll.finalize(),
ScopeHuman: tptHuman.finalize(),
}
}
// addBucket places v into the bucket matching edges and increments
// its count. Values outside the edge range are silently dropped; the
// v1 edge lists all end in +Inf so this is unreachable in practice.
func addBucket(buckets []DistributionBucketV1, edges []float64, v float64) {
idx := assignBucket(edges, v)
if idx < 0 || idx >= len(buckets) {
return
}
buckets[idx].Count++
}
// safeMean returns sum/n or 0 when n is zero. Keeps the JSON mean
// field numeric (never NaN) when a scope has no contributing rows.
func safeMean(sum float64, n int) float64 {
if n == 0 {
return 0
}
return sum / float64(n)
}
// computeAgentPortfolio fills SessionStats.AgentPortfolio by folding
// per-session counts and output tokens into one bucket per agent.
// Maps are always non-nil so the JSON output keeps stable {} values
// when the window contains no sessions.
//
// Sessions with an empty agent name are skipped to match the rest of
// the analytics code (sessions.go's "agent != ”" filter on the agents
// list). They would otherwise emit an empty-string JSON key and bias
// pickPrimaryAgent's lexicographic tiebreaker toward "".
//
// Token totals only include sessions whose has_total_output_tokens
// flag is set. Without that guard, agents whose token coverage is
// missing (default 0) would be indistinguishable from agents that
// truly produced no output tokens.
//
// The all-session maps (by_sessions/by_messages/by_tokens) are built
// from rowsAll so they count subagent spend and reconcile with the
// subagent-inclusive Totals. The _human maps are built from rowsRoot:
// a subagent is not a human session, and (since subagents are also not
// is_automated) folding them via the !isAutomated gate would wrongly
// inflate the human variants. rowsRoot already excludes subagents, so
// the human accumulation uses it directly.
func computeAgentPortfolio(
s *SessionStats, rowsAll, rowsRoot []sessionStatsRow,
) {
bySessions := map[string]int{}
byMessages := map[string]int{}
byTokens := map[string]int64{}
for _, r := range rowsAll {
if r.agent == "" {
continue
}
bySessions[r.agent]++
byMessages[r.agent] += r.messageCount
if r.hasTotalOutputTokens {
byTokens[r.agent] += r.totalOutputTokens
}
}
bySessionsHuman := map[string]int{}
byMessagesHuman := map[string]int{}
byTokensHuman := map[string]int64{}
for _, r := range rowsRoot {
if r.agent == "" || r.isAutomated {
continue
}
bySessionsHuman[r.agent]++
byMessagesHuman[r.agent] += r.messageCount
if r.hasTotalOutputTokens {
byTokensHuman[r.agent] += r.totalOutputTokens
}
}
s.AgentPortfolio.BySessions = bySessions
s.AgentPortfolio.ByMessages = byMessages
s.AgentPortfolio.ByTokens = byTokens
s.AgentPortfolio.Primary = pickPrimaryAgent(bySessions)
s.AgentPortfolio.BySessionsHuman = bySessionsHuman
s.AgentPortfolio.ByMessagesHuman = byMessagesHuman
s.AgentPortfolio.ByTokensHuman = byTokensHuman
s.AgentPortfolio.PrimaryHuman = pickPrimaryAgent(bySessionsHuman)
}
// pickPrimaryAgent returns the agent with the highest session count.
// Ties are broken by choosing the lexicographically smallest agent
// name — a stable rule so downstream tools that golden-compare the
// JSON output see deterministic values regardless of Go's randomised
// map iteration order. Returns "" for an empty map.
func pickPrimaryAgent(bySessions map[string]int) string {
best := ""
bestN := -1
for agent, n := range bySessions {
if n > bestN || (n == bestN && agent < best) {
best = agent
bestN = n
}
}
return best
}
// sessionCacheTotals accumulates the denominator tokens (input +
// cache_read + cache_creation) that drive the per-session ratio, plus
// the dollar figures for one Claude session. Output tokens don't feed
// the ratio and are baked directly into dollars* as they're parsed,
// so they're intentionally not kept on the struct.
type sessionCacheTotals struct {
inputTok int64
cacheCreateT int64
cacheReadT int64
dollarsSpent float64
dollarsNoCac float64 // cost if the workload had never cached
}
// computeCacheEconomics populates stats.CacheEconomics for Claude
// sessions in the window. The field is a nullable pointer — it is
// left nil whenever rows contains no agent="claude" session so the
// JSON output stays absent for non-Claude workloads (see spec:
// "Section 6 hidden if cache_economics absent").
//
// Overall hit ratio is the weighted mean of cache_read over
// (input + cache_read + cache_creation), weighted by each session's
// denominator (equivalently: sum(cache_read)/sum(denominator) across
// sessions with a nonzero denominator). The spec's aggregator rule
// for merging cache_hit_ratio across machines is a weighted mean
// over the same denominator, so computing the single-machine number
// the same way keeps merge semantics stable.
//
// dollars_spent prices every eligible Claude message using the
// model_pricing table. dollars_saved_vs_uncached reprices cache_read
// tokens at the input rate and zeroes cache_creation (the
// counterfactual where the workload never cached), then subtracts
// dollars_spent. A missing pricing row zeroes out that model's
// contribution — the same graceful-degrade behaviour as GetDailyUsage.
func (db *DB) computeCacheEconomics(
ctx context.Context, stats *SessionStats,
rows []sessionStatsRow,
) error {
claudeIDs := collectClaudeSessionIDs(rows)
if len(claudeIDs) == 0 {
return nil
}
pricing, err := db.loadPricingMap(ctx)
if err != nil {
return fmt.Errorf("loading pricing: %w", err)
}
rateResolver := export.NewPricingResolver(pricing)
perSession := make(map[string]*sessionCacheTotals, len(claudeIDs))
if err := queryChunked(claudeIDs,
func(chunk []string) error {
return db.accumulateCacheTotals(
ctx, chunk, rateResolver, perSession,
)
}); err != nil {
return err
}
ce := &StatsCacheEconomics{
ClaudeOnly: true,
CacheHitRatio: CacheHitRatioDistribution{
Buckets: buildCacheHitRatioBuckets(),
},
}
var (
cacheReadSum int64
denominatorSum int64
dollarsSpent float64
dollarsNoCache float64
)
// Iterate in session-id order so floating-point sums stay
// deterministic across runs; Go's map iteration order is
// randomised and (a+b)+c != a+(b+c) in IEEE 754.
keys := make([]string, 0, len(perSession))
for k := range perSession {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
totals, ok := perSession[k]
if !ok || totals == nil {
continue
}
denom := totals.inputTok + totals.cacheReadT +
totals.cacheCreateT
dollarsSpent += totals.dollarsSpent
dollarsNoCache += totals.dollarsNoCac
if denom <= 0 {
continue
}
cacheReadSum += totals.cacheReadT
denominatorSum += denom
ratio := float64(totals.cacheReadT) / float64(denom)
addBucket(ce.CacheHitRatio.Buckets,
cacheHitRatioEdges, ratio)
}
if denominatorSum > 0 {
ce.CacheHitRatio.Overall =
float64(cacheReadSum) / float64(denominatorSum)
}
ce.DollarsSpent = dollarsSpent
// Negative savings are a legitimate outcome for write-heavy
// workloads where cache_creation premiums outweigh cache_read
// discounts. The existing usage views (internal/db/usage.go,
// frontend/src/lib/utils/usageSavings.ts) surface that "costlier
// than uncached" state directly, so do not clamp it away here —
// hiding it would mask real cache-efficiency regressions.
ce.DollarsSavedVsUncached = dollarsNoCache - dollarsSpent
stats.CacheEconomics = ce
return nil
}
// collectClaudeSessionIDs filters sessionStatsRow to the Claude-agent
// subset used by the cache_economics query. Kept as a helper so the
// caller reads as "build the list, run the query".
func collectClaudeSessionIDs(rows []sessionStatsRow) []string {
out := make([]string, 0, len(rows))
for _, r := range rows {
if r.agent == "claude" {
out = append(out, r.id)
}
}
return out
}
// accumulateCacheTotals folds one chunk of Claude session IDs into
// perSession. Messages with empty token_usage or empty model are
// skipped — they match usageMessageEligibility's filter and keep the
// dollar numbers consistent with GetDailyUsage.
func (db *DB) accumulateCacheTotals(
ctx context.Context, sessionIDs []string,
pricing *export.PricingResolver,
perSession map[string]*sessionCacheTotals,
) error {
ph, args := inPlaceholders(sessionIDs)
// ORDER BY (session_id, ordinal) so floating-point sums are
// reproducible across runs: SQLite is free to return rows in any
// physical order otherwise, and (a+b)+c != a+(b+c) in IEEE 754.
// The cross-session fold in computeCacheEconomics already sorts
// session IDs; the per-message order completes the determinism
// chain so golden tests stay byte-stable.
q := `SELECT session_id, model, token_usage
FROM messages
WHERE session_id IN ` + ph + `
AND token_usage != ''
AND model != ''
AND model != '<synthetic>'
ORDER BY session_id, ordinal`
sqlRows, err := db.getReader().QueryContext(ctx, q, args...)
if err != nil {
return fmt.Errorf("querying cache tokens: %w", err)
}
defer sqlRows.Close()
for sqlRows.Next() {
var sessionID, model, tokenJSON string
if err := sqlRows.Scan(
&sessionID, &model, &tokenJSON,
); err != nil {
return fmt.Errorf("scanning cache tokens: %w", err)
}
addMessageToCacheTotals(
perSession, sessionID, model, tokenJSON, pricing,
)
}
return sqlRows.Err()
}
// addMessageToCacheTotals parses one message's token_usage JSON and
// folds its contribution into perSession. Split out of
// accumulateCacheTotals so the row loop stays a thin scan+dispatch.
func addMessageToCacheTotals(
perSession map[string]*sessionCacheTotals,
sessionID, model, tokenJSON string,
pricing *export.PricingResolver,
) {
inputTok, outputTok, cacheCrTok, cacheRdTok :=
clampedUsageTokenCounters(tokenJSON)
totals, ok := perSession[sessionID]
if !ok {
totals = &sessionCacheTotals{}
perSession[sessionID] = totals
}
totals.inputTok += int64(inputTok)
totals.cacheCreateT += int64(cacheCrTok)
totals.cacheReadT += int64(cacheRdTok)
rates := pricing.Lookup(model).Rates
totals.dollarsSpent += rates.CostForTokens(
inputTok, outputTok, 0, cacheCrTok, cacheRdTok)
// Uncached counterfactual: cache_creation tokens would still
// have been sent as ordinary input (so they are billed at the
// input rate, not dropped), and cache_read tokens are re-billed
// at the input rate too. This matches the rest of the codebase
// (see internal/db/usage.go and the savings calculation in
// frontend/src/lib/utils/usageSavings.ts).
totals.dollarsNoCac += (float64(inputTok)*rates.InputPerMTok +
float64(outputTok)*rates.OutputPerMTok +
float64(cacheCrTok)*rates.InputPerMTok +
float64(cacheRdTok)*rates.InputPerMTok) / 1_000_000
}
// computeTemporal fills stats.Temporal.HourlyUTC and ReporterTimezone.
//
// HourlyUTC groups user messages (role='user') by their UTC calendar
// hour. Each entry reports the count of user messages in that hour and
// the number of distinct sessions with at least one user message in
// that hour. Hours with zero activity are omitted (sparse output).
//
// Window + agent + project filters apply transitively via sessionIDs —
// the caller already filtered sessions via loadSessionsInWindow, so
// restricting to session_id IN (...) inherits those predicates. An
// empty sessionIDs slice short-circuits to an empty entry list without
// touching the database.
//
// Entries are sorted by TS ascending. The slice is always non-nil so
// the JSON output emits "hourly_utc": [] rather than null.
//
// ReporterTimezone reflects f.Timezone when set (honouring the CLI
// --timezone flag), otherwise the best-effort local IANA name. When
// the env/local fallback cannot be resolved safely, the field stays
// empty so downstream fallback logic can take over.
func (db *DB) computeTemporal(
ctx context.Context, stats *SessionStats, f StatsFilter,
from, to time.Time, sessionIDs []string,
) error {
stats.Temporal.HourlyUTC = []TemporalHourlyUTCEntry{}
stats.Temporal.ReporterTimezone = reporterTimezone(f)
if len(sessionIDs) == 0 {
return nil
}
perHour := map[string]*TemporalHourlyUTCEntry{}
if err := queryChunked(sessionIDs,
func(chunk []string) error {
return db.accumulateHourlyUTC(
ctx, chunk, from, to, perHour,
)
}); err != nil {
return err
}
hours := make([]string, 0, len(perHour))
for h := range perHour {
hours = append(hours, h)
}
sort.Strings(hours)
out := make([]TemporalHourlyUTCEntry, 0, len(hours))
for _, h := range hours {
entry, ok := perHour[h]
if !ok || entry == nil {
continue
}
out = append(out, *entry)
}
stats.Temporal.HourlyUTC = out
return nil
}
// accumulateHourlyUTC folds one chunk of session IDs into perHour.
// Messages without a timestamp are skipped — strftime returns NULL for
// empty strings, and we ignore the resulting row rather than bucketing
// it into the epoch.
//
// from/to bound the message timestamps so that long-running sessions
// don't drag pre-window or post-window activity into hourly_utc. The
// session window already restricted us to in-window sessions; this
// extra predicate keeps a session's stray messages from leaking out
// of [from, to).
//
// Sessions-per-hour is a distinct count: a session sending many
// messages in one hour counts once, but the same session appearing in
// two hours contributes to both. queryChunked slices sessionIDs into
// disjoint chunks, so a per-chunk seen-set is enough — no session ID
// crosses chunk boundaries.
func (db *DB) accumulateHourlyUTC(
ctx context.Context, sessionIDs []string,
from, to time.Time,
perHour map[string]*TemporalHourlyUTCEntry,
) error {
ph, args := inPlaceholders(sessionIDs)
args = append(args,
from.UTC().Format(time.RFC3339Nano),
to.UTC().Format(time.RFC3339Nano),
)
q := `SELECT
strftime('%Y-%m-%dT%H:00:00Z', m.timestamp) AS utc_hour,
m.session_id
FROM messages m
WHERE m.session_id IN ` + ph + `
AND m.role = 'user'
AND m.timestamp IS NOT NULL
AND m.timestamp != ''
AND m.timestamp >= ?
AND m.timestamp < ?`
rows, err := db.getReader().QueryContext(ctx, q, args...)
if err != nil {
return fmt.Errorf("querying temporal hourly_utc: %w", err)
}
defer rows.Close()
seen := map[string]map[string]struct{}{}
for rows.Next() {
var hour sql.NullString
var sessionID string
if err := rows.Scan(&hour, &sessionID); err != nil {
return fmt.Errorf("scanning hourly_utc: %w", err)
}
if !hour.Valid || hour.String == "" {
continue
}
entry, ok := perHour[hour.String]
if !ok {
entry = &TemporalHourlyUTCEntry{TS: hour.String}
perHour[hour.String] = entry
}
entry.UserMessages++
hourSeen, ok := seen[hour.String]
if !ok {
hourSeen = map[string]struct{}{}
seen[hour.String] = hourSeen
}
if _, dup := hourSeen[sessionID]; !dup {
hourSeen[sessionID] = struct{}{}
entry.Sessions++
}
}
return rows.Err()
}
// reporterTimezone picks the best-effort IANA name to record in
// SessionStats.Temporal.ReporterTimezone. Precedence:
//
// 1. f.Timezone when non-empty — echoes the --timezone flag.
// 2. Valid IANA names from TZ or the current local location.
// 3. Empty string when the fallback name is only a sentinel or
// otherwise cannot be resolved safely.
func reporterTimezone(f StatsFilter) string {
if f.Timezone != "" {
return f.Timezone
}
return timeutil.BestEffortLocalTimezone()
}
// computeOutcomes populates stats.Outcomes from the Claude-agent subset
// of rows. The pointer stays nil when the window contains no Claude
// sessions so the JSON output stays absent for pure non-Claude
// workloads (matching the cache_economics convention: omitempty + nil).
//
// The JSON contract exposes success/failure/unknown buckets, but
// agentsview's sessions.outcome column uses a different vocabulary
// ("completed" / "abandoned" / "errored" / "unknown" — see
// internal/signals/outcome.go). The switch below maps the stored
// vocabulary onto the contract. Unknown counts the schema default
// "unknown" plus any legacy empty string or future additions.
// GradeDistribution is always allocated as a non-nil map so the JSON
// emits "grade_distribution": {} rather than null when no session has
// a grade yet; empty health_grade values are skipped so the map never
// carries a "" key.
//
// ToolRetryRate is guarded against division by zero — without that
// guard a window with retries but no (counted) tool calls would divide
// by zero (NaN), which JSON cannot encode. CompactionsPerSession and
// AvgEditChurn do not need a guard because the early return above
// guarantees len(claudeRows) > 0.
func computeOutcomes(s *SessionStats, rows []sessionStatsRow) {
var claudeRows []sessionStatsRow
for _, r := range rows {
if r.agent == "claude" {
claudeRows = append(claudeRows, r)
}
}
if len(claudeRows) == 0 {
return
}
out := &StatsOutcomes{
ClaudeOnly: true,
GradeDistribution: map[string]int{},
}
totalTools := 0
totalRetries := 0
totalCompactions := 0
totalChurn := 0
for _, r := range claudeRows {
// Map agentsview's outcome vocabulary (see
// internal/signals/outcome.go) onto the JSON contract's
// success/failure/unknown buckets. "completed" is the only
// positive outcome; "abandoned" and "errored" both indicate
// the session did not reach a clean finish.
switch r.outcome {
case "completed":
out.Success++
case "abandoned", "errored":
out.Failure++
default:
// Covers "unknown", empty, and any future additions.
out.Unknown++
}
if r.healthGrade != "" {
out.GradeDistribution[r.healthGrade]++
}
totalTools += r.totalToolCalls
totalRetries += r.toolRetryCount
totalCompactions += r.compactionCount
totalChurn += r.editChurnCount
}
if totalTools > 0 {
out.ToolRetryRate = float64(totalRetries) /
float64(totalTools)
}
// len(claudeRows) > 0 is guaranteed by the early return above.
out.CompactionsPerSession = float64(totalCompactions) /
float64(len(claudeRows))
out.AvgEditChurn = float64(totalChurn) /
float64(len(claudeRows))
s.Outcomes = out
}
// computeAdoption populates stats.Adoption for Claude sessions in the
// window. The field is a nullable pointer — it stays nil whenever the
// window contains zero agent="claude" sessions so the JSON output stays
// absent for pure non-Claude workloads (matching the cache_economics
// and outcomes convention: omitempty + nil).
//
// Metrics are derived from the tool_calls table, restricted to the
// already-filtered Claude session IDs so window/project predicates flow
// through transitively:
//
// - PlanModeRate: distinct Claude sessions with at least one row where
// tool_name = "ExitPlanMode", divided by total Claude sessions.
// Always in [0, 1].
// - SubagentsPerSession: total tool_calls rows with tool_name in
// ("Task", "Agent"), divided by total Claude sessions. Can exceed 1
// (it is a mean). Both names refer to the same subagent dispatch
// primitive — Claude Code records it as "Task" historically and as
// "Agent" in newer transcripts; counting both keeps the metric
// stable across the rename.
// - DistinctSkills: count of distinct non-empty skill_name values
// recorded on rows with tool_name = "Skill". The schema already
// normalises skill_name as a dedicated column (see schema.sql), so
// no JSON parsing is required.
func (db *DB) computeAdoption(
ctx context.Context, stats *SessionStats, rows []sessionStatsRow,
) error {
claudeIDs := collectClaudeSessionIDs(rows)
if len(claudeIDs) == 0 {
return nil
}
planModeSessions := map[string]struct{}{}
skillNames := map[string]struct{}{}
var totalSubagents int
if err := queryChunked(claudeIDs,
func(chunk []string) error {
return db.accumulateAdoption(
ctx, chunk,
planModeSessions, skillNames, &totalSubagents,
)
}); err != nil {
return err
}
n := float64(len(claudeIDs))
stats.Adoption = &StatsAdoption{
ClaudeOnly: true,
PlanModeRate: float64(len(planModeSessions)) / n,
SubagentsPerSession: float64(totalSubagents) / n,
DistinctSkills: len(skillNames),
}
return nil
}
// accumulateAdoption folds one chunk of Claude session IDs into the
// three per-window accumulators. One pass over tool_calls scans only
// the three tool_name values the adoption metrics need; a
// single-column skill_name projection keeps the result set narrow.
func (db *DB) accumulateAdoption(
ctx context.Context, sessionIDs []string,
planModeSessions map[string]struct{},
skillNames map[string]struct{},
totalSubagents *int,
) error {
ph, args := inPlaceholders(sessionIDs)
q := `SELECT session_id, tool_name, COALESCE(skill_name, '')
FROM tool_calls
WHERE session_id IN ` + ph + `
AND tool_name IN ('ExitPlanMode', 'Task', 'Agent', 'Skill')`
rows, err := db.getReader().QueryContext(ctx, q, args...)
if err != nil {
return fmt.Errorf("querying adoption tool_calls: %w", err)
}
defer rows.Close()
for rows.Next() {
var sessionID, toolName, skillName string
if err := rows.Scan(&sessionID, &toolName, &skillName); err != nil {
return fmt.Errorf("scanning adoption tool_calls: %w", err)
}
switch toolName {
case "ExitPlanMode":
planModeSessions[sessionID] = struct{}{}
case "Task", "Agent":
*totalSubagents++
case "Skill":
if skillName != "" {
skillNames[skillName] = struct{}{}
}
}
}
return rows.Err()
}
// computeOutcomeStats populates stats.OutcomeStats by discovering the git
// repositories enclosing session cwds in the window and aggregating
// author-filtered commit activity across them. Output stays nil when no
// session in the window has a recognisable cwd — a signal that the caller
// has no git-derived outcome data, not a legitimate zero.
//
// Each repo is processed independently: a failure from one (bad path,
// missing git, unreadable config) is logged via the error path but does
// not abort the aggregation — per-repo errors are swallowed so a single
// broken checkout can't erase every other repo's numbers. Repos with no
// resolvable author email are skipped; without an author filter the log
// aggregation would attribute every other contributor's commits to the
// local user.
//
// PR counts are only populated when f.GHToken is set. When gh is
// configured, PRsOpened and PRsMerged accumulate across every repo that
// successfully returned a PRResult; gh failures (unauthenticated,
// network) are swallowed the same way log failures are. When the token
// is empty, both pointers stay nil so the JSON output distinguishes
// "gh not configured" from "configured, zero PRs".
//
// from/to are the absolute window bounds already resolved by
// windowBounds. They are formatted as RFC3339 UTC before being handed to
// `git log --since/--until` (git accepts RFC3339) and to
// `gh pr list --search`, which wants YYYY-MM-DD or RFC3339. The raw
// f.Since / f.Until strings ("28d", "7d", etc.) are not passed through
// because git does not understand the compact duration form.
func (db *DB) computeOutcomeStats(
ctx context.Context, s *SessionStats, f StatsFilter,
from, to time.Time, rows []sessionStatsRow,
) error {
cwds := make([]string, 0, len(rows))
for _, r := range rows {
if r.cwd != "" {
cwds = append(cwds, r.cwd)
}
}
repos := git.DiscoverRepos(ctx, cwds)
if len(repos) == 0 {
return nil
}
since := from.UTC().Format(time.RFC3339)
until := to.UTC().Format(time.RFC3339)
var cache *git.Cache
if db.ReadOnly() {
cache = git.NewReadOnlyCache(db.rawReader())
} else {
cache = git.NewCache(db.rawWriter())
}
out := &StatsOutcomeStats{}
contributed := false
for _, repo := range repos {
email := git.AuthorEmail(ctx, repo)
if email == "" {
continue
}
logRes, err := git.AggregateLogCached(
ctx, cache, repo, email, since, until, time.Hour,
)
if err != nil {
// Per-repo failures are logged but don't abort
// aggregation across other repos.
log.Printf(
"computeOutcomeStats: repo=%s op=log err=%v",
repo, err,
)
continue
}
contributed = true
out.ReposActive++
out.Commits += logRes.Commits
out.LOCAdded += logRes.LOCAdded
out.LOCRemoved += logRes.LOCRemoved
out.FilesChanged += logRes.FilesChanged
if f.GHToken != "" {
prRes, err := git.AggregatePRsCached(
ctx, cache, repo, since, until,
f.GHToken, time.Hour,
)
if err != nil {
log.Printf(
"computeOutcomeStats: repo=%s op=pr err=%v",
repo, err,
)
} else if prRes != nil {
addPtr(&out.PRsOpened, prRes.Opened)
addPtr(&out.PRsMerged, prRes.Merged)
}
}
}
// Leave OutcomeStats nil when every repo was skipped (missing
// author email) or every git command failed. Emitting an
// all-zero block would falsely advertise "no commits" when the
// real signal is "we couldn't derive any".
if !contributed {
return nil
}
s.OutcomeStats = out
return nil
}
// addPtr lazily allocates *p on first write, then adds v. Used by
// computeOutcomeStats so PRsOpened / PRsMerged stay nil when no repo
// produced a gh result — distinguishing "gh not configured" from a
// legitimate zero count.
func addPtr(p **int, v int) {
if *p == nil {
zero := 0
*p = &zero
}
**p += v
}