From bd468208fe764f94b3112f49d8f1d43396c8391f Mon Sep 17 00:00:00 2001 From: Prateek Rungta Date: Sat, 1 Aug 2026 07:57:00 -0400 Subject: [PATCH 1/7] feat(filter): expose branch filters in clients Make branch identity usable across session discovery, activity analysis, and usage attribution so users can inspect work by branch without losing backend parity or fast default query paths. Squashed follow-ups: - fix(usage): preserve comparison work during lazy branch enrichment - fix(usage): keep project-qualified branches independently selectable - perf(usage): skip branch buckets for totals-only comparisons Co-authored-by: Wes McKinney Co-authored-by: Marius van Niekerk --- .github/workflows/ci.yml | 7 + .gitignore | 2 + Makefile | 7 + cmd/agentsview/activity.go | 47 +- cmd/agentsview/cli.go | 9 + cmd/agentsview/cli_test.go | 28 ++ cmd/agentsview/session_get.go | 3 + cmd/agentsview/session_list.go | 8 + cmd/agentsview/session_search.go | 9 +- cmd/benchgate/main.go | 8 +- cmd/testfixture/main.go | 26 +- docs/session-api.md | 38 +- frontend/messages/en.json | 22 +- frontend/messages/fr.json | 38 ++ frontend/messages/ko.json | 30 +- frontend/messages/zh-CN.json | 22 +- frontend/messages/zh-TW.json | 22 +- frontend/src/lib/api/client.ts | 34 ++ frontend/src/lib/api/generated/index.ts | 7 +- .../models/ActivityBranchKeyMinutes.ts | 16 + .../api/generated/models/ActivityReport.ts | 1 + .../lib/api/generated/models/BranchTotal.ts | 15 + .../api/generated/models/DbBranchBreakdown.ts | 15 + .../{DbBranchInfo.ts => DbBranchOption.ts} | 4 +- ...{BranchesResponse.ts => DbBranchResult.ts} | 3 +- .../api/generated/models/DbDailyUsageEntry.ts | 1 + .../generated/models/UsageSummaryResponse.ts | 1 + .../api/generated/services/ActivityService.ts | 2 +- .../generated/services/AnalyticsService.ts | 24 +- .../api/generated/services/MetadataService.ts | 30 +- .../api/generated/services/SearchService.ts | 2 +- .../api/generated/services/SessionsService.ts | 4 +- .../api/generated/services/TrendsService.ts | 2 +- .../api/generated/services/UsageService.ts | 56 ++- frontend/src/lib/api/types/activity.ts | 32 +- frontend/src/lib/api/types/core.ts | 11 - frontend/src/lib/api/types/usage.ts | 24 ++ frontend/src/lib/branchFilters.test.ts | 90 ++++ frontend/src/lib/branchFilters.ts | 83 ++++ .../components/activity/ActivityPage.svelte | 49 +++ .../components/activity/ActivityPage.test.ts | 47 +- .../lib/components/activity/Breakdowns.svelte | 74 ++-- .../components/activity/Breakdowns.test.ts | 179 ++++++-- .../activity/ConcurrencyTimeline.svelte | 29 +- .../activity/ConcurrencyTimeline.test.ts | 128 ++++-- .../components/activity/SessionsTable.svelte | 27 +- .../components/activity/SummaryCards.test.ts | 1 + .../lib/components/activity/activeSessions.ts | 13 +- .../components/analytics/ActiveFilters.svelte | 26 ++ .../components/analytics/AnalyticsPage.svelte | 6 + .../analytics/AnalyticsPage.test.ts | 27 ++ .../filters/SessionActiveFilters.svelte | 28 ++ .../filters/SessionFilterControl.svelte | 131 +++++- .../filters/SessionFilterControl.test.ts | 138 +++++- .../lib/components/shared/BranchPicker.svelte | 406 ++++++++++++++++++ .../components/shared/BranchPicker.test.ts | 299 +++++++++++++ .../lib/components/sidebar/SessionList.svelte | 1 - .../components/usage/AttributionPanel.svelte | 228 ++++++---- .../components/usage/AttributionPanel.test.ts | 314 ++++++++++++++ .../usage/CostTimeSeriesChart.svelte | 168 +++++--- .../usage/CostTimeSeriesChart.test.ts | 192 ++++++++- .../components/usage/FilterDropdown.svelte | 79 ++-- .../components/usage/FilterDropdown.test.ts | 82 ++++ .../src/lib/components/usage/Treemap.svelte | 20 +- .../src/lib/components/usage/UsagePage.svelte | 77 +++- .../lib/components/usage/UsagePage.test.ts | 35 ++ .../UsagePairwiseComparisonPanel.test.ts | 1 + .../usage/UsageSummaryCards.test.ts | 1 + frontend/src/lib/stores/activity.svelte.ts | 42 +- frontend/src/lib/stores/activity.test.ts | 30 +- frontend/src/lib/stores/analytics.svelte.ts | 20 + frontend/src/lib/stores/analytics.test.ts | 50 +++ frontend/src/lib/stores/router.svelte.ts | 1 + .../src/lib/stores/sessionRouteParams.test.ts | 1 + frontend/src/lib/stores/sessionRouteParams.ts | 1 + frontend/src/lib/stores/sessions.svelte.ts | 82 ++-- frontend/src/lib/stores/sessions.test.ts | 61 +++ frontend/src/lib/stores/usage.svelte.ts | 170 ++++++-- frontend/src/lib/stores/usage.test.ts | 346 +++++++++++++++ frontend/src/lib/utils/lists.ts | 23 + .../src/lib/utils/usageChartColors.test.ts | 1 + frontend/src/lib/utils/usageChartColors.ts | 16 + internal/activity/activity.go | 68 ++- internal/activity/branch_rollup_test.go | 45 ++ internal/activity/parity_pgtest_test.go | 40 +- internal/activity/sessions_test.go | 23 + internal/db/activityreport.go | 10 +- internal/db/branch_filter_test.go | 314 +++++++++++++- internal/db/insights_test.go | 4 +- internal/db/messages.go | 19 +- internal/db/query_dialect.go | 125 +++++- internal/db/query_dialect_test.go | 46 ++ internal/db/sessions.go | 133 ++++-- internal/db/store.go | 2 +- internal/db/usage.go | 362 ++++++++++------ internal/db/usage_cost_allocation.go | 20 +- internal/db/usage_test.go | 28 ++ internal/duckdb/activityreport.go | 4 +- internal/duckdb/analytics_usage.go | 253 ++++++----- internal/duckdb/store.go | 60 ++- internal/duckdb/store_test.go | 123 ++++-- internal/mcp/tools.go | 75 +++- internal/postgres/activityreport.go | 4 +- internal/postgres/push_pgtest_test.go | 1 + internal/postgres/sessions.go | 50 +-- internal/postgres/store_test.go | 51 +++ internal/postgres/usage.go | 290 +++++++------ internal/postgres/usage_pgtest_test.go | 151 ++++++- internal/recall/extract/manager_test.go | 4 +- internal/server/huma_routes_activity.go | 2 +- internal/server/huma_routes_analytics.go | 2 +- internal/server/huma_routes_metadata.go | 31 +- internal/server/huma_routes_search.go | 2 +- internal/server/huma_routes_sessions.go | 2 +- internal/server/huma_routes_usage.go | 7 +- internal/server/server_test.go | 43 ++ internal/server/usage.go | 31 ++ internal/server/usage_internal_test.go | 55 ++- internal/service/direct.go | 2 + internal/service/http.go | 5 + internal/service/usage.go | 156 ++++++- internal/service/usage_internal_test.go | 37 ++ internal/service/usage_test.go | 142 +++++- internal/sync/engine_integration_test.go | 4 +- testdata/golden/activity_report_v4.json | 36 ++ testdata/golden/usage_daily_breakdown_v4.json | 9 +- testdata/golden/usage_daily_v4.json | 9 +- 127 files changed, 6187 insertions(+), 1126 deletions(-) create mode 100644 frontend/src/lib/api/generated/models/ActivityBranchKeyMinutes.ts create mode 100644 frontend/src/lib/api/generated/models/BranchTotal.ts create mode 100644 frontend/src/lib/api/generated/models/DbBranchBreakdown.ts rename frontend/src/lib/api/generated/models/{DbBranchInfo.ts => DbBranchOption.ts} (70%) rename frontend/src/lib/api/generated/models/{BranchesResponse.ts => DbBranchResult.ts} (75%) create mode 100644 frontend/src/lib/components/shared/BranchPicker.svelte create mode 100644 frontend/src/lib/components/shared/BranchPicker.test.ts create mode 100644 frontend/src/lib/components/usage/FilterDropdown.test.ts create mode 100644 frontend/src/lib/utils/lists.ts create mode 100644 internal/activity/branch_rollup_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35d8b927a..9ed810e83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,13 @@ jobs: - name: Install docs check tools run: | + # The runner image's Microsoft apt repos intermittently fail + # signature validation and abort `apt-get update`; ripgrep comes + # from the Ubuntu archive, so drop them rather than fail on them. + sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list \ + /etc/apt/sources.list.d/microsoft-prod.sources \ + /etc/apt/sources.list.d/azure-cli.list \ + /etc/apt/sources.list.d/azure-cli.sources sudo apt-get update sudo apt-get install -y ripgrep diff --git a/.gitignore b/.gitignore index b7ad391f3..33df225f6 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,8 @@ __pycache__/ .cache .gomodcache/ .gopath/ +# per-worktree golangci-lint cache (see GOLANGCI_LINT_CACHE in Makefile) +/.golangci-cache/ # Docs build and hydrated assets docs/.venv/ diff --git a/Makefile b/Makefile index 997325cdf..f53fa1910 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,13 @@ GOLANGCI_LINT_VERSION ?= v2.11.4 GOLANGCI_LINT_CACHE ?= $(CURDIR)/.golangci-cache export GOLANGCI_LINT_CACHE CUSTOM_GCL := ./custom-gcl +# Isolate golangci-lint's cache per checkout. Sibling worktrees share the +# default cache (~/Library/Caches/golangci-lint), and cached suggested-fixes +# resolve through worktree-relative paths, so `run --fix` can apply byte +# offsets computed against another checkout's version of a file and corrupt +# it in place. +GOLANGCI_LINT_CACHE ?= $(CURDIR)/.golangci-cache +export GOLANGCI_LINT_CACHE PRICING_SNAPSHOT_FILE := internal/pricing/snapshot/litellm_snapshot.json.gz # sqlite-vec's cgo bindings #include "sqlite3.h". Without an override the diff --git a/cmd/agentsview/activity.go b/cmd/agentsview/activity.go index 1e6c14c8a..4b11006f3 100644 --- a/cmd/agentsview/activity.go +++ b/cmd/agentsview/activity.go @@ -27,6 +27,7 @@ type ActivityReportConfig struct { Timezone string Bucket string Project string + Branch string Agent string Machine string JSON bool @@ -39,6 +40,10 @@ var activityReportNow = time.Now // runActivityReport syncs, resolves the range, runs the report, and prints it. func runActivityReport(cfg ActivityReportConfig) { ctx := context.Background() + if _, err := branchFilterToken(cfg.Project, cfg.Branch); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } backend, cleanup, err := resolveArchiveQueryBackend(ctx, archiveQueryPolicy{ Offline: cfg.Offline, NoSync: cfg.NoSync, @@ -92,6 +97,11 @@ func fetchHTTPActivityReport( setIfNotEmpty("project", cfg.Project) setIfNotEmpty("agent", cfg.Agent) setIfNotEmpty("machine", cfg.Machine) + gitBranch, err := branchFilterToken(cfg.Project, cfg.Branch) + if err != nil { + return activity.Report{}, err + } + setIfNotEmpty("git_branch", gitBranch) endpoint := strings.TrimSuffix(tr.URL, "/") + "/api/v1/activity/report?" + q.Encode() @@ -165,9 +175,14 @@ func resolveActivityReport( return activity.Report{}, err } + gitBranch, err := branchFilterToken(cfg.Project, cfg.Branch) + if err != nil { + return activity.Report{}, err + } f := db.AnalyticsFilter{ Timezone: tz, Project: cfg.Project, + GitBranch: gitBranch, Agent: cfg.Agent, Machine: cfg.Machine, ExcludeOneShot: false, @@ -211,6 +226,7 @@ func printActivityReport(r activity.Report) { printKeyMinutes("By project", r.ByProject) printKeyMinutes("By model", r.ByModel) printKeyMinutes("By agent", r.ByAgent) + printBranchKeyMinutes("By branch", r.ByBranch) printActivitySessions(r.BySession) } @@ -243,7 +259,7 @@ func printKeyMinutes(label string, rows []activity.KeyMinutes) { return } w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) - for _, row := range topKeyMinutes(rows, 5) { + for _, row := range firstN(rows, 5) { fmt.Fprintf(w, " %s\t%.1f min\n", sanitizeTerminal(row.Key), row.AgentMinutes) } @@ -251,6 +267,29 @@ func printKeyMinutes(label string, rows []activity.KeyMinutes) { fmt.Println() } +func printBranchKeyMinutes(label string, rows []activity.BranchKeyMinutes) { + fmt.Printf("%s (top 5):\n", label) + if len(rows) == 0 { + fmt.Println(" (none)") + fmt.Println() + return + } + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + for _, row := range firstN(rows, 5) { + branch := row.Branch + if branch == "" { + branch = "(no branch)" + } + key := branch + if row.Project != "" { + key = row.Project + "/" + branch + } + fmt.Fprintf(w, " %s\t%.1f min\n", sanitizeTerminal(key), row.AgentMinutes) + } + w.Flush() + fmt.Println() +} + // printActivitySessions prints the top 5 sessions by appearance order. func printActivitySessions(rows []activity.SessionRow) { fmt.Println("Top sessions (top 5):") @@ -260,8 +299,7 @@ func printActivitySessions(rows []activity.SessionRow) { } w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) fmt.Fprintln(w, " TITLE\tPROJECT\tAGENT\tMINUTES\tCOST") - limit := min(len(rows), 5) - for _, s := range rows[:limit] { + for _, s := range firstN(rows, 5) { fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", sanitizeTerminal(s.Title), sanitizeTerminal(s.Project), sanitizeTerminal(s.Agent), @@ -271,8 +309,7 @@ func printActivitySessions(rows []activity.SessionRow) { w.Flush() } -// topKeyMinutes returns the first n rows of rows (already sorted by the query). -func topKeyMinutes(rows []activity.KeyMinutes, n int) []activity.KeyMinutes { +func firstN[T any](rows []T, n int) []T { return rows[:min(len(rows), n)] } diff --git a/cmd/agentsview/cli.go b/cmd/agentsview/cli.go index caceeaf2d..5882ec0cc 100644 --- a/cmd/agentsview/cli.go +++ b/cmd/agentsview/cli.go @@ -25,6 +25,14 @@ const ( const dataVersionTooNewExitCode = 3 +func branchFilterToken(project, branch string) (string, error) { + tok, err := db.BranchFilterToken(project, branch) + if errors.Is(err, db.ErrBranchWithoutProject) { + return "", errors.New("--branch requires --project") + } + return tok, err +} + type cliExitError struct { code int err error @@ -580,6 +588,7 @@ func newActivityReportCommand() *cobra.Command { cmd.Flags().StringVar(&cfg.Timezone, "timezone", "", "IANA timezone for range bucketing") cmd.Flags().StringVar(&cfg.Bucket, "bucket", "", "Bucket size: 5m, 15m, 1h, 1d, 1w") cmd.Flags().StringVar(&cfg.Project, "project", "", "Filter by project") + cmd.Flags().StringVar(&cfg.Branch, "branch", "", "Filter by git branch name (requires --project)") cmd.Flags().StringVar(&cfg.Agent, "agent", "", "Filter by agent name") cmd.Flags().StringVar(&cfg.Machine, "machine", "", "Filter by machine name") registerFormatFlags(cmd.Flags()) diff --git a/cmd/agentsview/cli_test.go b/cmd/agentsview/cli_test.go index 334a5bafd..80ee5c093 100644 --- a/cmd/agentsview/cli_test.go +++ b/cmd/agentsview/cli_test.go @@ -366,3 +366,31 @@ func TestSyncHelpMentionsConfiguredHosts(t *testing.T) { assert.Contains(t, help, want, "sync help missing %q", want) } } + +func TestBranchFilterToken(t *testing.T) { + tests := []struct { + name string + project string + branch string + wantErr bool + want string + }{ + {name: "empty branch yields no token", project: "proj", branch: ""}, + {name: "branch without project errors", project: "", branch: "main", wantErr: true}, + { + name: "project and branch encode a token", project: "proj", branch: "main", + want: db.EncodeBranchFilterToken("proj", "main"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tok, err := branchFilterToken(tt.project, tt.branch) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, tok) + }) + } +} diff --git a/cmd/agentsview/session_get.go b/cmd/agentsview/session_get.go index 55ef21203..d40add05b 100644 --- a/cmd/agentsview/session_get.go +++ b/cmd/agentsview/session_get.go @@ -136,6 +136,9 @@ func printSessionDetailHuman(w io.Writer, s *service.SessionDetail) error { fmt.Fprintf(w, "%s %s\n", label("ID"), sanitizeTerminal(s.ID)) fmt.Fprintf(w, "%s %s\n", label("Name"), sanitizeTerminal(name)) fmt.Fprintf(w, "%s %s\n", label("Project"), sanitizeTerminal(s.Project)) + if s.GitBranch != "" { + fmt.Fprintf(w, "%s %s\n", label("Branch"), sanitizeTerminal(s.GitBranch)) + } fmt.Fprintf(w, "%s %s\n", label("Agent"), sanitizeTerminal(s.Agent)) fmt.Fprintf(w, "%s %s\n", label("Machine"), sanitizeTerminal(s.Machine)) fmt.Fprintf(w, "%s %s\n", diff --git a/cmd/agentsview/session_list.go b/cmd/agentsview/session_list.go index 7cb64dfc9..48ebf7c80 100644 --- a/cmd/agentsview/session_list.go +++ b/cmd/agentsview/session_list.go @@ -20,6 +20,7 @@ import ( func newSessionListCommand() *cobra.Command { var ( project, excludeProject, machine, agent string + branch string date, dateFrom, dateTo, activeSince string since string minMessages, maxMessages int @@ -47,6 +48,10 @@ func newSessionListCommand() *cobra.Command { } activeSince = resolvedActiveSince + gitBranch, err := branchFilterToken(project, branch) + if err != nil { + return err + } svc, cleanup, err := resolveService(cmd) if err != nil { return err @@ -57,6 +62,7 @@ func newSessionListCommand() *cobra.Command { Project: project, ExcludeProject: excludeProject, Machine: machine, + GitBranch: gitBranch, Agent: agent, Date: date, DateFrom: dateFrom, @@ -141,6 +147,8 @@ func newSessionListCommand() *cobra.Command { "Exclude sessions from the given project") flags.StringVar(&machine, "machine", "", "Filter by machine name") + flags.StringVar(&branch, "branch", "", + "Filter by git branch name (requires --project)") flags.StringVar(&agent, "agent", "", "Filter by agent (claude, codex, cursor, ...)") flags.StringVar(&date, "date", "", diff --git a/cmd/agentsview/session_search.go b/cmd/agentsview/session_search.go index 17eade611..a31857ae2 100644 --- a/cmd/agentsview/session_search.go +++ b/cmd/agentsview/session_search.go @@ -23,7 +23,8 @@ func newSessionSearchCommand() *cobra.Command { in, scope string excludeSystem, reveal bool project, excludeProject, agent string - machine, date, dateFrom, dateTo string + machine, branch string + date, dateFrom, dateTo string activeSince, since string includeChildren, includeAutomated bool includeOneShot bool @@ -53,6 +54,10 @@ func newSessionSearchCommand() *cobra.Command { if err != nil { return err } + gitBranch, err := branchFilterToken(project, branch) + if err != nil { + return err + } svc, cleanup, err := resolveService(cmd) if err != nil { return err @@ -68,6 +73,7 @@ func newSessionSearchCommand() *cobra.Command { Project: project, ExcludeProject: excludeProject, Machine: machine, + GitBranch: gitBranch, Agent: agent, Date: date, DateFrom: dateFrom, @@ -110,6 +116,7 @@ func newSessionSearchCommand() *cobra.Command { flags.StringVar(&project, "project", "", "Filter by project name") flags.StringVar(&excludeProject, "exclude-project", "", "Exclude project") flags.StringVar(&machine, "machine", "", "Filter by machine") + flags.StringVar(&branch, "branch", "", "Filter by git branch name (requires --project)") flags.StringVar(&agent, "agent", "", "Filter by agent") flags.StringVar(&date, "date", "", "Sessions active on YYYY-MM-DD") flags.StringVar(&dateFrom, "date-from", "", "Sessions active on or after YYYY-MM-DD") diff --git a/cmd/benchgate/main.go b/cmd/benchgate/main.go index fa7a94b27..5946a4fb7 100644 --- a/cmd/benchgate/main.go +++ b/cmd/benchgate/main.go @@ -410,10 +410,10 @@ func render(r results) (string, int) { return b.String(), 0 } -// renderSyntax reports unparseable result lines in one capture. A -// benchmark whose result line is corrupted (e.g. by interleaved log -// output) parses on neither side and would otherwise vanish from -// the gate without a trace. +// renderSyntax reports unparseable result lines in one capture +// (e.g. log output interleaved into a result line). Candidate-side +// corruption is fatal in render; baseline-side corruption only drops +// the affected benchmark to the missing-baseline not-gated path. func renderSyntax(b *strings.Builder, side string, errs []string) { if len(errs) == 0 { return diff --git a/cmd/testfixture/main.go b/cmd/testfixture/main.go index 77d957797..a26cb70cd 100644 --- a/cmd/testfixture/main.go +++ b/cmd/testfixture/main.go @@ -24,31 +24,32 @@ type sessionSpec struct { parentSessionID string relationshipType string terminationStatus string + gitBranch string } var specs = []sessionSpec{ - {"project-alpha", "small-2", 2, 2, "", "", ""}, - {"project-alpha", "small-5", 5, 3, "", "", ""}, + {"project-alpha", "small-2", 2, 2, "", "", "", "main"}, + {"project-alpha", "small-5", 5, 3, "", "", "", "feature/login"}, // One unclean session for e2e termination tests. {"project-beta", "mixed-content-7", 7, 3, "", "", - "tool_call_pending"}, - {"project-beta", "medium-8", 8, 4, "", "", ""}, - {"project-beta", "medium-100", 100, 50, "", "", ""}, - {"project-gamma", "large-200", 200, 100, "", "", ""}, - {"project-gamma", "large-1500", 1500, 750, "", "", ""}, - {"project-delta", "xlarge-5500", 5500, 2750, "", "", ""}, + "tool_call_pending", "main"}, + {"project-beta", "medium-8", 8, 4, "", "", "", "fix/crash"}, + {"project-beta", "medium-100", 100, 50, "", "", "", "main"}, + {"project-gamma", "large-200", 200, 100, "", "", "", "release/v2"}, + {"project-gamma", "large-1500", 1500, 750, "", "", "", ""}, + {"project-delta", "xlarge-5500", 5500, 2750, "", "", "", "main"}, // Sub-agent and fork sessions: must NOT appear in session // list, stats, or analytics summary counts. {"project-alpha", "subagent-1", 12, 6, - "test-session-small-5", "subagent", ""}, + "test-session-small-5", "subagent", "", "feature/login"}, {"project-alpha", "subagent-2", 8, 4, - "test-session-small-5", "subagent", ""}, + "test-session-small-5", "subagent", "", "feature/login"}, {"project-beta", "fork-1", 15, 7, - "test-session-medium-8", "fork", ""}, + "test-session-medium-8", "fork", "", "fix/crash"}, // Empty session (0 messages): must also be excluded. - {"project-gamma", "empty-0", 0, 0, "", "", ""}, + {"project-gamma", "empty-0", 0, 0, "", "", "", ""}, } func main() { @@ -244,6 +245,7 @@ func createSessionFixture( Project: spec.project, Machine: "test-machine", Agent: "claude", + GitBranch: spec.gitBranch, StartedAt: new(startedAt.Format(time.RFC3339Nano)), EndedAt: new(endedAt.Format(time.RFC3339Nano)), MessageCount: spec.msgCount, diff --git a/docs/session-api.md b/docs/session-api.md index 63c98c06f..1c3ee4f87 100644 --- a/docs/session-api.md +++ b/docs/session-api.md @@ -120,26 +120,33 @@ GET /api/v1/branches GET /api/v1/agents ``` -`GET /api/v1/branches` returns distinct `(project, branch)` pairs -plus an opaque `token` field: +`GET /api/v1/branches` returns a bounded list of distinct branch names for +filter pickers. Results are sorted by session count and then branch name. The +search is case-insensitive and matches branch-name substrings. + +| Query parameter | Meaning | +|-----------------|---------| +| `search` | Optional case-insensitive branch-name substring | +| `projects` | Optional repeated project filter applied before branch-name deduplication | +| `scope` | `roots` by default; `all` also includes subagent and fork sessions | +| `limit` | Maximum branch names, from 1 through 100; default 100 | ```json { "branches": [ { - "project": "myapp", "branch": "main", - "token": "..." + "session_count": 42 } - ] + ], + "has_more": false } ``` -Pass the returned token back as the `git_branch` query parameter on -branch-aware endpoints. Treat it as opaque and URL-encode it in -manual HTTP calls. The token is scoped by both project and branch, -so `app-a/main` and `app-b/main` remain distinct, and an empty -branch remains distinct from a literal `unknown` branch. +`has_more` is true when additional matching branch names exist beyond the +requested limit. Branch-aware endpoints accept branch names directly in +`git_branch`; clients do not obtain opaque filter tokens from this metadata +endpoint. ## Commands @@ -229,7 +236,7 @@ therefore appear on both dates. | `--project` | `project` | string | | `--exclude-project` | `exclude_project` | string | | `--machine` | `machine` | string | -| — | `git_branch` | opaque token from `GET /api/v1/branches` | +| `--branch` | `git_branch` | Branch name; the CLI requires `--project`. Direct HTTP callers may supply project scope separately | | `--agent` | `agent` | string | | `--date` | `date` | `YYYY-MM-DD` | | `--date-from` | `date_from` | `YYYY-MM-DD` | @@ -573,7 +580,7 @@ default; opt back in with `--include-one-shot`, | `--project` | `project` | string | | `--exclude-project` | `exclude_project` | string | | `--machine` | `machine` | string | -| — | `git_branch` | opaque token from `GET /api/v1/branches` | +| `--branch` | `git_branch` | Branch name; requires `--project`. The HTTP param takes an opaque token from `GET /api/v1/branches`, which the flag encodes | | `--agent` | `agent` | string | | `--date` | `date` | `YYYY-MM-DD` | | `--date-from` | `date_from` | `YYYY-MM-DD` | @@ -804,7 +811,7 @@ metadata contract as `agentsview activity report --json`. | `timezone` | IANA timezone name; default `UTC` | | `bucket` | Optional bucket override: `5m`, `15m`, `1h`, `1d`, or `1w` | | `project` | Filter by project | -| `git_branch` | Filter by opaque branch token from `GET /api/v1/branches` | +| `git_branch` | Opaque (project, branch) token from `GET /api/v1/branches`; the CLI `--branch` flag encodes it | | `agent` | Filter by agent | | `machine` | Filter by machine | | `automation` | `all`, `interactive`, or `automated`; default `all` | @@ -898,6 +905,7 @@ Response excerpt: "by_project": [{"key": "agentsview", "agent_minutes": 96.4, "cost": 4.20}], "by_model": [{"key": "claude-sonnet-4-6", "agent_minutes": 80.0, "cost": 3.10}], "by_agent": [{"key": "codex", "agent_minutes": 64.0, "cost": 2.85}], + "by_branch": [{"project": "agentsview", "branch": "main", "agent_minutes": 96.4, "cost": 4.20}], "by_session": [ { "session_id": "codex:abc", @@ -926,7 +934,9 @@ Response excerpt: ``` Breakdown rows include total, automated, and interactive minutes and -costs. Session rows with no reliable timestamped activity use +costs. `by_branch` rows carry `project`/`branch` as separate fields; an +empty `branch` means no recorded branch. Session rows with no reliable +timestamped activity use `"timing_quality": "untimed"` and `agent_minutes: null`; they can still contribute cost and output tokens when usage rows exist. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 9f7fa17ac..0a9c45ff0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -477,6 +477,9 @@ "sidebar_filters_machine": "Machine", "sidebar_filters_search_machines": "Search machines...", "sidebar_filters_no_machines": "No machines", + "sidebar_filters_branch": "Branch", + "sidebar_filters_search_branches": "Search branches...", + "sidebar_filters_no_branches": "No branches", "sidebar_filters_min_prompts": "Min Prompts", "sidebar_filters_clear_filters": "Clear filters", "sidebar_row_expand": "Expand", @@ -495,6 +498,7 @@ "shared_active_filters_label": "Filters:", "shared_active_filters_clear_project": "Clear project filter", "shared_active_filters_remove_machine": "Remove {machine} filter", + "shared_active_filters_remove_branch": "Remove {branch} filter", "shared_active_filters_remove_agent": "Remove {agent} filter", "shared_active_filters_clear_min_prompts": "Clear min prompts filter", "shared_active_filters_min_prompts": "≥{count} prompts", @@ -826,6 +830,7 @@ "usage_token_type_output": "Output", "usage_selected_tokens": "Selected Tokens", "usage_top_sessions_by_selected_tokens": "Top Sessions by {tokenTypes}", + "usage_branch": "Branch", "usage_refresh": "Refresh usage data", "usage_summary_total_cost": "Total Cost", "usage_summary_total_tokens": "Total Tokens", @@ -866,6 +871,7 @@ ], "usage_cost_over_time_title": "Cost Over Time", "usage_tokens_over_time_title": "Tokens Over Time", + "usage_unattributed": "Unattributed", "usage_cost_attribution_title": "Cost Attribution", "usage_tokens_attribution_title": "Token Attribution", "usage_attribution_treemap": "Treemap", @@ -873,6 +879,11 @@ "usage_click_to_hide_hint": "Click to hide from chart", "usage_click_to_hide": "Click to hide {label}", "usage_hide_from_chart": "Hide {label} from chart", + "usage_click_to_filter_hint": "Click to add or remove filters", + "usage_click_to_filter": "Click to filter by {label}", + "usage_click_to_clear_filter": "Click to clear the {label} filter", + "usage_filter_to_item": "Filter by {label}", + "usage_clear_filter_item": "Clear the {label} filter", "usage_cache_efficiency_title": "Cache Efficiency", "usage_cache_reads": "Cache Reads", "usage_cache_writes": "Cache Writes", @@ -1036,6 +1047,8 @@ "activity_all_agents": "All Agents", "activity_filter_by_machine": "Filter by machine", "activity_all_machines": "All Machines", + "activity_filter_by_branch": "Filter by branch", + "activity_all_branches": "All Branches", "activity_filter_by_automation": "Filter by automation", "activity_all_sessions": "All Sessions", "activity_interactive": "Interactive", @@ -1044,6 +1057,7 @@ "activity_project": "Project", "activity_model": "Model", "activity_agent": "Agent", + "activity_branch": "Branch", "activity_min_unit": " min", "activity_int_auto_split": "int {int} / auto {auto}", "activity_breakdown": "Breakdown", @@ -1993,13 +2007,19 @@ "activity_insight_no_matching_agents": "No matching agents", "activity_filter_agents_placeholder": "Filter agents", "activity_filter_machines_placeholder": "Filter machines", + "activity_filter_branches_placeholder": "Filter branches", "activity_filter_automation_placeholder": "Filter automation", "activity_no_matching_agents": "No matching agents", "activity_no_matching_machines": "No matching machines", + "activity_no_matching_branches": "No matching branches", "activity_no_automation_filters": "No automation filters", "activity_loading_report": "Loading activity report...", "appearance_high_contrast": "High contrast", "appearance_on": "On", "appearance_off": "Off", - "appearance_text_size": "Text size" + "appearance_text_size": "Text size", + "shared_branch_clear_search": "Clear branch search", + "shared_branch_loading": "Loading branches…", + "shared_branch_no_match": "No matching branches", + "shared_branch_refine": "More branches exist. Refine your search." } diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index f6bc91ad0..f07d1f3ce 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -468,6 +468,9 @@ "sidebar_filters_hide_single_turn": "Masquer les sessions à un tour", "sidebar_filters_include_automated": "Inclure les sessions automatisées", "sidebar_filters_project": "Projet", + "sidebar_filters_branch": "Branche", + "sidebar_filters_search_branches": "Rechercher des branches...", + "sidebar_filters_no_branches": "Aucune branche", "sidebar_filters_hide_unknown": "Masquer les inconnus", "sidebar_filters_agent": "Agent", "sidebar_filters_search_agents": "Rechercher des agents...", @@ -477,6 +480,9 @@ "sidebar_filters_machine": "Machine", "sidebar_filters_search_machines": "Rechercher des machines...", "sidebar_filters_no_machines": "Aucune machine", + "sidebar_filters_branch": "Branche", + "sidebar_filters_search_branches": "Rechercher des branches...", + "sidebar_filters_no_branches": "Aucune branche", "sidebar_filters_min_prompts": "Prompts min.", "sidebar_filters_clear_filters": "Effacer les filtres", "sidebar_row_expand": "Déplier", @@ -495,6 +501,7 @@ "shared_active_filters_label": "Filtres :", "shared_active_filters_clear_project": "Effacer le filtre de projet", "shared_active_filters_remove_machine": "Retirer le filtre {machine}", + "shared_active_filters_remove_branch": "Retirer le filtre {branch}", "shared_active_filters_remove_agent": "Retirer le filtre {agent}", "shared_active_filters_clear_min_prompts": "Effacer le filtre de prompts minimum", "shared_active_filters_min_prompts": "≥{count} prompts", @@ -503,6 +510,7 @@ "shared_active_filters_clear_hidden_unknown": "Effacer le filtre masquant les projets inconnus", "shared_active_filters_unknown_hidden": "Inconnus masqués", "shared_active_filters_remove_project": "Retirer le filtre de projet {project}", + "shared_active_filters_remove_branch": "Retirer le filtre {branch}", "shared_active_filters_clear_single_turn": "Effacer le filtre à un tour", "shared_active_filters_single_turn_hidden": "Un seul tour masqué", "shared_active_filters_clear_automated": "Effacer le filtre des sessions automatisées", @@ -566,6 +574,9 @@ "shared_none": "Aucun", "shared_other": "Autre", "shared_no_branch": "(aucune branche)", + "shared_branch_loading": "Chargement des branches…", + "shared_branch_no_match": "Aucune branche correspondante", + "shared_branch_refine": "D'autres branches existent. Affinez votre recherche.", "shared_unknown": "inconnu", "analytics_refresh": "Actualiser les analytics", "analytics_export_csv": "Exporter en CSV", @@ -812,7 +823,9 @@ "analytics_skill_trend_loading": "Chargement de l'utilisation des skills...", "analytics_skill_trend_empty": "Aucune donnée d'utilisation des skills", "usage_model": "Modèle", + "usage_branch": "Branche", "usage_models": "Modèles", + "usage_branch": "Branche", "usage_mode_label": "Mesure de consommation", "usage_mode_cost": "Coût", "usage_mode_tokens": "Tokens", @@ -845,6 +858,8 @@ "usage_filter_selected": "{label} : {countLabel} sélectionné(s)", "usage_filter_none": "{label} : aucun", "usage_filter_hidden": "{label} : {countLabel} masqué(s)", + "usage_filter_to_item": "Filtrer par {label}", + "usage_clear_filter_item": "Effacer le filtre {label}", "usage_filter_search": "Rechercher...", "usage_filter_select_all": "Tout sélectionner", "usage_filter_deselect_all": "Tout désélectionner", @@ -870,9 +885,18 @@ "usage_tokens_attribution_title": "Répartition des tokens", "usage_attribution_treemap": "Treemap", "usage_attribution_list": "Liste", + "usage_unattributed": "Sans attribution", + "usage_click_to_filter_hint": "Cliquez pour ajouter ou retirer des filtres", + "usage_click_to_filter": "Cliquez pour filtrer par {label}", + "usage_click_to_clear_filter": "Cliquez pour effacer le filtre {label}", "usage_click_to_hide_hint": "Cliquez pour masquer dans le graphique", "usage_click_to_hide": "Cliquez pour masquer {label}", "usage_hide_from_chart": "Masquer {label} du graphique", + "usage_click_to_filter_hint": "Cliquez pour ajouter ou supprimer des filtres", + "usage_click_to_filter": "Cliquez pour filtrer par {label}", + "usage_click_to_clear_filter": "Cliquez pour effacer le filtre {label}", + "usage_filter_to_item": "Filtrer par {label}", + "usage_clear_filter_item": "Effacer le filtre {label}", "usage_cache_efficiency_title": "Efficacité du cache", "usage_cache_reads": "Lectures de cache", "usage_cache_writes": "Écritures de cache", @@ -1034,8 +1058,15 @@ "activity_insight_generate": "Générer", "activity_filter_by_agent": "Filtrer par agent", "activity_all_agents": "Tous les agents", + "activity_filter_by_branch": "Filtrer par branche", + "activity_all_branches": "Toutes les branches", + "activity_branch": "Branche", + "activity_filter_branches_placeholder": "Filtrer les branches", + "activity_no_matching_branches": "Aucune branche correspondante", "activity_filter_by_machine": "Filtrer par machine", "activity_all_machines": "Toutes les machines", + "activity_filter_by_branch": "Filtrer par branche", + "activity_all_branches": "Toutes les branches", "activity_filter_by_automation": "Filtrer par automatisation", "activity_all_sessions": "Toutes les sessions", "activity_interactive": "Interactives", @@ -1044,6 +1075,7 @@ "activity_project": "Projet", "activity_model": "Modèle", "activity_agent": "Agent", + "activity_branch": "Branche", "activity_min_unit": " min", "activity_int_auto_split": "int {int} / auto {auto}", "activity_breakdown": "Répartition", @@ -1992,11 +2024,17 @@ "activity_insight_no_matching_agents": "Aucun agent correspondant", "activity_filter_agents_placeholder": "Filtrer les agents", "activity_filter_machines_placeholder": "Filtrer les machines", + "activity_filter_branches_placeholder": "Filtrer les branches", "activity_filter_automation_placeholder": "Filtrer l'automatisation", "activity_no_matching_agents": "Aucun agent correspondant", "activity_no_matching_machines": "Aucune machine correspondante", + "activity_no_matching_branches": "Aucune branche correspondante", "activity_no_automation_filters": "Aucun filtre d'automatisation", "activity_loading_report": "Chargement du rapport d'activité...", + "shared_branch_clear_search": "Effacer la recherche de branches", + "shared_branch_loading": "Chargement des branches…", + "shared_branch_no_match": "Aucune branche correspondante", + "shared_branch_refine": "D'autres branches existent. Affinez votre recherche.", "appearance_high_contrast": "Contraste élevé", "appearance_on": "Activé", "appearance_off": "Désactivé", diff --git a/frontend/messages/ko.json b/frontend/messages/ko.json index b47d66069..3b02c03c9 100644 --- a/frontend/messages/ko.json +++ b/frontend/messages/ko.json @@ -202,7 +202,9 @@ "input countLabel", "local countPlural = count: plural" ], - "selectors": ["countPlural"], + "selectors": [ + "countPlural" + ], "match": { "countPlural=other": "{countLabel}개 단계" } @@ -464,6 +466,9 @@ "sidebar_filters_machine": "머신", "sidebar_filters_search_machines": "머신 검색...", "sidebar_filters_no_machines": "머신 없음", + "sidebar_filters_branch": "브랜치", + "sidebar_filters_search_branches": "브랜치 검색...", + "sidebar_filters_no_branches": "브랜치 없음", "sidebar_filters_min_prompts": "최소 프롬프트 수", "sidebar_filters_clear_filters": "필터 지우기", "sidebar_row_expand": "펼치기", @@ -482,6 +487,7 @@ "shared_active_filters_label": "필터:", "shared_active_filters_clear_project": "프로젝트 필터 지우기", "shared_active_filters_remove_machine": "{machine} 필터 제거", + "shared_active_filters_remove_branch": "{branch} 브랜치 필터 제거", "shared_active_filters_remove_agent": "{agent} 필터 제거", "shared_active_filters_clear_min_prompts": "최소 프롬프트 필터 지우기", "shared_active_filters_min_prompts": "≥{count} 프롬프트", @@ -614,7 +620,9 @@ "input countLabel", "local countPlural = count: plural" ], - "selectors": ["countPlural"], + "selectors": [ + "countPlural" + ], "match": { "countPlural=other": "세션 {countLabel}개" } @@ -789,6 +797,7 @@ "analytics_skill_trend_empty": "스킬 사용 데이터가 없습니다", "usage_model": "모델", "usage_models": "모델", + "usage_branch": "브랜치", "usage_mode_label": "사용량 지표", "usage_mode_cost": "비용", "usage_mode_tokens": "토큰", @@ -841,6 +850,7 @@ ], "usage_cost_over_time_title": "시간대별 비용", "usage_tokens_over_time_title": "시간대별 토큰", + "usage_unattributed": "미귀속", "usage_cost_attribution_title": "비용 배분", "usage_tokens_attribution_title": "토큰 배분", "usage_attribution_treemap": "트리맵", @@ -848,6 +858,11 @@ "usage_click_to_hide_hint": "클릭하여 차트에서 숨기기", "usage_click_to_hide": "{label} 숨기기", "usage_hide_from_chart": "{label}을(를) 차트에서 숨기기", + "usage_click_to_filter_hint": "클릭하여 필터 추가 또는 제거", + "usage_click_to_filter": "클릭하여 {label}(으)로 필터링", + "usage_click_to_clear_filter": "클릭하여 {label} 필터 해제", + "usage_filter_to_item": "{label}(으)로 필터링", + "usage_clear_filter_item": "{label} 필터 해제", "usage_cache_efficiency_title": "캐시 효율성", "usage_cache_reads": "캐시 읽기", "usage_cache_writes": "캐시 쓰기", @@ -1011,6 +1026,8 @@ "activity_all_agents": "모든 에이전트", "activity_filter_by_machine": "머신으로 필터링", "activity_all_machines": "모든 머신", + "activity_filter_by_branch": "브랜치로 필터링", + "activity_all_branches": "모든 브랜치", "activity_filter_by_automation": "자동화 여부로 필터링", "activity_all_sessions": "모든 세션", "activity_interactive": "대화형", @@ -1019,6 +1036,7 @@ "activity_project": "프로젝트", "activity_model": "모델", "activity_agent": "에이전트", + "activity_branch": "브랜치", "activity_min_unit": " 분", "activity_int_auto_split": "대화형 {int} / 자동 {auto}", "activity_breakdown": "세부 내역", @@ -1955,13 +1973,19 @@ "activity_insight_no_matching_agents": "일치하는 에이전트 없음", "activity_filter_agents_placeholder": "에이전트 필터링", "activity_filter_machines_placeholder": "머신 필터링", + "activity_filter_branches_placeholder": "브랜치 필터링", "activity_filter_automation_placeholder": "자동화 필터링", "activity_no_matching_agents": "일치하는 에이전트 없음", "activity_no_matching_machines": "일치하는 머신 없음", + "activity_no_matching_branches": "일치하는 브랜치 없음", "activity_no_automation_filters": "자동화 필터 없음", "activity_loading_report": "활동 보고서 로드 중...", "appearance_high_contrast": "고대비", "appearance_on": "켜짐", "appearance_off": "꺼짐", - "appearance_text_size": "텍스트 크기" + "appearance_text_size": "텍스트 크기", + "shared_branch_clear_search": "브랜치 검색 지우기", + "shared_branch_loading": "브랜치를 불러오는 중…", + "shared_branch_no_match": "일치하는 브랜치가 없습니다", + "shared_branch_refine": "더 많은 브랜치가 있습니다. 검색어를 구체화하세요." } diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index d1ba74037..3525066bd 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -464,6 +464,9 @@ "sidebar_filters_machine": "Machine", "sidebar_filters_search_machines": "搜索 machines...", "sidebar_filters_no_machines": "无 machines", + "sidebar_filters_branch": "分支", + "sidebar_filters_search_branches": "搜索分支...", + "sidebar_filters_no_branches": "无分支", "sidebar_filters_min_prompts": "最少提示数", "sidebar_filters_clear_filters": "清除筛选器", "sidebar_row_expand": "展开", @@ -482,6 +485,7 @@ "shared_active_filters_label": "筛选器:", "shared_active_filters_clear_project": "清除项目筛选器", "shared_active_filters_remove_machine": "移除 {machine} 筛选器", + "shared_active_filters_remove_branch": "移除 {branch} 筛选器", "shared_active_filters_remove_agent": "移除 {agent} 筛选器", "shared_active_filters_clear_min_prompts": "清除最少提示数筛选器", "shared_active_filters_min_prompts": "≥{count} 条提示", @@ -802,6 +806,7 @@ "usage_token_type_output": "输出", "usage_selected_tokens": "已选 Token", "usage_top_sessions_by_selected_tokens": "按{tokenTypes}排名的会话", + "usage_branch": "分支", "usage_refresh": "刷新用量数据", "usage_summary_total_cost": "总成本", "usage_summary_total_tokens": "总 Token", @@ -839,6 +844,7 @@ ], "usage_cost_over_time_title": "成本趋势", "usage_tokens_over_time_title": "Token 趋势", + "usage_unattributed": "未归属", "usage_cost_attribution_title": "成本归因", "usage_tokens_attribution_title": "Token 归因", "usage_attribution_treemap": "矩形树图", @@ -846,6 +852,11 @@ "usage_click_to_hide_hint": "点击可从图表隐藏", "usage_click_to_hide": "点击隐藏 {label}", "usage_hide_from_chart": "从图表隐藏 {label}", + "usage_click_to_filter_hint": "点击可添加或移除筛选", + "usage_click_to_filter": "点击按 {label} 筛选", + "usage_click_to_clear_filter": "点击清除 {label} 筛选", + "usage_filter_to_item": "按 {label} 筛选", + "usage_clear_filter_item": "清除 {label} 筛选", "usage_cache_efficiency_title": "缓存效率", "usage_cache_reads": "缓存读取", "usage_cache_writes": "缓存写入", @@ -1009,6 +1020,8 @@ "activity_all_agents": "全部代理", "activity_filter_by_machine": "按机器筛选", "activity_all_machines": "全部机器", + "activity_filter_by_branch": "按分支筛选", + "activity_all_branches": "全部分支", "activity_filter_by_automation": "按自动化筛选", "activity_all_sessions": "全部会话", "activity_interactive": "交互式", @@ -1017,6 +1030,7 @@ "activity_project": "项目", "activity_model": "模型", "activity_agent": "代理", + "activity_branch": "分支", "activity_min_unit": " 分钟", "activity_int_auto_split": "交互 {int} / 自动 {auto}", "activity_breakdown": "细分", @@ -1955,13 +1969,19 @@ "activity_insight_no_matching_agents": "无匹配代理", "activity_filter_agents_placeholder": "筛选代理", "activity_filter_machines_placeholder": "筛选机器", + "activity_filter_branches_placeholder": "筛选分支", "activity_filter_automation_placeholder": "筛选自动化", "activity_no_matching_agents": "无匹配代理", "activity_no_matching_machines": "无匹配机器", + "activity_no_matching_branches": "无匹配分支", "activity_no_automation_filters": "无自动化筛选", "activity_loading_report": "正在加载活动报告...", "appearance_high_contrast": "高对比度", "appearance_on": "开", "appearance_off": "关", - "appearance_text_size": "文字大小" + "appearance_text_size": "文字大小", + "shared_branch_clear_search": "清除分支搜索", + "shared_branch_loading": "正在加载分支…", + "shared_branch_no_match": "没有匹配的分支", + "shared_branch_refine": "还有更多分支,请缩小搜索范围。" } diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json index 25210370c..2c90ab956 100644 --- a/frontend/messages/zh-TW.json +++ b/frontend/messages/zh-TW.json @@ -464,6 +464,9 @@ "sidebar_filters_machine": "Machine", "sidebar_filters_search_machines": "搜尋 machines...", "sidebar_filters_no_machines": "無 machines", + "sidebar_filters_branch": "分支", + "sidebar_filters_search_branches": "搜索分支...", + "sidebar_filters_no_branches": "無分支", "sidebar_filters_min_prompts": "最少提示數", "sidebar_filters_clear_filters": "清除篩選器", "sidebar_row_expand": "展開", @@ -482,6 +485,7 @@ "shared_active_filters_label": "篩選器:", "shared_active_filters_clear_project": "清除專案篩選器", "shared_active_filters_remove_machine": "移除 {machine} 篩選器", + "shared_active_filters_remove_branch": "移除 {branch} 篩選器", "shared_active_filters_remove_agent": "移除 {agent} 篩選器", "shared_active_filters_clear_min_prompts": "清除最少提示數篩選器", "shared_active_filters_min_prompts": "≥{count} 條提示", @@ -802,6 +806,7 @@ "usage_token_type_output": "輸出", "usage_selected_tokens": "已選 Token", "usage_top_sessions_by_selected_tokens": "依{tokenTypes}排名的工作階段", + "usage_branch": "分支", "usage_refresh": "重整使用量", "usage_summary_total_cost": "總成本", "usage_summary_total_tokens": "總 Token", @@ -839,6 +844,7 @@ ], "usage_cost_over_time_title": "成本趨勢", "usage_tokens_over_time_title": "Token 趨勢", + "usage_unattributed": "未歸屬", "usage_cost_attribution_title": "成本歸因", "usage_tokens_attribution_title": "Token 歸因", "usage_attribution_treemap": "矩形樹圖", @@ -846,6 +852,11 @@ "usage_click_to_hide_hint": "點擊可從圖表隱藏", "usage_click_to_hide": "點擊隱藏 {label}", "usage_hide_from_chart": "從圖表隱藏 {label}", + "usage_click_to_filter_hint": "點擊可新增或移除篩選", + "usage_click_to_filter": "點擊依 {label} 篩選", + "usage_click_to_clear_filter": "點擊清除 {label} 篩選", + "usage_filter_to_item": "依 {label} 篩選", + "usage_clear_filter_item": "清除 {label} 篩選", "usage_cache_efficiency_title": "快取效率", "usage_cache_reads": "快取讀取", "usage_cache_writes": "快取寫入", @@ -1009,6 +1020,8 @@ "activity_all_agents": "全部代理", "activity_filter_by_machine": "按機器篩選", "activity_all_machines": "全部機器", + "activity_filter_by_branch": "按分支篩選", + "activity_all_branches": "全部分支", "activity_filter_by_automation": "按自動化篩選", "activity_all_sessions": "全部對話", "activity_interactive": "交互式", @@ -1017,6 +1030,7 @@ "activity_project": "專案", "activity_model": "模型", "activity_agent": "代理", + "activity_branch": "分支", "activity_min_unit": " 分鐘", "activity_int_auto_split": "交互 {int} / 自動 {auto}", "activity_breakdown": "細分", @@ -1955,13 +1969,19 @@ "activity_insight_no_matching_agents": "無匹配代理", "activity_filter_agents_placeholder": "篩選代理", "activity_filter_machines_placeholder": "篩選機器", + "activity_filter_branches_placeholder": "篩選分支", "activity_filter_automation_placeholder": "篩選自動化", "activity_no_matching_agents": "無匹配代理", "activity_no_matching_machines": "無匹配機器", + "activity_no_matching_branches": "無匹配分支", "activity_no_automation_filters": "無自動化篩選", "activity_loading_report": "正在載入活動報告...", "appearance_high_contrast": "高對比", "appearance_on": "開", "appearance_off": "關", - "appearance_text_size": "文字大小" + "appearance_text_size": "文字大小", + "shared_branch_clear_search": "清除分支搜尋", + "shared_branch_loading": "正在載入分支…", + "shared_branch_no_match": "沒有相符的分支", + "shared_branch_refine": "還有更多分支,請縮小搜尋範圍。" } diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 5574dade2..4b898fcd7 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -5,9 +5,11 @@ import type { GenerateInsightRequest, } from "./types.js"; import type { SessionTiming } from "./types/timing.js"; +import { MetadataService } from "./generated/index.js"; import { ApiError, authHeaders, + configureGeneratedClient, getAuthToken, getBase, isRemoteConnection, @@ -19,6 +21,38 @@ export interface SyncHandle { done: Promise; } +export interface BranchSearchParams { + projects?: string[]; + search?: string; + limit?: number; + includeOneShot?: boolean; + includeAutomated?: boolean; + scope?: "roots" | "all"; +} + +export interface BranchSearchResponse { + branches: Array<{ branch: string }>; + has_more: boolean; +} + +export async function searchBranches( + params: BranchSearchParams, +): Promise { + configureGeneratedClient(); + const response = await MetadataService.getApiV1Branches({ + projects: params.projects, + search: params.search || undefined, + limit: params.limit ?? 100, + includeOneShot: params.includeOneShot, + includeAutomated: params.includeAutomated, + scope: params.scope, + }); + return { + branches: (response.branches ?? []) as Array<{ branch: string }>, + has_more: response.has_more, + }; +} + function streamSyncSSE( path: string, onProgress?: (p: SyncProgress) => void, diff --git a/frontend/src/lib/api/generated/index.ts b/frontend/src/lib/api/generated/index.ts index eb769c7f5..a0dd08fa7 100644 --- a/frontend/src/lib/api/generated/index.ts +++ b/frontend/src/lib/api/generated/index.ts @@ -7,6 +7,7 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; +export type { ActivityBranchKeyMinutes } from './models/ActivityBranchKeyMinutes'; export type { ActivityBucket } from './models/ActivityBucket'; export type { ActivityKeyMinutes } from './models/ActivityKeyMinutes'; export type { ActivityPeak } from './models/ActivityPeak'; @@ -20,7 +21,7 @@ export type { ApiErrorResponse } from './models/ApiErrorResponse'; export type { ApplyWorktreeMappingsRequest } from './models/ApplyWorktreeMappingsRequest'; export type { ApplyWorktreeMappingsResponse } from './models/ApplyWorktreeMappingsResponse'; export type { BatchDeleteInputBody } from './models/BatchDeleteInputBody'; -export type { BranchesResponse } from './models/BranchesResponse'; +export type { BranchTotal } from './models/BranchTotal'; export type { BulkStarInputBody } from './models/BulkStarInputBody'; export type { CacheStats } from './models/CacheStats'; export type { Comparison } from './models/Comparison'; @@ -36,7 +37,9 @@ export type { DbAgentBreakdown } from './models/DbAgentBreakdown'; export type { DbAgentInfo } from './models/DbAgentInfo'; export type { DbAgentSummary } from './models/DbAgentSummary'; export type { DbAnalyticsSummary } from './models/DbAnalyticsSummary'; -export type { DbBranchInfo } from './models/DbBranchInfo'; +export type { DbBranchBreakdown } from './models/DbBranchBreakdown'; +export type { DbBranchOption } from './models/DbBranchOption'; +export type { DbBranchResult } from './models/DbBranchResult'; export type { DbCacheHitRatioDistribution } from './models/DbCacheHitRatioDistribution'; export type { DbCallTiming } from './models/DbCallTiming'; export type { DbCategoryTotal } from './models/DbCategoryTotal'; diff --git a/frontend/src/lib/api/generated/models/ActivityBranchKeyMinutes.ts b/frontend/src/lib/api/generated/models/ActivityBranchKeyMinutes.ts new file mode 100644 index 000000000..7b68ba156 --- /dev/null +++ b/frontend/src/lib/api/generated/models/ActivityBranchKeyMinutes.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { MoneyMoney } from './MoneyMoney'; +export type ActivityBranchKeyMinutes = { + agent_minutes: number; + automated_agent_minutes: number; + automated_cost: MoneyMoney; + branch: string; + cost: MoneyMoney; + interactive_agent_minutes: number; + interactive_cost: MoneyMoney; + project: string; + project_key: string; +}; diff --git a/frontend/src/lib/api/generated/models/ActivityReport.ts b/frontend/src/lib/api/generated/models/ActivityReport.ts index dc3a76878..c767e042b 100644 --- a/frontend/src/lib/api/generated/models/ActivityReport.ts +++ b/frontend/src/lib/api/generated/models/ActivityReport.ts @@ -13,6 +13,7 @@ export type ActivityReport = { bucket_unit: string; buckets: any[] | null; by_agent: any[] | null; + by_branch: any[] | null; by_model: any[] | null; by_project: any[] | null; by_session: any[] | null; diff --git a/frontend/src/lib/api/generated/models/BranchTotal.ts b/frontend/src/lib/api/generated/models/BranchTotal.ts new file mode 100644 index 000000000..bb974a90c --- /dev/null +++ b/frontend/src/lib/api/generated/models/BranchTotal.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { MoneyMoney } from './MoneyMoney'; +export type BranchTotal = { + branch: string; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: MoneyMoney; + inputTokens: number; + outputTokens: number; + project: string; + project_key: string; +}; diff --git a/frontend/src/lib/api/generated/models/DbBranchBreakdown.ts b/frontend/src/lib/api/generated/models/DbBranchBreakdown.ts new file mode 100644 index 000000000..8dee94c94 --- /dev/null +++ b/frontend/src/lib/api/generated/models/DbBranchBreakdown.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { MoneyMoney } from './MoneyMoney'; +export type DbBranchBreakdown = { + branch: string; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: MoneyMoney; + inputTokens: number; + outputTokens: number; + project: string; + project_key: string; +}; diff --git a/frontend/src/lib/api/generated/models/DbBranchInfo.ts b/frontend/src/lib/api/generated/models/DbBranchOption.ts similarity index 70% rename from frontend/src/lib/api/generated/models/DbBranchInfo.ts rename to frontend/src/lib/api/generated/models/DbBranchOption.ts index 0eb5262cb..a96730d84 100644 --- a/frontend/src/lib/api/generated/models/DbBranchInfo.ts +++ b/frontend/src/lib/api/generated/models/DbBranchOption.ts @@ -2,9 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type DbBranchInfo = { +export type DbBranchOption = { branch: string; - project: string; - token: string; }; diff --git a/frontend/src/lib/api/generated/models/BranchesResponse.ts b/frontend/src/lib/api/generated/models/DbBranchResult.ts similarity index 75% rename from frontend/src/lib/api/generated/models/BranchesResponse.ts rename to frontend/src/lib/api/generated/models/DbBranchResult.ts index b3eda7a99..60ff921e1 100644 --- a/frontend/src/lib/api/generated/models/BranchesResponse.ts +++ b/frontend/src/lib/api/generated/models/DbBranchResult.ts @@ -2,7 +2,8 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type BranchesResponse = { +export type DbBranchResult = { branches: any[] | null; + has_more: boolean; }; diff --git a/frontend/src/lib/api/generated/models/DbDailyUsageEntry.ts b/frontend/src/lib/api/generated/models/DbDailyUsageEntry.ts index d5e6ddba9..ce2e238e2 100644 --- a/frontend/src/lib/api/generated/models/DbDailyUsageEntry.ts +++ b/frontend/src/lib/api/generated/models/DbDailyUsageEntry.ts @@ -5,6 +5,7 @@ import type { MoneyMoney } from './MoneyMoney'; export type DbDailyUsageEntry = { agentBreakdowns: any[] | null; + branchBreakdowns: any[] | null; cacheCreationTokens: number; cacheReadTokens: number; date: string; diff --git a/frontend/src/lib/api/generated/models/UsageSummaryResponse.ts b/frontend/src/lib/api/generated/models/UsageSummaryResponse.ts index 5002f3fc8..f40a14f39 100644 --- a/frontend/src/lib/api/generated/models/UsageSummaryResponse.ts +++ b/frontend/src/lib/api/generated/models/UsageSummaryResponse.ts @@ -11,6 +11,7 @@ import type { ExportProjectMapEntry } from './ExportProjectMapEntry'; import type { UnsupportedUsage } from './UnsupportedUsage'; export type UsageSummaryResponse = { agentTotals: any[] | null; + branchTotals: any[] | null; cacheStats: CacheStats; comparison?: Comparison; daily: any[] | null; diff --git a/frontend/src/lib/api/generated/services/ActivityService.ts b/frontend/src/lib/api/generated/services/ActivityService.ts index bf4c2d2b9..5c9ce6c16 100644 --- a/frontend/src/lib/api/generated/services/ActivityService.ts +++ b/frontend/src/lib/api/generated/services/ActivityService.ts @@ -54,7 +54,7 @@ export class ActivityService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** diff --git a/frontend/src/lib/api/generated/services/AnalyticsService.ts b/frontend/src/lib/api/generated/services/AnalyticsService.ts index 1445e4af4..c59b7c104 100644 --- a/frontend/src/lib/api/generated/services/AnalyticsService.ts +++ b/frontend/src/lib/api/generated/services/AnalyticsService.ts @@ -63,7 +63,7 @@ export class AnalyticsService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** @@ -193,7 +193,7 @@ export class AnalyticsService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** @@ -322,7 +322,7 @@ export class AnalyticsService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** @@ -446,7 +446,7 @@ export class AnalyticsService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** @@ -570,7 +570,7 @@ export class AnalyticsService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** @@ -700,7 +700,7 @@ export class AnalyticsService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** @@ -830,7 +830,7 @@ export class AnalyticsService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** @@ -955,7 +955,7 @@ export class AnalyticsService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** @@ -1084,7 +1084,7 @@ export class AnalyticsService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** @@ -1208,7 +1208,7 @@ export class AnalyticsService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** @@ -1333,7 +1333,7 @@ export class AnalyticsService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** @@ -1462,7 +1462,7 @@ export class AnalyticsService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** diff --git a/frontend/src/lib/api/generated/services/MetadataService.ts b/frontend/src/lib/api/generated/services/MetadataService.ts index 63049f690..bb5f16e60 100644 --- a/frontend/src/lib/api/generated/services/MetadataService.ts +++ b/frontend/src/lib/api/generated/services/MetadataService.ts @@ -3,7 +3,7 @@ /* tslint:disable */ /* eslint-disable */ import type { AgentsResponse } from '../models/AgentsResponse'; -import type { BranchesResponse } from '../models/BranchesResponse'; +import type { DbBranchResult } from '../models/DbBranchResult'; import type { DbSessionStats } from '../models/DbSessionStats'; import type { DbStats } from '../models/DbStats'; import type { MachinesResponse } from '../models/MachinesResponse'; @@ -56,12 +56,16 @@ export class MetadataService { } /** * List branches - * @returns BranchesResponse OK + * @returns DbBranchResult OK * @throws ApiError */ public static getApiV1Branches({ includeOneShot, includeAutomated, + scope, + projects, + search, + limit = 100, }: { /** * Include one-shot sessions @@ -71,13 +75,33 @@ export class MetadataService { * Include automated sessions */ includeAutomated?: boolean, - }): CancelablePromise { + /** + * Session scope: roots (default) counts only root sessions; all also counts subagent and fork sessions, matching the activity and usage rollups + */ + scope?: 'roots' | 'all', + /** + * Restrict to these projects before deduplicating branch names + */ + projects?: any[] | null, + /** + * Case-insensitive branch name substring + */ + search?: string, + /** + * Maximum number of branch names + */ + limit?: number, + }): CancelablePromise { return __request(OpenAPI, { method: 'GET', url: '/api/v1/branches', query: { 'include_one_shot': includeOneShot, 'include_automated': includeAutomated, + 'scope': scope, + 'projects': projects, + 'search': search, + 'limit': limit, }, errors: { 400: `Bad Request`, diff --git a/frontend/src/lib/api/generated/services/SearchService.ts b/frontend/src/lib/api/generated/services/SearchService.ts index a289a45f9..8bc0e393a 100644 --- a/frontend/src/lib/api/generated/services/SearchService.ts +++ b/frontend/src/lib/api/generated/services/SearchService.ts @@ -137,7 +137,7 @@ export class SearchService { */ machine?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** diff --git a/frontend/src/lib/api/generated/services/SessionsService.ts b/frontend/src/lib/api/generated/services/SessionsService.ts index 22fcfee40..5f47e58a7 100644 --- a/frontend/src/lib/api/generated/services/SessionsService.ts +++ b/frontend/src/lib/api/generated/services/SessionsService.ts @@ -137,7 +137,7 @@ export class SessionsService { */ machine?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** @@ -351,7 +351,7 @@ export class SessionsService { */ machine?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** diff --git a/frontend/src/lib/api/generated/services/TrendsService.ts b/frontend/src/lib/api/generated/services/TrendsService.ts index 9fcbd062e..7011fb844 100644 --- a/frontend/src/lib/api/generated/services/TrendsService.ts +++ b/frontend/src/lib/api/generated/services/TrendsService.ts @@ -53,7 +53,7 @@ export class TrendsService { */ project?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, /** diff --git a/frontend/src/lib/api/generated/services/UsageService.ts b/frontend/src/lib/api/generated/services/UsageService.ts index fb88c5d31..3d3cb34f8 100644 --- a/frontend/src/lib/api/generated/services/UsageService.ts +++ b/frontend/src/lib/api/generated/services/UsageService.ts @@ -23,6 +23,7 @@ export class UsageService { project, machine, gitBranch, + excludeGitBranch, excludeProject, excludeProjectKey, excludeAgent, @@ -35,6 +36,7 @@ export class UsageService { includeAutomated, noDefaultRange, breakdowns = true, + branchBreakdowns = false, sessionCounts = true, }: { /** @@ -66,9 +68,13 @@ export class UsageService { */ machine?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, + /** + * Exclude branch names; multiple names use the branch list separator, and legacy project-qualified values remain accepted + */ + excludeGitBranch?: string, /** * Exclude a project */ @@ -117,6 +123,10 @@ export class UsageService { * Include per-model, per-project, and per-agent breakdowns */ breakdowns?: boolean, + /** + * Include per-project branch breakdowns + */ + branchBreakdowns?: boolean, /** * Include distinct session counts */ @@ -133,6 +143,7 @@ export class UsageService { 'project': project, 'machine': machine, 'git_branch': gitBranch, + 'exclude_git_branch': excludeGitBranch, 'exclude_project': excludeProject, 'exclude_project_key': excludeProjectKey, 'exclude_agent': excludeAgent, @@ -145,6 +156,7 @@ export class UsageService { 'include_automated': includeAutomated, 'no_default_range': noDefaultRange, 'breakdowns': breakdowns, + 'branch_breakdowns': branchBreakdowns, 'session_counts': sessionCounts, 'current_microdollars': currentMicrodollars, }, @@ -180,6 +192,7 @@ export class UsageService { project, machine, gitBranch, + excludeGitBranch, excludeProject, excludeProjectKey, excludeAgent, @@ -192,6 +205,7 @@ export class UsageService { includeAutomated, noDefaultRange, breakdowns = true, + branchBreakdowns = false, sessionCounts = true, }: { /** @@ -235,9 +249,13 @@ export class UsageService { */ machine?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, + /** + * Exclude branch names; multiple names use the branch list separator, and legacy project-qualified values remain accepted + */ + excludeGitBranch?: string, /** * Exclude a project */ @@ -286,6 +304,10 @@ export class UsageService { * Include per-model, per-project, and per-agent breakdowns */ breakdowns?: boolean, + /** + * Include per-project branch breakdowns + */ + branchBreakdowns?: boolean, /** * Include distinct session counts */ @@ -302,6 +324,7 @@ export class UsageService { 'project': project, 'machine': machine, 'git_branch': gitBranch, + 'exclude_git_branch': excludeGitBranch, 'exclude_project': excludeProject, 'exclude_project_key': excludeProjectKey, 'exclude_agent': excludeAgent, @@ -314,6 +337,7 @@ export class UsageService { 'include_automated': includeAutomated, 'no_default_range': noDefaultRange, 'breakdowns': breakdowns, + 'branch_breakdowns': branchBreakdowns, 'session_counts': sessionCounts, 'left_dimension': leftDimension, 'left_value': leftValue, @@ -348,6 +372,7 @@ export class UsageService { project, machine, gitBranch, + excludeGitBranch, excludeProject, excludeProjectKey, excludeAgent, @@ -360,6 +385,7 @@ export class UsageService { includeAutomated, noDefaultRange, breakdowns = true, + branchBreakdowns = false, sessionCounts = true, }: { /** @@ -387,9 +413,13 @@ export class UsageService { */ machine?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, + /** + * Exclude branch names; multiple names use the branch list separator, and legacy project-qualified values remain accepted + */ + excludeGitBranch?: string, /** * Exclude a project */ @@ -438,6 +468,10 @@ export class UsageService { * Include per-model, per-project, and per-agent breakdowns */ breakdowns?: boolean, + /** + * Include per-project branch breakdowns + */ + branchBreakdowns?: boolean, /** * Include distinct session counts */ @@ -454,6 +488,7 @@ export class UsageService { 'project': project, 'machine': machine, 'git_branch': gitBranch, + 'exclude_git_branch': excludeGitBranch, 'exclude_project': excludeProject, 'exclude_project_key': excludeProjectKey, 'exclude_agent': excludeAgent, @@ -466,6 +501,7 @@ export class UsageService { 'include_automated': includeAutomated, 'no_default_range': noDefaultRange, 'breakdowns': breakdowns, + 'branch_breakdowns': branchBreakdowns, 'session_counts': sessionCounts, }, errors: { @@ -496,6 +532,7 @@ export class UsageService { project, machine, gitBranch, + excludeGitBranch, excludeProject, excludeProjectKey, excludeAgent, @@ -508,6 +545,7 @@ export class UsageService { includeAutomated, noDefaultRange, breakdowns = true, + branchBreakdowns = false, sessionCounts = true, limit = 20, sort = 'cost', @@ -538,9 +576,13 @@ export class UsageService { */ machine?: string, /** - * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + * Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted */ gitBranch?: string, + /** + * Exclude branch names; multiple names use the branch list separator, and legacy project-qualified values remain accepted + */ + excludeGitBranch?: string, /** * Exclude a project */ @@ -589,6 +631,10 @@ export class UsageService { * Include per-model, per-project, and per-agent breakdowns */ breakdowns?: boolean, + /** + * Include per-project branch breakdowns + */ + branchBreakdowns?: boolean, /** * Include distinct session counts */ @@ -617,6 +663,7 @@ export class UsageService { 'project': project, 'machine': machine, 'git_branch': gitBranch, + 'exclude_git_branch': excludeGitBranch, 'exclude_project': excludeProject, 'exclude_project_key': excludeProjectKey, 'exclude_agent': excludeAgent, @@ -629,6 +676,7 @@ export class UsageService { 'include_automated': includeAutomated, 'no_default_range': noDefaultRange, 'breakdowns': breakdowns, + 'branch_breakdowns': branchBreakdowns, 'session_counts': sessionCounts, 'limit': limit, 'sort': sort, diff --git a/frontend/src/lib/api/types/activity.ts b/frontend/src/lib/api/types/activity.ts index 94e34597e..19e9927c5 100644 --- a/frontend/src/lib/api/types/activity.ts +++ b/frontend/src/lib/api/types/activity.ts @@ -4,10 +4,38 @@ import type { ActivityReportInterval, ActivitySessionRow, ActivityKeyMinutes, + ActivityBranchKeyMinutes, } from "../generated/index"; -export type Report = ActivityReport; export type Bucket = ActivityBucket; export type ReportInterval = ActivityReportInterval; -export type SessionRow = ActivitySessionRow; +// models comes across as any[]; the codegen can't type OpenAPI 3.1's +// nullable-array items (see the Report comment below). +export type SessionRow = Omit & { + models: string[] | null; +}; export type KeyMinutes = ActivityKeyMinutes; +export type BranchKeyMinutes = ActivityBranchKeyMinutes; + +// Narrows the generated model's any[] | null collections to their element +// types; openapi-typescript-codegen can't type OpenAPI 3.1's nullable-array +// syntax (type: [T, "null"]) and falls back to any[]. Structurally +// compatible with ActivityReport, so responses need no runtime conversion. +export type Report = Omit< + ActivityReport, + | "buckets" + | "by_agent" + | "by_branch" + | "by_model" + | "by_project" + | "by_session" + | "intervals" +> & { + buckets: Bucket[] | null; + by_agent: KeyMinutes[] | null; + by_branch: BranchKeyMinutes[] | null; + by_model: KeyMinutes[] | null; + by_project: KeyMinutes[] | null; + by_session: SessionRow[] | null; + intervals: ReportInterval[] | null; +}; diff --git a/frontend/src/lib/api/types/core.ts b/frontend/src/lib/api/types/core.ts index 13c594741..f276b5ec0 100644 --- a/frontend/src/lib/api/types/core.ts +++ b/frontend/src/lib/api/types/core.ts @@ -212,17 +212,6 @@ export interface MachinesResponse { machines: string[]; } -/** Matches Go BranchInfo struct in internal/db/sessions.go */ -export interface BranchInfo { - project: string; - branch: string; - token: string; -} - -export interface BranchesResponse { - branches: BranchInfo[]; -} - /** Matches Go AgentInfo struct */ export interface AgentInfo { name: string; diff --git a/frontend/src/lib/api/types/usage.ts b/frontend/src/lib/api/types/usage.ts index 7909f371e..e6c7e6046 100644 --- a/frontend/src/lib/api/types/usage.ts +++ b/frontend/src/lib/api/types/usage.ts @@ -48,6 +48,17 @@ export interface MachineBreakdown { cost: Money; } +export interface BranchBreakdown { + project_key: string; + project: string; + branch: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: Money; +} + export interface DailyUsageEntry { date: string; inputTokens: number; @@ -60,6 +71,7 @@ export interface DailyUsageEntry { projectBreakdowns?: ProjectBreakdown[]; agentBreakdowns?: AgentBreakdown[]; machineBreakdowns?: MachineBreakdown[]; + branchBreakdowns?: BranchBreakdown[]; } export interface ProjectTotal { @@ -90,6 +102,17 @@ export interface AgentTotal { cost: Money; } +export interface BranchTotal { + project_key: string; + project: string; + branch: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: Money; +} + export interface CacheStats { cacheReadTokens: number; cacheCreationTokens: number; @@ -165,6 +188,7 @@ export interface UsageSummaryResponse { projectTotals: ProjectTotal[]; modelTotals: ModelTotal[]; agentTotals: AgentTotal[]; + branchTotals: BranchTotal[]; sessionCounts: UsageSessionCounts; cacheStats: CacheStats; unsupportedUsage?: UnsupportedUsage; diff --git a/frontend/src/lib/branchFilters.test.ts b/frontend/src/lib/branchFilters.test.ts index 89443becb..399e6acb6 100644 --- a/frontend/src/lib/branchFilters.test.ts +++ b/frontend/src/lib/branchFilters.test.ts @@ -1,10 +1,100 @@ import { describe, expect, it } from "vitest"; import { + NO_BRANCH_FILTER_TOKEN, + NO_BRANCH_MATCH_TOKEN, + branchFilterValuesEqual, branchFilterToken, branchLabel, branchTokenLabel, + branchPickerValues, + intersectBranchFilterValues, + reconcileBranchFilterValues, + scopeBranchFilterValues, } from "./branchFilters.js"; +describe("branch filter compatibility", () => { + it("compares qualified identities before falling back to branch names", () => { + expect(branchFilterValuesEqual( + branchFilterToken("proj-a", "main"), + branchFilterToken("proj-b", "main"), + )).toBe(false); + expect(branchFilterValuesEqual( + "main", + branchFilterToken("proj-a", "main"), + )).toBe(true); + }); + + it("normalizes legacy picker values and preserves no-branch selections", () => { + expect(branchPickerValues([ + branchFilterToken("proj-a", "main"), + branchFilterToken("proj-b", "main"), + branchFilterToken("proj-a", ""), + ])).toEqual(["main", NO_BRANCH_FILTER_TOKEN]); + }); + + it("intersects plain branch names with legacy project-pair tokens", () => { + expect(intersectBranchFilterValues( + ["main", "dev"], + [branchFilterToken("proj-a", "main"), "feature"], + )).toEqual([branchFilterToken("proj-a", "main")]); + }); + + it("does not intersect same-named legacy branches from different projects", () => { + expect(intersectBranchFilterValues( + [branchFilterToken("proj-a", "main")], + [branchFilterToken("proj-b", "main")], + )).toEqual([]); + }); + + it("preserves qualified identity regardless of intersection order", () => { + const qualified = branchFilterToken("proj-a", "main"); + expect(intersectBranchFilterValues([qualified], ["main"])).toEqual([ + qualified, + ]); + expect(intersectBranchFilterValues(["main"], [qualified])).toEqual([ + qualified, + ]); + }); + + it("preserves existing legacy values while adding plain picker values", () => { + expect(reconcileBranchFilterValues( + [ + branchFilterToken("proj-a", "main"), + branchFilterToken("proj-b", "dev"), + ], + ["main", "feature"], + )).toEqual([branchFilterToken("proj-a", "main"), "feature"]); + }); + + it("uses sentinel values that cannot collide with legal Git branch names", () => { + expect(NO_BRANCH_FILTER_TOKEN).toMatch(/[\u0000-\u001f]/); + expect(NO_BRANCH_MATCH_TOKEN).toMatch(/[\u0000-\u001f]/); + expect(branchPickerValues([ + "__agentsview_no_branch__", + "__agentsview_no_branch_match__", + ])).toEqual([ + "__agentsview_no_branch__", + "__agentsview_no_branch_match__", + ]); + }); + + it("keeps plain names when scoping and drops conflicting legacy pairs", () => { + expect(scopeBranchFilterValues([ + "main", + branchFilterToken("proj-a", "dev"), + branchFilterToken("proj-b", "feature"), + ], "proj-a")).toEqual(["main", branchFilterToken("proj-a", "dev")]); + }); + + it("preserves opaque project identities for server-side scoping", () => { + const opaque = branchFilterToken("pl1:sha256:alpha", "main"); + expect(scopeBranchFilterValues([ + opaque, + branchFilterToken("beta", "dev"), + ], "alpha")).toEqual([opaque]); + }); +}); + describe("branch filter labels", () => { it("keeps empty branch labels distinct from a real unknown branch", () => { const noBranch = "(no branch)"; diff --git a/frontend/src/lib/branchFilters.ts b/frontend/src/lib/branchFilters.ts index 2e8c26cfc..1e36b07f5 100644 --- a/frontend/src/lib/branchFilters.ts +++ b/frontend/src/lib/branchFilters.ts @@ -1,6 +1,9 @@ // Keep these in sync with internal/db branch token separators. export const BRANCH_TOKEN_SEP = "\u001f"; export const BRANCH_LIST_SEP = "\u001e"; +export const NO_BRANCH_FILTER_TOKEN = "\u001dno_branch"; +export const NO_BRANCH_MATCH_TOKEN = "\u001dno_branch_match"; +const OPAQUE_PROJECT_KEY_PREFIX = "pl1:sha256:"; export function branchFilterToken(project: string, branch: string): string { return project + BRANCH_TOKEN_SEP + branch; @@ -29,6 +32,86 @@ export function branchTokenLabel( token: string, noBranchLabel: string, ): string { + if (token === NO_BRANCH_FILTER_TOKEN) return noBranchLabel; const { project, branch } = splitBranchFilterToken(token); return branchLabel(project, branch, noBranchLabel); } + +export function branchFilterValue(value: string): string { + const name = branchName(value); + return name === "" ? NO_BRANCH_FILTER_TOKEN : name; +} + +function branchName(value: string): string { + if (value === NO_BRANCH_FILTER_TOKEN) return ""; + return splitBranchFilterToken(value).branch; +} + +export function branchFilterValuesEqual( + left: string, + right: string, +): boolean { + const leftToken = splitBranchFilterToken(left); + const rightToken = splitBranchFilterToken(right); + if (branchName(left) !== branchName(right)) return false; + return !leftToken.project || + !rightToken.project || + leftToken.project === rightToken.project; +} + +export function branchPickerValues(values: string[]): string[] { + return [...new Set(values.map(branchFilterValue))]; +} + +export function reconcileBranchFilterValues( + current: string[], + pickerValues: string[], +): string[] { + const remaining = new Set(pickerValues.map(branchFilterValue)); + const next: string[] = []; + for (const value of current) { + const name = branchFilterValue(value); + if (!remaining.delete(name)) continue; + next.push(value); + } + for (const value of pickerValues) { + const name = branchFilterValue(value); + if (!remaining.delete(name)) continue; + next.push(name); + } + return next; +} + +export function scopeBranchFilterValues( + values: string[], + project: string, +): string[] { + if (!project) return values; + return values.filter((value) => { + const decoded = splitBranchFilterToken(value); + return !decoded.project || + decoded.project === project || + decoded.project.startsWith(OPAQUE_PROJECT_KEY_PREFIX); + }); +} + +export function intersectBranchFilterValues( + left: string[], + right: string[], +): string[] { + const result: string[] = []; + const seen = new Set(); + for (const leftValue of left) { + const leftToken = splitBranchFilterToken(leftValue); + for (const rightValue of right) { + if (!branchFilterValuesEqual(leftValue, rightValue)) continue; + const value = leftToken.project ? leftValue : rightValue; + if (!seen.has(value)) { + seen.add(value); + result.push(value); + } + break; + } + } + return result; +} diff --git a/frontend/src/lib/components/activity/ActivityPage.svelte b/frontend/src/lib/components/activity/ActivityPage.svelte index e5b94285b..3ca880be7 100644 --- a/frontend/src/lib/components/activity/ActivityPage.svelte +++ b/frontend/src/lib/components/activity/ActivityPage.svelte @@ -17,6 +17,12 @@ import ProjectTypeahead from "../layout/ProjectTypeahead.svelte"; import { Card, Typeahead, type TypeaheadOption } from "@kenn-io/kit-ui"; import RefreshControl from "../shared/RefreshControl.svelte"; + import BranchPicker from "../shared/BranchPicker.svelte"; + import { searchBranches } from "../../api/client.js"; + import { + branchPickerValues, + reconcileBranchFilterValues, + } from "../../branchFilters.js"; import { addDays, endOfMonth, @@ -112,6 +118,23 @@ displayLabel: machine, })), ]); + const branchProjects = $derived(activity.project ? [activity.project] : []); + const selectedBranchNames = $derived( + branchPickerValues(activity.branch ? [activity.branch] : []), + ); + + function searchActivityBranches(params: { + projects?: string[]; + search: string; + limit: number; + }) { + return searchBranches({ + ...params, + includeOneShot: true, + includeAutomated: true, + scope: "all", + }); + } const automationOptions: TypeaheadOption[] = $derived([ { name: "all", @@ -268,6 +291,14 @@ activity.load(); } + function onBranchChange(values: string[]) { + activity.setBranch(reconcileBranchFilterValues( + activity.branch ? [activity.branch] : [], + values, + )[0] ?? ""); + activity.load(); + } + function onAutomationChange(value: string) { activity.setAutomation(value as Automation); activity.load(); @@ -349,6 +380,24 @@ /> +
+ +
+
{ component = mount(ActivityPage, { target: document.body }); await flushEffects(); - expect( - document.body.querySelector("button[aria-label^=\"Reclassify\"]"), - ).toBeNull(); + expect(document.body.querySelector('button[aria-label^="Reclassify"]')).toBeNull(); const link = document.body.querySelector("a.bar-label") as HTMLAnchorElement; expect(link).toBeTruthy(); - expect(link.getAttribute("href")).toBe( - "/data?project_key=pl1%3Asha256%3Awrong", - ); + expect(link.getAttribute("href")).toBe("/data?project_key=pl1%3Asha256%3Awrong"); expect(link.getAttribute("title")).toBe("View wrong-project in Data"); }); }); +describe("ActivityPage branch filter", () => { + it("wires the shared single-select branch picker through the activity store", () => { + expect(source).toContain(" { + expect(source).toContain("activity.project ? [activity.project] : []"); + expect(source).toContain("projects={branchProjects}"); + }); +}); + describe("ActivityPage date yoke controls", () => { it("updates shared yoke state from the unified range picker", () => { expect(source).toContain(" import { m } from "../../i18n/index.js"; import { router } from "../../stores/router.svelte.js"; - import type { Report } from "../../api/types.js"; - import type { ActivityKeyMinutes } from "../../api/generated/index"; + import type { KeyMinutes, Report } from "../../api/types.js"; import { formatMoney, moneyFromMicrodollars } from "../../money.js"; + import { branchFilterToken, branchLabel } from "../../branchFilters.js"; interface Props { report: Report; @@ -14,25 +14,21 @@ type Metric = "minutes" | "cost"; let metric = $state("minutes"); - // by_* fields are typed `any[] | null` by the codegen; cast each - // to the generated element model for field-level type safety. - function asKeyMinutes(arr: any[] | null): ActivityKeyMinutes[] { - return (arr ?? []) as ActivityKeyMinutes[]; - } + type BreakdownRow = KeyMinutes & { displayLabel?: string }; - function rowValue(row: ActivityKeyMinutes): number { + function rowValue(row: BreakdownRow): number { return metric === "cost" ? row.cost.microdollars : row.agent_minutes; } // Per-row automation split for the active metric. Interactive + automated // sum to rowValue, so the two bar segments stack to the full bar width. - function interactiveValue(row: ActivityKeyMinutes): number { + function interactiveValue(row: BreakdownRow): number { return metric === "cost" ? row.interactive_cost.microdollars : row.interactive_agent_minutes; } - function automatedValue(row: ActivityKeyMinutes): number { + function automatedValue(row: BreakdownRow): number { return metric === "cost" ? row.automated_cost.microdollars : row.automated_agent_minutes; } @@ -40,8 +36,8 @@ // cost-only row contributes nothing to the minutes view (and would otherwise // render as an empty "0" bar), and a zero-cost row drops from the cost view. // The backend pre-sorts by minutes, so re-sort for the cost view. - function rankedRows(arr: any[] | null): ActivityKeyMinutes[] { - return asKeyMinutes(arr) + function rankedRows(arr: BreakdownRow[] | null): BreakdownRow[] { + return (arr ?? []) .filter((r) => rowValue(r) > 0) .sort((a, b) => rowValue(b) - rowValue(a)); } @@ -49,39 +45,57 @@ const byProject = $derived(rankedRows(report.by_project)); const byModel = $derived(rankedRows(report.by_model)); const byAgent = $derived(rankedRows(report.by_agent)); + // Tokenizing branch rows depends only on the report, not the metric, so + // keep it out of the rankedRows derived: flipping minutes/cost re-ranks + // without re-allocating every row of an uncapped (project, branch) list. + const branchRows = $derived( + (report.by_branch ?? []).map((b) => ({ + ...b, + key: branchFilterToken(b.project_key, b.branch), + displayLabel: branchLabel(b.project, b.branch, m.shared_no_branch()), + })), + ); + const byBranch = $derived(rankedRows(branchRows)); interface Panel { title: string; - rows: ActivityKeyMinutes[]; - projectRows: boolean; + rows: BreakdownRow[]; + projectRows: boolean; + label?: (row: BreakdownRow) => string; } const panels = $derived.by((): Panel[] => [ { title: m.activity_project(), rows: byProject, projectRows: true }, { title: m.activity_model(), rows: byModel, projectRows: false }, { title: m.activity_agent(), rows: byAgent, projectRows: false }, + { + title: m.activity_branch(), + rows: byBranch, + projectRows: false, + label: (row) => row.displayLabel ?? row.key, + }, ]); - function rowIdentity(row: ActivityKeyMinutes, projectRows: boolean): string { - return projectRows ? (row.project_key || row.key) : row.key; + function rowIdentity(row: BreakdownRow, projectRows: boolean): string { + return projectRows ? (row.project_key || row.key) : row.key; } - function projectKeyOf(row: ActivityKeyMinutes): string { + function projectKeyOf(row: BreakdownRow): string { return row.project_key || row.key; } - function projectHref(row: ActivityKeyMinutes): string { + function projectHref(row: BreakdownRow): string { return router.buildHref("data", { project_key: projectKeyOf(row) }); } - function openInData(event: MouseEvent, row: ActivityKeyMinutes) { + function openInData(event: MouseEvent, row: BreakdownRow) { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return; event.preventDefault(); router.navigate("data", { project_key: projectKeyOf(row) }); } - function maxValue(rows: ActivityKeyMinutes[]): number { + function maxValue(rows: BreakdownRow[]): number { if (rows.length === 0) return 1; const m = Math.max(...rows.map(rowValue)); // Fall back to 1 only when the max is non-positive, so the largest bar @@ -97,7 +111,7 @@ return Math.round(v).toLocaleString(); } - function fmtValue(row: ActivityKeyMinutes): string { + function fmtValue(row: KeyMinutes): string { return metric === "cost" ? formatMoney(row.cost) : fmtMinutes(row.agent_minutes); } @@ -112,13 +126,18 @@ let tooltip = $state<{ x: number; y: number; text: string } | null>(null); - function sumValue(rows: ActivityKeyMinutes[]): number { + function sumValue(rows: KeyMinutes[]): number { let total = 0; for (const r of rows) total += rowValue(r); return total; } - function showTip(e: MouseEvent, row: ActivityKeyMinutes, total: number) { + function showTip( + e: MouseEvent, + row: KeyMinutes, + total: number, + label: string, + ) { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const pct = total > 0 ? Math.round((rowValue(row) / total) * 100) : 0; const unit = metric === "cost" ? "" : m.activity_min_unit(); @@ -126,7 +145,7 @@ tooltip = { x: rect.left + rect.width / 2, y: rect.top - 4, - text: `${row.key} · ${fmtValue(row)}${unit} · ${pct}% · ${split}`, + text: `${label} · ${fmtValue(row)}${unit} · ${pct}% · ${split}`, }; } @@ -179,10 +198,11 @@ {#if panel.rows.length > 0}
{#each panel.rows as row (rowIdentity(row, panel.projectRows))} + {@const label = panel.label?.(row) ?? row.key}
showTip(e, row, total)} + onmouseenter={(e) => showTip(e, row, total, label)} onmouseleave={hideTip} > {#if panel.projectRows} @@ -195,8 +215,8 @@ {truncate(row.key, 22)} {:else} - - {truncate(row.key, 22)} + + {truncate(label, 22)} {/if}
diff --git a/frontend/src/lib/components/activity/Breakdowns.test.ts b/frontend/src/lib/components/activity/Breakdowns.test.ts index cc5177af7..da2b2a585 100644 --- a/frontend/src/lib/components/activity/Breakdowns.test.ts +++ b/frontend/src/lib/components/activity/Breakdowns.test.ts @@ -1,21 +1,31 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { mount, tick, unmount } from "svelte"; import Breakdowns from "./Breakdowns.svelte"; import { router } from "../../stores/router.svelte.js"; import type { Report } from "../../api/types.js"; import { testMoney } from "../../test/money.js"; +import { BRANCH_TOKEN_SEP } from "../../branchFilters.js"; function makeReport(): Report { return { peak: { agents: 0, at: null }, totals: { - active_minutes: 0, idle_minutes: 0, agent_minutes: 0, sessions: 0, - untimed_sessions: 0, distinct_projects: 0, distinct_models: 0, - output_tokens: 0, cost: testMoney(0), - automated_agent_minutes: 0, interactive_agent_minutes: 0, - automated_cost: testMoney(0), interactive_cost: testMoney(0), - automated_sessions: 0, interactive_sessions: 0, + active_minutes: 0, + idle_minutes: 0, + agent_minutes: 0, + sessions: 0, + untimed_sessions: 0, + distinct_projects: 0, + distinct_models: 0, + output_tokens: 0, + cost: testMoney(0), + automated_agent_minutes: 0, + interactive_agent_minutes: 0, + automated_cost: testMoney(0), + interactive_cost: testMoney(0), + automated_sessions: 0, + interactive_sessions: 0, }, partial: false, as_of: null, @@ -30,18 +40,29 @@ function makeReport(): Report { buckets: [], by_project: [ { - key: "alpha", project_key: "pl1:sha256:alpha", agent_minutes: 30, cost: testMoney(0), - interactive_agent_minutes: 20, automated_agent_minutes: 10, - interactive_cost: testMoney(0), automated_cost: testMoney(0), + key: "alpha", + project_key: "pl1:sha256:alpha", + agent_minutes: 30, + cost: testMoney(0), + interactive_agent_minutes: 20, + automated_agent_minutes: 10, + interactive_cost: testMoney(0), + automated_cost: testMoney(0), }, { - key: "beta", project_key: "pl1:sha256:beta", agent_minutes: 10, cost: testMoney(0), - interactive_agent_minutes: 10, automated_agent_minutes: 0, - interactive_cost: testMoney(0), automated_cost: testMoney(0), + key: "beta", + project_key: "pl1:sha256:beta", + agent_minutes: 10, + cost: testMoney(0), + interactive_agent_minutes: 10, + automated_agent_minutes: 0, + interactive_cost: testMoney(0), + automated_cost: testMoney(0), }, ], by_model: [], by_agent: [], + by_branch: [], by_session: [], intervals: [], projects: {}, @@ -103,14 +124,22 @@ describe("Breakdowns", () => { // usage; they must not render as empty "0" bars in the minutes view. report.by_project = [ { - key: "timed", agent_minutes: 30, cost: testMoney(1), - interactive_agent_minutes: 30, automated_agent_minutes: 0, - interactive_cost: testMoney(1), automated_cost: testMoney(0), + key: "timed", + agent_minutes: 30, + cost: testMoney(1), + interactive_agent_minutes: 30, + automated_agent_minutes: 0, + interactive_cost: testMoney(1), + automated_cost: testMoney(0), }, { - key: "costonly", agent_minutes: 0, cost: testMoney(5), - interactive_agent_minutes: 0, automated_agent_minutes: 0, - interactive_cost: testMoney(5), automated_cost: testMoney(0), + key: "costonly", + agent_minutes: 0, + cost: testMoney(5), + interactive_agent_minutes: 0, + automated_agent_minutes: 0, + interactive_cost: testMoney(5), + automated_cost: testMoney(0), }, ] as Report["by_project"]; const target = document.createElement("div"); @@ -130,14 +159,22 @@ describe("Breakdowns", () => { const report = makeReport(); report.by_project = [ { - key: "timed", agent_minutes: 30, cost: testMoney(1), - interactive_agent_minutes: 30, automated_agent_minutes: 0, - interactive_cost: testMoney(1), automated_cost: testMoney(0), + key: "timed", + agent_minutes: 30, + cost: testMoney(1), + interactive_agent_minutes: 30, + automated_agent_minutes: 0, + interactive_cost: testMoney(1), + automated_cost: testMoney(0), }, { - key: "costonly", agent_minutes: 0, cost: testMoney(5), - interactive_agent_minutes: 0, automated_agent_minutes: 0, - interactive_cost: testMoney(5), automated_cost: testMoney(0), + key: "costonly", + agent_minutes: 0, + cost: testMoney(5), + interactive_agent_minutes: 0, + automated_agent_minutes: 0, + interactive_cost: testMoney(5), + automated_cost: testMoney(0), }, ] as Report["by_project"]; const target = document.createElement("div"); @@ -182,8 +219,8 @@ describe("Breakdowns", () => { it("links project rows to Data and leaves other panels plain", async () => { const report = makeReport(); - report.by_model = [{ ...report.by_project![0], key: "model-a" }]; - report.by_agent = [{ ...report.by_project![0], key: "agent-a" }]; + report.by_model = [{ ...report.by_project![0]!, key: "model-a" }]; + report.by_agent = [{ ...report.by_project![0]!, key: "agent-a" }]; const target = document.createElement("div"); document.body.appendChild(target); const component = mount(Breakdowns, { target, props: { report } }); @@ -191,9 +228,7 @@ describe("Breakdowns", () => { const links = target.querySelectorAll("a.bar-label"); expect(links).toHaveLength(2); - expect(links[0]!.getAttribute("href")).toBe( - "/data?project_key=pl1%3Asha256%3Aalpha", - ); + expect(links[0]!.getAttribute("href")).toBe("/data?project_key=pl1%3Asha256%3Aalpha"); expect(links[0]!.getAttribute("title")).toBe("View alpha in Data"); // Model/agent panels render plain spans, and no action buttons remain. expect(target.querySelectorAll("span.bar-label")).toHaveLength(2); @@ -212,9 +247,7 @@ describe("Breakdowns", () => { try { const link = target.querySelector("a.bar-label") as HTMLAnchorElement; - expect(link.getAttribute("href")).toBe( - "/data?desktop=&project_key=pl1%3Asha256%3Aalpha", - ); + expect(link.getAttribute("href")).toBe("/data?desktop=&project_key=pl1%3Asha256%3Aalpha"); } finally { unmount(component); window.history.replaceState(null, "", "/"); @@ -281,4 +314,84 @@ describe("Breakdowns", () => { unmount(component); navigate.mockRestore(); }); + + it("renders branch rows as project/branch labels, never raw tokens", async () => { + const report = makeReport(); + report.by_branch = [ + { + project_key: "pl1:sha256:alpha", + project: "alpha", + branch: "main", + agent_minutes: 12, + cost: testMoney(0), + interactive_agent_minutes: 12, + automated_agent_minutes: 0, + interactive_cost: testMoney(0), + automated_cost: testMoney(0), + }, + { + project_key: "pl1:sha256:alpha", + project: "alpha", + branch: "", + agent_minutes: 3, + cost: testMoney(0), + interactive_agent_minutes: 3, + automated_agent_minutes: 0, + interactive_cost: testMoney(0), + automated_cost: testMoney(0), + }, + ]; + const target = document.createElement("div"); + document.body.appendChild(target); + const c = mount(Breakdowns, { target, props: { report } }); + await tick(); + const labels = [...target.querySelectorAll(".bar-label")].map( + (el) => el.textContent?.trim() ?? "", + ); + expect(labels).toContain("alpha/main"); + expect(labels).toContain("alpha/(no branch)"); + expect(labels.some((l) => l.includes(BRANCH_TOKEN_SEP))).toBe(false); + unmount(c); + target.remove(); + }); + + it("keeps branch rows with colliding display labels distinct", async () => { + const report = makeReport(); + report.by_branch = [ + { + project_key: "pl1:sha256:first", + project: "", + branch: "main", + agent_minutes: 12, + cost: testMoney(0), + interactive_agent_minutes: 12, + automated_agent_minutes: 0, + interactive_cost: testMoney(0), + automated_cost: testMoney(0), + }, + { + project_key: "pl1:sha256:second", + project: "", + branch: "main", + agent_minutes: 3, + cost: testMoney(0), + interactive_agent_minutes: 3, + automated_agent_minutes: 0, + interactive_cost: testMoney(0), + automated_cost: testMoney(0), + }, + ]; + const target = document.createElement("div"); + document.body.appendChild(target); + const component = mount(Breakdowns, { target, props: { report } }); + await tick(); + + const branchPanel = Array.from(target.querySelectorAll(".breakdown-panel")).find( + (panel) => panel.querySelector(".panel-title")?.textContent === "Branch", + ); + expect(branchPanel?.querySelectorAll(".bar-row")).toHaveLength(2); + expect(target.textContent).not.toContain("pl1:sha256:"); + unmount(component); + target.remove(); + }); }); diff --git a/frontend/src/lib/components/activity/ConcurrencyTimeline.svelte b/frontend/src/lib/components/activity/ConcurrencyTimeline.svelte index c3394c778..35fc7545b 100644 --- a/frontend/src/lib/components/activity/ConcurrencyTimeline.svelte +++ b/frontend/src/lib/components/activity/ConcurrencyTimeline.svelte @@ -1,13 +1,8 @@ + +
+ + + {#if open} + + {/if} +
+ + diff --git a/frontend/src/lib/components/shared/BranchPicker.test.ts b/frontend/src/lib/components/shared/BranchPicker.test.ts new file mode 100644 index 000000000..a7035fdb8 --- /dev/null +++ b/frontend/src/lib/components/shared/BranchPicker.test.ts @@ -0,0 +1,299 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { mount, tick, unmount } from "svelte"; +import { render } from "@testing-library/svelte"; +import BranchPicker from "./BranchPicker.svelte"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function flush() { + await tick(); + await Promise.resolve(); + await tick(); +} + +function rowNames(): string[] { + return Array.from( + document.querySelectorAll("[role=option]:not(.all)"), + ).map((row) => row.textContent?.trim() ?? ""); +} + +let component: ReturnType | undefined; + +afterEach(() => { + if (component) { + unmount(component); + component = undefined; + } + vi.useRealTimers(); + document.body.innerHTML = ""; +}); + +describe("BranchPicker", () => { + it("forwards project scope and limits ordinary results to 100", async () => { + const search = vi.fn().mockResolvedValue({ + branches: Array.from({ length: 105 }, (_, i) => ({ branch: `branch-${i}` })), + has_more: true, + }); + component = mount(BranchPicker, { + target: document.body, + props: { + mode: "multi", + selected: [], + projects: ["project-a"], + search, + label: "Branch", + allLabel: "All branches", + placeholder: "Search branches", + clearSearchLabel: "Clear branch search", + loadingLabel: "Loading branches…", + emptyLabel: "No matching branches", + refineLabel: "More branches exist. Refine your search.", + onChange: () => {}, + }, + }); + + document.querySelector(".branch-picker-trigger")!.click(); + await flush(); + + expect(search).toHaveBeenCalledWith({ + projects: ["project-a"], + search: "", + limit: 100, + }); + expect(rowNames()).toHaveLength(100); + expect(document.body.textContent).toContain( + "More branches exist. Refine your search.", + ); + }); + + it("pins selected branches above search results without duplicating them", async () => { + const search = vi.fn().mockResolvedValue({ + branches: [{ branch: "feature" }, { branch: "main" }], + has_more: false, + }); + component = mount(BranchPicker, { + target: document.body, + props: { + mode: "multi", + selected: ["main", "selected-only"], + projects: [], + search, + label: "Branch", + allLabel: "All branches", + placeholder: "Search branches", + clearSearchLabel: "Clear branch search", + loadingLabel: "Loading branches…", + emptyLabel: "No matching branches", + refineLabel: "More branches exist. Refine your search.", + onChange: () => {}, + }, + }); + + document.querySelector(".branch-picker-trigger")!.click(); + await flush(); + + expect(search).toHaveBeenCalledWith({ search: "", limit: 100 }); + expect(rowNames()).toEqual(["main", "selected-only", "feature"]); + }); + + it("reloads with a changed project scope while open", async () => { + const search = vi.fn().mockResolvedValue({ branches: [], has_more: false }); + const view = render(BranchPicker, { + mode: "single", + selected: [], + projects: ["project-a"], + search, + label: "Branch", + allLabel: "All branches", + placeholder: "Search branches", + clearSearchLabel: "Clear branch search", + loadingLabel: "Loading branches…", + emptyLabel: "No matching branches", + refineLabel: "More branches exist. Refine your search.", + onChange: () => {}, + }); + document.querySelector(".branch-picker-trigger")!.click(); + await flush(); + + await view.rerender({ projects: ["project-b"] }); + await flush(); + + expect(search).toHaveBeenLastCalledWith({ + projects: ["project-b"], + search: "", + limit: 100, + }); + view.unmount(); + }); + + it("positions the panel fixed so overflow ancestors cannot clip it", async () => { + const search = vi.fn().mockResolvedValue({ branches: [], has_more: false }); + component = mount(BranchPicker, { + target: document.body, + props: { + mode: "single", + selected: [], + projects: [], + search, + label: "Branch", + allLabel: "All branches", + placeholder: "Search branches", + clearSearchLabel: "Clear branch search", + loadingLabel: "Loading branches…", + emptyLabel: "No matching branches", + refineLabel: "More branches exist. Refine your search.", + onChange: () => {}, + }, + }); + const trigger = document.querySelector( + ".branch-picker-trigger", + )!; + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 20, + y: 30, + top: 30, + right: 152, + bottom: 58, + left: 20, + width: 132, + height: 28, + toJSON: () => ({}), + }); + + trigger.click(); + await flush(); + + const panel = document.querySelector(".branch-picker-panel")!; + const style = panel.getAttribute("style") ?? ""; + expect(style).toContain("left:"); + expect(style).toContain("top:"); + }); + + it("waits for the debounce before searching typed text", async () => { + vi.useFakeTimers(); + const search = vi.fn().mockResolvedValue({ branches: [], has_more: false }); + component = mount(BranchPicker, { + target: document.body, + props: { + mode: "single", + selected: [], + projects: [], + search, + label: "Branch", + allLabel: "All branches", + placeholder: "Search branches", + clearSearchLabel: "Clear branch search", + loadingLabel: "Loading branches…", + emptyLabel: "No matching branches", + refineLabel: "More branches exist. Refine your search.", + onChange: () => {}, + }, + }); + document.querySelector(".branch-picker-trigger")!.click(); + await flush(); + + const input = document.querySelector("input")!; + input.value = "queued"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); + + expect(search).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(249); + expect(search).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(search).toHaveBeenLastCalledWith({ search: "queued", limit: 100 }); + }); + + it("cancels a queued search when the picker closes", async () => { + vi.useFakeTimers(); + const search = vi.fn().mockResolvedValue({ branches: [], has_more: false }); + component = mount(BranchPicker, { + target: document.body, + props: { + mode: "single", + selected: [], + projects: [], + search, + label: "Branch", + allLabel: "All branches", + placeholder: "Search branches", + clearSearchLabel: "Clear branch search", + loadingLabel: "Loading branches…", + emptyLabel: "No matching branches", + refineLabel: "More branches exist. Refine your search.", + onChange: () => {}, + }, + }); + const trigger = document.querySelector(".branch-picker-trigger")!; + trigger.click(); + await flush(); + const input = document.querySelector("input")!; + input.value = "queued"; + input.dispatchEvent(new Event("input", { bubbles: true })); + trigger.click(); + await vi.advanceTimersByTimeAsync(250); + + expect(search).toHaveBeenCalledTimes(1); + }); + + it("debounces search and suppresses stale responses", async () => { + vi.useFakeTimers(); + const first = deferred<{ branches: { branch: string }[]; has_more: boolean }>(); + const second = deferred<{ branches: { branch: string }[]; has_more: boolean }>(); + const search = vi.fn() + .mockResolvedValueOnce({ branches: [], has_more: false }) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + component = mount(BranchPicker, { + target: document.body, + props: { + mode: "single", + selected: [], + projects: ["project-a"], + search, + label: "Branch", + allLabel: "All branches", + placeholder: "Search branches", + clearSearchLabel: "Clear branch search", + loadingLabel: "Loading branches…", + emptyLabel: "No matching branches", + refineLabel: "More branches exist. Refine your search.", + onChange: () => {}, + }, + }); + document.querySelector(".branch-picker-trigger")!.click(); + await flush(); + + const input = document.querySelector("input")!; + input.value = "old"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + input.value = "new"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + + second.resolve({ branches: [{ branch: "new-result" }], has_more: false }); + await flush(); + first.resolve({ branches: [{ branch: "old-result" }], has_more: false }); + await flush(); + + expect(search).toHaveBeenLastCalledWith({ + projects: ["project-a"], + search: "new", + limit: 100, + }); + expect(rowNames()).toEqual(["new-result"]); + }); +}); diff --git a/frontend/src/lib/components/sidebar/SessionList.svelte b/frontend/src/lib/components/sidebar/SessionList.svelte index cb63f5061..a0b02f06b 100644 --- a/frontend/src/lib/components/sidebar/SessionList.svelte +++ b/frontend/src/lib/components/sidebar/SessionList.svelte @@ -492,7 +492,6 @@ onToggleGroupByAgent={toggleGroupByAgent} onToggleGroupByProject={toggleGroupByProject} onClearGroupMode={() => setGroupMode("none")} - extraActive={sessions.filters.termination !== ""} onClearExtra={() => sessions.setTerminationFilter("")} extraSections={statusFilterSection} /> diff --git a/frontend/src/lib/components/usage/AttributionPanel.svelte b/frontend/src/lib/components/usage/AttributionPanel.svelte index cab197b30..5bc757d4c 100644 --- a/frontend/src/lib/components/usage/AttributionPanel.svelte +++ b/frontend/src/lib/components/usage/AttributionPanel.svelte @@ -4,26 +4,26 @@ type GroupBy, type AttributionView, } from "../../stores/usage.svelte.js"; + import { + branchFilterToken, + branchLabel, + } from "../../branchFilters.js"; import Treemap from "./Treemap.svelte"; import { m } from "../../i18n/index.js"; import { formatMoney, moneyFromMicrodollars } from "../../money.js"; import { formatTokenCount } from "../../utils/format.js"; import { sumSelectedTokens } from "../../stores/usageTokenTypes.js"; + import { createVirtualizer } from "../../virtual/createVirtualizer.svelte.js"; interface Props { colorMap: ReadonlyMap; } let { colorMap }: Props = $props(); - - function fmtPct(v: number, total: number): string { - if (total <= 0) return ""; - return `${((v / total) * 100).toFixed(1)}%`; - } - const groupBy = $derived(usage.toggles.attribution.groupBy); const view = $derived(usage.toggles.attribution.view); const isTokenMode = $derived(usage.mode === "token"); + const noBranchLabel = $derived(m.shared_no_branch()); interface Row { id: string; @@ -59,6 +59,14 @@ ? sumSelectedTokens(m, usage.selectedTokenTypes) : m.cost.microdollars, })); + } else if (groupBy === "branch") { + items = s.branchTotals.map((b) => ({ + id: branchFilterToken(b.project_key, b.branch), + label: branchLabel(b.project, b.branch, noBranchLabel), + value: isTokenMode + ? sumSelectedTokens(b, usage.selectedTokenTypes) + : b.cost.microdollars, + })); } else { items = s.agentTotals.map((a) => ({ id: a.agent, @@ -86,15 +94,40 @@ })); }); + const RAIL_ROW_HEIGHT = 24; + const LIST_ROW_HEIGHT = 42; + let railScrollElement: HTMLDivElement | undefined = $state(); + let listScrollElement: HTMLDivElement | undefined = $state(); + + const railVirtualizer = createVirtualizer(() => ({ + count: rows.length, + getScrollElement: () => railScrollElement ?? null, + estimateSize: () => RAIL_ROW_HEIGHT, + overscan: 6, + getItemKey: (index) => rows[index]?.id ?? index, + })); + + const listVirtualizer = createVirtualizer(() => ({ + count: rows.length, + getScrollElement: () => listScrollElement ?? null, + estimateSize: () => LIST_ROW_HEIGHT, + overscan: 6, + getItemKey: (index) => rows[index]?.id ?? index, + })); + + // One SVG group is drawn per tile, and branch grouping can produce + // thousands of (project, branch) rows whose tiles would be sub-pixel; + // cap the treemap to the top slice by cost. The side rail and list view + // scroll, so every row stays visible and clickable there. + const TREEMAP_MAX_TILES = 40; + const treemapItems = $derived( - rows.map((r) => ({ + rows.slice(0, TREEMAP_MAX_TILES).map((r) => ({ id: r.id, label: r.label, value: r.value, color: r.color, - meta: fmtPct(r.value, rows.reduce( - (sum, item) => sum + item.value, 0, - )), + meta: r.pct > 0 ? `${(r.pct * 100).toFixed(1)}%` : "", })), ); @@ -103,11 +136,40 @@ usage.toggleProjectKey(id); } else if (groupBy === "agent") { usage.toggleAgent(id); - } else { + } else if (groupBy === "model") { usage.toggleModel(id); + } else if (groupBy === "branch") { + usage.toggleBranch(id); } } + // Project and agent clicks exclude the item ("hide"); model and + // branch clicks toggle an include-based selection ("filter"), so the + // hint, tooltip, and aria copy must describe different actions. + const includeBased = $derived( + groupBy === "model" || groupBy === "branch", + ); + + function isRowSelected(id: string): boolean { + if (groupBy === "model") return usage.isModelSelected(id); + if (groupBy === "branch") return usage.isBranchSelected(id); + return false; + } + + function rowTitle(id: string, label: string): string { + if (!includeBased) return m.usage_click_to_hide({ label }); + return isRowSelected(id) + ? m.usage_click_to_clear_filter({ label }) + : m.usage_click_to_filter({ label }); + } + + function rowAriaLabel(id: string, label: string): string { + if (!includeBased) return m.usage_hide_from_chart({ label }); + return isRowSelected(id) + ? m.usage_clear_filter_item({ label }) + : m.usage_filter_to_item({ label }); + } + function handleGroupByChange(g: GroupBy) { usage.setAttributionGroupBy(g); } @@ -147,6 +209,13 @@ > {m.analytics_col_agent()} +
{:else} -
{m.usage_click_to_hide_hint()}
+
+ {includeBased + ? m.usage_click_to_filter_hint() + : m.usage_click_to_hide_hint()} +
{#if view === "treemap"}
@@ -179,67 +252,78 @@ height={260} onSelect={handleSelect} formatValue={isTokenMode ? formatTokenCount : undefined} + titleFor={rowTitle} + ariaLabelFor={rowAriaLabel} />
-
- {#each rows as row, i (row.id)} - - -
handleSelect(row.id)} - > - {i + 1} - - {row.label} - - {isTokenMode - ? formatTokenCount(row.value) - : formatMoney(moneyFromMicrodollars(row.value))} - -
- {/each} -
-
- {:else} -
- {#each rows as row, i (row.id)} - - +
handleSelect(row.id)} + class="rail-virtual-spacer" + style="height: {railVirtualizer.instance?.getTotalSize() ?? 0}px; position: relative;" > - {i + 1} - -
- {row.label} -
+ {#each railVirtualizer.instance?.getVirtualItems() ?? [] as virtualRow (virtualRow.key)} + {@const row = rows[virtualRow.index]} + {#if row} + +
-
-
- - {(row.pct * 100).toFixed(1)}% - - - {isTokenMode - ? formatTokenCount(row.value) - : formatMoney(moneyFromMicrodollars(row.value))} - + class="rail-row" + title={rowTitle(row.id, row.label)} + style="position: absolute; top: 0; left: 0; width: 100%; height: {virtualRow.size}px; transform: translateY({virtualRow.start}px);" + onclick={() => handleSelect(row.id)} + > + {virtualRow.index + 1} + + {row.label} + + {isTokenMode + ? formatTokenCount(row.value) + : formatMoney(moneyFromMicrodollars(row.value))} + +
+ {/if} + {/each}
- {/each} +
+
+ {:else} +
+
+ {#each listVirtualizer.instance?.getVirtualItems() ?? [] as virtualRow (virtualRow.key)} + {@const row = rows[virtualRow.index]} + {#if row} + + +
handleSelect(row.id)} + > + {virtualRow.index + 1} + +
+ {row.label} +
+
+
+
+ {(row.pct * 100).toFixed(1)}% + + {isTokenMode + ? formatTokenCount(row.value) + : formatMoney(moneyFromMicrodollars(row.value))} + +
+ {/if} + {/each} +
{/if} {/if} @@ -312,9 +396,6 @@ } .side-rail { - display: flex; - flex-direction: column; - gap: 2px; overflow-y: auto; max-height: 280px; } @@ -327,6 +408,7 @@ border-radius: var(--radius-sm); cursor: pointer; transition: background 0.1s; + box-sizing: border-box; } .rail-row:hover { @@ -367,9 +449,8 @@ /* List view */ .list-view { - display: flex; - flex-direction: column; - gap: 4px; + max-height: 420px; + overflow-y: auto; } .list-row { @@ -380,6 +461,7 @@ border-radius: var(--radius-sm); cursor: pointer; transition: background 0.1s; + box-sizing: border-box; } .list-row:hover { diff --git a/frontend/src/lib/components/usage/AttributionPanel.test.ts b/frontend/src/lib/components/usage/AttributionPanel.test.ts index 599bc6d6b..66813fc12 100644 --- a/frontend/src/lib/components/usage/AttributionPanel.test.ts +++ b/frontend/src/lib/components/usage/AttributionPanel.test.ts @@ -20,10 +20,37 @@ vi.mock("../../api/generated/index", () => ({ UsageService: usageServiceMocks, })); +vi.mock("../../virtual/createVirtualizer.svelte.js", () => ({ + createVirtualizer: ( + options: () => { + count: number; + estimateSize: (index: number) => number; + getItemKey?: (index: number) => string | number; + }, + ) => ({ + get instance() { + const opts = options(); + const count = Math.min(opts.count, 12); + const size = opts.estimateSize(0); + return { + getTotalSize: () => opts.count * size, + getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ + index, + key: opts.getItemKey?.(index) ?? index, + start: index * size, + size, + end: (index + 1) * size, + })), + }; + }, + }), +})); + import AttributionPanel from "./AttributionPanel.svelte"; import { settings } from "../../stores/settings.svelte.js"; import { usage } from "../../stores/usage.svelte.js"; import { usageChartColorMaps } from "../../utils/usageChartColors.js"; +import { branchFilterToken } from "../../branchFilters.js"; function summaryWithAgents(agents: string[]): UsageSummaryResponse { return { @@ -39,6 +66,7 @@ function summaryWithAgents(agents: string[]): UsageSummaryResponse { daily: [], projectTotals: [], modelTotals: [], + branchTotals: [], agentTotals: agents.map((agent, i) => ({ agent, inputTokens: 60 - i * 20, @@ -156,8 +184,74 @@ describe("AttributionPanel agent exclusion", () => { ); unmount(component); }); + + // Agent clicks exclude the clicked agent, so the hide copy stays. + it("describes agent rows as hide actions", async () => { + const component = mountPanel(); + await tick(); + + expect( + document.querySelector(".hint")?.textContent?.trim(), + ).toBe("Click to hide from chart"); + const rows = document.querySelectorAll(".list-row"); + expect(rows[0]?.getAttribute("title")).toBe("Click to hide claude"); + unmount(component); + }); }); +function usageSummary(): UsageSummaryResponse { + return { + from: "2024-01-01", + to: "2024-01-31", + totals: { + inputTokens: 100, + outputTokens: 50, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: testMoney(12), + }, + daily: [], + projectTotals: [], + modelTotals: [], + agentTotals: [], + branchTotals: [ + { + project_key: "pl1:sha256:alpha", + project: "alpha", + branch: "main", + inputTokens: 60, + outputTokens: 30, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(8), + }, + { + project_key: "pl1:sha256:alpha", + project: "alpha", + branch: "dev", + inputTokens: 40, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(4), + }, + ], + sessionCounts: { + total: 2, + byProject: { alpha: 2 }, + byAgent: {}, + }, + cacheStats: { + cacheReadTokens: 0, + cacheCreationTokens: 0, + uncachedInputTokens: 100, + outputTokens: 50, + hitRate: 0, + savingsVsUncached: testMoney(0), + }, + }; +} + describe("AttributionPanel project identity", () => { beforeEach(() => { vi.clearAllMocks(); @@ -340,3 +434,223 @@ describe("AttributionPanel colors", () => { unmount(component); }); }); + +describe("AttributionPanel branch mode", () => { + beforeEach(() => { + usage.summary = usageSummary(); + usage.toggles.attribution.groupBy = "branch"; + usage.toggles.attribution.view = "list"; + }); + + afterEach(() => { + usage.summary = null; + usage.toggles.attribution.groupBy = "project"; + usage.selectedGitBranch = ""; + document.body.innerHTML = ""; + vi.restoreAllMocks(); + }); + + it("routes a branch row click into the branch exclusion toggle", async () => { + const spy = vi + .spyOn(usage, "toggleBranch") + .mockImplementation(() => {}); + const component = mountPanel(); + await tick(); + + const row = Array.from( + document.querySelectorAll(".list-row"), + ).find((r) => r.textContent?.includes("alpha/dev")); + expect(row).toBeTruthy(); + row?.click(); + await tick(); + + expect(spy).toHaveBeenCalledWith( + branchFilterToken("pl1:sha256:alpha", "dev"), + ); + unmount(component); + }); + + // Branch clicks filter the dashboard to the clicked branch (include + // semantics), so the copy must say "filter", not "hide". + it("describes branch rows as filter actions", async () => { + const component = mountPanel(); + await tick(); + + // Neutral toggle copy: once a row is selected, clicking it clears + // the filter, so the hint must not promise only "filter". + expect( + document.querySelector(".hint")?.textContent?.trim(), + ).toBe("Click to add or remove filters"); + + const row = Array.from( + document.querySelectorAll(".list-row"), + ).find((r) => r.textContent?.includes("alpha/dev")); + expect(row?.getAttribute("title")).toBe( + "Click to filter by alpha/dev", + ); + unmount(component); + }); + + it("describes a selected branch row as clearing its filter", async () => { + usage.selectedGitBranch = branchFilterToken( + "pl1:sha256:alpha", + "dev", + ); + const component = mountPanel(); + await tick(); + + const row = Array.from( + document.querySelectorAll(".list-row"), + ).find((r) => r.textContent?.includes("alpha/dev")); + expect(row?.getAttribute("title")).toBe( + "Click to clear the alpha/dev filter", + ); + unmount(component); + }); + + it("passes filter copy to treemap tile titles and aria labels", async () => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + usage.toggles.attribution.view = "treemap"; + usage.selectedGitBranch = branchFilterToken( + "pl1:sha256:alpha", + "dev", + ); + const component = mountPanel(); + await tick(); + + const titles = Array.from( + document.querySelectorAll("g.tile title"), + ).map((t) => t.textContent); + expect(titles).toContain("Click to filter by alpha/main"); + expect(titles).toContain("Click to clear the alpha/dev filter"); + + const ariaLabels = Array.from( + document.querySelectorAll("g.tile"), + ).map((g) => g.getAttribute("aria-label")); + expect(ariaLabels).toContain("Filter by alpha/main"); + expect(ariaLabels).toContain("Clear the alpha/dev filter"); + + unmount(component); + vi.unstubAllGlobals(); + }); + + it("virtualizes a large branch list without losing exact selection", async () => { + usage.summary = usageSummary(); + usage.summary.branchTotals = Array.from({ length: 1000 }, (_, index) => ({ + project_key: `pl1:sha256:${index}`, + project: `project-${index}`, + branch: `branch-${index}`, + inputTokens: index, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(1000 - index), + })); + const spy = vi.spyOn(usage, "toggleBranch").mockImplementation(() => {}); + + const component = mountPanel(); + await tick(); + + expect(document.querySelectorAll(".list-row")).toHaveLength(12); + expect( + document.querySelector(".list-virtual-spacer")?.style.height, + ).toBe("42000px"); + document.querySelector(".list-row")?.click(); + expect(spy).toHaveBeenCalledWith( + branchFilterToken("pl1:sha256:0", "branch-0"), + ); + unmount(component); + }); + + it("virtualizes the large treemap rail while retaining the tile cap", async () => { + usage.summary = usageSummary(); + usage.summary.branchTotals = Array.from({ length: 1000 }, (_, index) => ({ + project_key: `pl1:sha256:${index}`, + project: `project-${index}`, + branch: `branch-${index}`, + inputTokens: index, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(1000 - index), + })); + usage.toggles.attribution.view = "treemap"; + + const component = mountPanel(); + await tick(); + + expect(document.querySelectorAll("g.tile").length).toBeLessThanOrEqual(40); + expect(document.querySelectorAll(".rail-row")).toHaveLength(12); + expect( + document.querySelector(".rail-virtual-spacer")?.style.height, + ).toBe("24000px"); + unmount(component); + }); +}); + +describe("AttributionPanel model mode", () => { + beforeEach(() => { + const summary = summaryWithAgents([]); + summary.modelTotals = [ + { + model: "claude-sonnet-5", + inputTokens: 60, + outputTokens: 30, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(8), + }, + { + model: "gpt-4o", + inputTokens: 40, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(4), + }, + ]; + usage.summary = summary; + usage.toggles.attribution.groupBy = "model"; + usage.toggles.attribution.view = "list"; + }); + + afterEach(() => { + usage.summary = null; + usage.toggles.attribution.groupBy = "project"; + usage.selectedModels = ""; + document.body.innerHTML = ""; + }); + + // Model selection is include-based like branches, so model rows must + // advertise filtering, and a selected row must advertise clearing. + it("describes model rows as filter actions with selected-state copy", async () => { + usage.selectedModels = "gpt-4o"; + const component = mountPanel(); + await tick(); + + expect( + document.querySelector(".hint")?.textContent?.trim(), + ).toBe("Click to add or remove filters"); + + const rows = Array.from( + document.querySelectorAll(".list-row"), + ); + const unselected = rows.find((r) => + r.textContent?.includes("claude-sonnet-5"), + ); + const selected = rows.find((r) => r.textContent?.includes("gpt-4o")); + expect(unselected?.getAttribute("title")).toBe( + "Click to filter by claude-sonnet-5", + ); + expect(selected?.getAttribute("title")).toBe( + "Click to clear the gpt-4o filter", + ); + unmount(component); + }); +}); diff --git a/frontend/src/lib/components/usage/CostTimeSeriesChart.svelte b/frontend/src/lib/components/usage/CostTimeSeriesChart.svelte index 0705170d2..6fe6683be 100644 --- a/frontend/src/lib/components/usage/CostTimeSeriesChart.svelte +++ b/frontend/src/lib/components/usage/CostTimeSeriesChart.svelte @@ -1,5 +1,10 @@
@@ -414,6 +461,13 @@ > {m.analytics_col_agent()} +
@@ -463,7 +517,7 @@
- {#if seriesData.keys.length > 1} + {#if seriesData.keys.length > 1 || seriesData.keys.includes("__unattributed__")}
{#each seriesData.keys as key} @@ -471,7 +525,7 @@ class="legend-dot" style="background: {colorMap.get(key) ?? 'var(--text-muted)'}" > - {key === "__other__" ? m.shared_other() : (seriesData.labels[key] ?? key)} + {legendLabel(key)} {/each}
diff --git a/frontend/src/lib/components/usage/CostTimeSeriesChart.test.ts b/frontend/src/lib/components/usage/CostTimeSeriesChart.test.ts index 5bfbf264f..c38263b36 100644 --- a/frontend/src/lib/components/usage/CostTimeSeriesChart.test.ts +++ b/frontend/src/lib/components/usage/CostTimeSeriesChart.test.ts @@ -107,6 +107,7 @@ function usageSummary(): UsageSummaryResponse { ], modelTotals: [], agentTotals: [], + branchTotals: [], sessionCounts: { total: 15, byProject: { agentsview: 15 }, @@ -214,23 +215,91 @@ describe("CostTimeSeriesChart", () => { }); it("keeps projects with the same display label as distinct series", async () => { - usage.summary = usageSummary(); - usage.summary.daily = [dailyEntry(0)]; - usage.summary.daily[0]!.projectBreakdowns = [ - { ...usage.summary.daily[0]!.projectBreakdowns![0]!, cost: testMoney(6) }, - { - ...usage.summary.daily[0]!.projectBreakdowns![0]!, - project_key: "pl1:sha256:other-archive", - cost: testMoney(4), - }, - ]; - - const component = mountChart(); - await tick(); - - expect(document.querySelectorAll("path[opacity='0.7']")).toHaveLength(2); - expect(document.querySelectorAll(".legend-item")).toHaveLength(2); - unmount(component); + usage.summary = usageSummary(); + usage.summary.daily = [dailyEntry(0)]; + usage.summary.daily[0]!.projectBreakdowns = [ + { ...usage.summary.daily[0]!.projectBreakdowns![0]!, cost: testMoney(6) }, + { + ...usage.summary.daily[0]!.projectBreakdowns![0]!, + project_key: "pl1:sha256:other-archive", + cost: testMoney(4), + }, + ]; + + const component = mountChart(); + await tick(); + + expect(document.querySelectorAll("path[opacity='0.7']")).toHaveLength(2); + expect(document.querySelectorAll(".legend-item")).toHaveLength(2); + unmount(component); + }); + + it("renders branch legend labels, not raw tokens", async () => { + const summary = usageSummary(); + for (const day of summary.daily) { + day.branchBreakdowns = [ + { + project_key: "pl1:sha256:agentsview", + project: "agentsview", + branch: "main", + inputTokens: 80, + outputTokens: 40, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(8), + }, + { + project_key: "pl1:sha256:agentsview", + project: "agentsview", + branch: "", + inputTokens: 20, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(2), + }, + ]; + } + usage.summary = summary; + usage.toggles.timeSeries.groupBy = "branch"; + + const component = mountChart(); + await tick(); + + const legendText = Array.from( + document.querySelectorAll(".legend-item"), + ).map((el) => el.textContent!.trim()); + expect(legendText).toContain("agentsview/main"); + expect(legendText).toContain("agentsview/(no branch)"); + expect(document.body.textContent).not.toContain("\u001f"); + + unmount(component); + }); + + it("keeps colliding branch display labels as distinct series", async () => { + const summary = usageSummary(); + summary.daily = [dailyEntry(0)]; + summary.daily[0]!.branchBreakdowns = [ + { + project_key: "pl1:sha256:first", project: "", branch: "main", + inputTokens: 60, outputTokens: 30, cacheCreationTokens: 0, + cacheReadTokens: 0, cost: testMoney(6), + }, + { + project_key: "pl1:sha256:second", project: "", branch: "main", + inputTokens: 40, outputTokens: 20, cacheCreationTokens: 0, + cacheReadTokens: 0, cost: testMoney(4), + }, + ]; + usage.summary = summary; + usage.toggles.timeSeries.groupBy = "branch"; + + const component = mountChart(); + await tick(); + + expect(document.querySelectorAll("path[opacity='0.7']")).toHaveLength(2); + expect(document.body.textContent).not.toContain("pl1:sha256:"); + unmount(component); }); it("uses distinct active model colors for paths and legend dots", async () => { @@ -338,4 +407,93 @@ describe("CostTimeSeriesChart", () => { expect(dots).toEqual(["rgb(255, 127, 14)", "rgb(31, 119, 180)"]); unmount(component); }); + + it("shows fully unattributable branch cost explicitly", async () => { + const summary = usageSummary(); + for (const day of summary.daily) { + day.branchBreakdowns = []; + } + usage.summary = summary; + usage.toggles.timeSeries.groupBy = "branch"; + + const component = mountChart(); + await tick(); + + expect(document.querySelector(".empty")).toBeNull(); + expect(document.querySelector("svg.chart-svg")).toBeTruthy(); + expect(document.body.textContent).toContain("Unattributed"); + unmount(component); + }); + + it("shows the unattributed remainder beside branch-attributed cost", async () => { + const summary = usageSummary(); + summary.daily = [dailyEntry(0), dailyEntry(1)]; + summary.daily[0]!.branchBreakdowns = [{ + project_key: "pl1:sha256:agentsview", + project: "agentsview", + branch: "main", + inputTokens: 60, + outputTokens: 30, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(6), + }]; + summary.daily[1]!.branchBreakdowns = []; + usage.summary = summary; + usage.toggles.timeSeries.groupBy = "branch"; + + const component = mountChart(); + await tick(); + + const legendText = Array.from( + document.querySelectorAll(".legend-item"), + ).map((el) => el.textContent!.trim()); + expect(legendText).toContain("agentsview/main"); + expect(legendText).toContain("Unattributed"); + expect(document.querySelectorAll("path[opacity='0.7']")).toHaveLength(2); + unmount(component); + }); + + it("shows same-day unattributed cost beside branch-attributed cost", async () => { + const summary = usageSummary(); + summary.daily = [dailyEntry(0)]; + summary.daily[0]!.branchBreakdowns = [{ + project_key: "pl1:sha256:agentsview", + project: "agentsview", + branch: "main", + inputTokens: 60, + outputTokens: 30, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(5), + }]; + usage.summary = summary; + usage.toggles.timeSeries.groupBy = "branch"; + + const component = mountChart(); + await tick(); + + const legendText = Array.from( + document.querySelectorAll(".legend-item"), + ).map((el) => el.textContent!.trim()); + expect(legendText).toContain("agentsview/main"); + expect(legendText).toContain("Unattributed"); + unmount(component); + }); + + it("keeps the total fallback for non-branch groupings without breakdowns", async () => { + const summary = usageSummary(); + for (const day of summary.daily) { + day.projectBreakdowns = []; + } + usage.summary = summary; + usage.toggles.timeSeries.groupBy = "project"; + + const component = mountChart(); + await tick(); + + expect(document.querySelector(".empty")).toBeNull(); + expect(document.querySelector("svg.chart-svg")).toBeTruthy(); + unmount(component); + }); }); diff --git a/frontend/src/lib/components/usage/FilterDropdown.svelte b/frontend/src/lib/components/usage/FilterDropdown.svelte index dbb2e08ab..b05fed1eb 100644 --- a/frontend/src/lib/components/usage/FilterDropdown.svelte +++ b/frontend/src/lib/components/usage/FilterDropdown.svelte @@ -11,14 +11,18 @@ interface FilterItem { name: string; + /** Display label when name is an opaque identity (e.g. branch tokens). */ + label?: string; count?: number; } interface Props { label: string; items: FilterItem[]; - /** Comma-separated list of EXCLUDED item names. */ + /** Separator-joined list of EXCLUDED item names. */ excludedCsv: string; + /** List separator; branch tokens can contain commas. */ + separator?: string; onToggle: (name: string) => void; onSelectAll?: () => void; onDeselectAll?: () => void; @@ -30,6 +34,7 @@ label, items, excludedCsv, + separator = ",", onToggle, onSelectAll, onDeselectAll, @@ -38,7 +43,7 @@ }: Props = $props(); const filterSet = $derived( - new Set(excludedCsv ? excludedCsv.split(",") : []), + new Set(excludedCsv ? excludedCsv.split(separator) : []), ); const filteredCount = $derived(filterSet.size); @@ -46,10 +51,24 @@ items.length - filteredCount, ); + function singleItemLabel(item: FilterItem): string { + const display = item.label ?? item.name; + const maxLen = 20; + if (display.length > maxLen) { + return `${label}: ${display.slice(0, maxLen)}...`; + } + return `${label}: ${display}`; + } + const buttonLabel = $derived.by(() => { if (filteredCount === 0) return m.usage_filter_all({ label }); if (mode === "include") { - if (filteredCount === 1) return `${label}: ${excludedCsv}`; + if (filteredCount === 1) { + // Resolve the display label: the name can be an opaque token + // (branch tokens embed a control-character separator). + const selected = items.find((i) => filterSet.has(i.name)); + if (selected) return singleItemLabel(selected); + } return m.usage_filter_selected({ label, countLabel: filteredCount.toLocaleString(), @@ -59,13 +78,7 @@ const visible = items.find( (i) => !filterSet.has(i.name), ); - if (visible) { - const maxLen = 20; - if (visible.name.length > maxLen) { - return `${label}: ${visible.name.slice(0, maxLen)}...`; - } - return `${label}: ${visible.name}`; - } + if (visible) return singleItemLabel(visible); } if (visibleCount === 0) return m.usage_filter_none({ label }); return m.usage_filter_hidden({ @@ -82,7 +95,7 @@ : !filterSet.has(item.name); return { id: item.name, - label: item.name, + label: item.label ?? item.name, active: included, count: item.count, color: color?.(item.name), @@ -106,16 +119,34 @@ }); - 0} - showBadge={false} - sections={[{ items: dropdownItems }]} - searchable={items.length > 8} - searchPlaceholder={m.usage_filter_search()} - emptyLabel={m.sidebar_filters_no_match()} - onSelectAll={mode === "exclude" ? onSelectAll : undefined} - onDeselectAll={mode === "exclude" ? onDeselectAll : undefined} - selectAllLabel={m.usage_filter_select_all()} - deselectAllLabel={m.usage_filter_deselect_all()} -/> +
+ 0} + showBadge={false} + sections={[{ items: dropdownItems }]} + searchable={items.length > 8} + searchPlaceholder={m.usage_filter_search()} + emptyLabel={m.sidebar_filters_no_match()} + onSelectAll={mode === "exclude" ? onSelectAll : undefined} + onDeselectAll={mode === "exclude" ? onDeselectAll : undefined} + selectAllLabel={m.usage_filter_select_all()} + deselectAllLabel={m.usage_filter_deselect_all()} + /> +
+ + diff --git a/frontend/src/lib/components/usage/FilterDropdown.test.ts b/frontend/src/lib/components/usage/FilterDropdown.test.ts new file mode 100644 index 000000000..d190ec5f7 --- /dev/null +++ b/frontend/src/lib/components/usage/FilterDropdown.test.ts @@ -0,0 +1,82 @@ +import { + afterEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; +import { flushSync, mount, tick, unmount } from "svelte"; +import FilterDropdown from "./FilterDropdown.svelte"; +import { BRANCH_LIST_SEP } from "../../branchFilters.js"; + +let component: ReturnType | undefined; + +afterEach(() => { + if (component) unmount(component); + component = undefined; + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +async function openDropdown() { + const trigger = document.querySelector( + ".kit-filter-dropdown__btn", + ); + trigger?.click(); + flushSync(); + await tick(); +} + +function rowStates(): Array<{ name: string; included: boolean }> { + return Array.from( + document.querySelectorAll(".kit-filter-dropdown__item"), + ).map((row) => ({ + name: + row.querySelector(".kit-filter-dropdown__label")?.textContent ?? "", + included: row.classList.contains("active"), + })); +} + +describe("FilterDropdown separators", () => { + it("keeps comma-containing names intact with a custom separator", async () => { + const tokenA = "proj\u001ffeat,one"; + const tokenB = "proj\u001fmain"; + component = mount(FilterDropdown, { + target: document.body, + props: { + label: "Branch", + items: [ + { name: tokenA, label: "proj/feat,one" }, + { name: tokenB, label: "proj/main" }, + ], + excludedCsv: tokenA, + separator: BRANCH_LIST_SEP, + onToggle: () => {}, + }, + }); + await openDropdown(); + + expect(rowStates()).toEqual([ + { name: "proj/feat,one", included: false }, + { name: "proj/main", included: true }, + ]); + }); + + it("splits on commas by default", async () => { + component = mount(FilterDropdown, { + target: document.body, + props: { + label: "Project", + items: [{ name: "alpha" }, { name: "beta" }], + excludedCsv: "alpha,beta", + onToggle: () => {}, + }, + }); + await openDropdown(); + + expect(rowStates()).toEqual([ + { name: "alpha", included: false }, + { name: "beta", included: false }, + ]); + }); +}); diff --git a/frontend/src/lib/components/usage/Treemap.svelte b/frontend/src/lib/components/usage/Treemap.svelte index e9aa6f325..3a7ab9846 100644 --- a/frontend/src/lib/components/usage/Treemap.svelte +++ b/frontend/src/lib/components/usage/Treemap.svelte @@ -1,5 +1,4 @@ @@ -110,17 +115,18 @@ height={tile.height} /> + onSelect?.(tile.id)} + aria-label={ariaLabelFor(tile.id, tile.label)} + onclick={() => onSelect(tile.id)} onkeydown={(e) => handleKey(e, tile.id)} clip-path="url(#{clipId})" > - {m.usage_click_to_hide({ label: tile.label })} + {titleFor(tile.id, tile.label)} { + if (sessions.filters.project) return [sessions.filters.project]; + const excluded = new Set( + usage.excludedProjects ? usage.excludedProjects.split(",") : [], + ); + const included = sessions.projects + .map((project) => project.name) + .filter((project) => !excluded.has(project)); + return included.length === sessions.projects.length ? [] : included; + }); + const selectedBranchValues = $derived( + scopeBranchFilterValues( + usage.selectedGitBranch + ? usage.selectedGitBranch.split(BRANCH_LIST_SEP) + : [], + sessions.filters.project, + ), + ); + const selectedBranchNames = $derived( + branchPickerValues(selectedBranchValues), + ); + + function searchUsageBranches(params: { + projects?: string[]; + search: string; + limit: number; + }) { + return searchBranches({ + ...params, + includeOneShot: true, + includeAutomated: true, + scope: "all", + }); + } + + function onUsageBranchesChange(values: string[]) { + usage.selectedGitBranch = reconcileBranchFilterValues( + selectedBranchValues, + values, + ).join(BRANCH_LIST_SEP); + usage.fetchAll(); + } + const earliestSession = $derived(sync.stats?.earliest_session ?? null); const rangeSelection = $derived( @@ -195,10 +246,10 @@ // apply params that are actually present in the URL. const USAGE_FILTER_KEYS = new Set([ "from", "to", "window_days", - "model", "exclude_model", "exclude_agent", + "model", "exclude_model", "exclude_agent", "branch", ]); const SESSION_FILTER_KEYS = new Set([ - "project", "machine", "agent", + "project", "machine", "git_branch", "agent", "termination", "active_since", "exclude_project", "min_messages", "max_messages", "min_user_messages", @@ -362,6 +413,11 @@ usage.excludedAgents = newExAgent; changed = true; } + const newBranch = params["branch"] ?? ""; + if (newBranch !== usage.selectedGitBranch) { + usage.selectedGitBranch = newBranch; + changed = true; + } if (usage.excludedModels) { usage.excludedModels = ""; changed = true; @@ -390,6 +446,7 @@ excludedProjects: usage.excludedProjects, excludedProjectKeys: usage.excludedProjectKeys, excludedAgents: usage.excludedAgents, + selectedGitBranch: usage.selectedGitBranch, excludedModels: usage.excludedModels, selectedModels: usage.selectedModels, }; @@ -511,6 +568,22 @@ usage.deselectAllModels(modelItems.map((m) => m.name))} /> + + ({ + createVirtualizer: ( + options: () => { + count: number; + estimateSize: (index: number) => number; + getItemKey?: (index: number) => string | number; + }, + ) => ({ + get instance() { + const opts = options(); + const size = opts.estimateSize(0); + return { + getTotalSize: () => opts.count * size, + getVirtualItems: () => + Array.from({ length: opts.count }, (_, index) => ({ + index, + key: opts.getItemKey?.(index) ?? index, + start: index * size, + size, + end: (index + 1) * size, + })), + }; + }, + }), +})); + async function flushEffects() { await tick(); await Promise.resolve(); @@ -39,6 +65,7 @@ function usageSummaryWithUnsupported(kind?: string) { projectTotals: [], modelTotals: [], agentTotals: [], + branchTotals: [], sessionCounts: { total: 0, byProject: {}, @@ -674,6 +701,14 @@ describe("UsagePage refresh behavior", () => { expect(initBlock).not.toContain("sessions.initFromParams(params)"); }); + it("wires the shared multi-select branch picker with project scope", () => { + expect(source).toContain(" { expect(source).toContain("UsagePairwiseComparisonPanel"); expect(source).toContain(""); diff --git a/frontend/src/lib/components/usage/UsagePairwiseComparisonPanel.test.ts b/frontend/src/lib/components/usage/UsagePairwiseComparisonPanel.test.ts index b91826fc7..dd2560dab 100644 --- a/frontend/src/lib/components/usage/UsagePairwiseComparisonPanel.test.ts +++ b/frontend/src/lib/components/usage/UsagePairwiseComparisonPanel.test.ts @@ -59,6 +59,7 @@ function usageSummary(): UsageSummaryResponse { }, ], agentTotals: [], + branchTotals: [], sessionCounts: { total: 2, byProject: { alpha: 1, beta: 1 }, diff --git a/frontend/src/lib/components/usage/UsageSummaryCards.test.ts b/frontend/src/lib/components/usage/UsageSummaryCards.test.ts index 9604ba858..a9c6e307a 100644 --- a/frontend/src/lib/components/usage/UsageSummaryCards.test.ts +++ b/frontend/src/lib/components/usage/UsageSummaryCards.test.ts @@ -36,6 +36,7 @@ function summary(): UsageSummaryResponse { projectTotals: [], modelTotals: [], agentTotals: [], + branchTotals: [], sessionCounts: { total: 1, byProject: { demo: 1 }, diff --git a/frontend/src/lib/stores/activity.svelte.ts b/frontend/src/lib/stores/activity.svelte.ts index 91cdc1435..444b343c1 100644 --- a/frontend/src/lib/stores/activity.svelte.ts +++ b/frontend/src/lib/stores/activity.svelte.ts @@ -1,4 +1,5 @@ import type { AgentInfo, ProjectInfo } from "../api/types.js"; +import { splitBranchFilterToken } from "../branchFilters.js"; import type { Report } from "../api/types/activity.js"; import { ActivityService, MetadataService } from "../api/generated/index"; import { callGenerated, configureGeneratedClient, isAbortError } from "../api/runtime.js"; @@ -51,6 +52,7 @@ class ActivityStore { project: string = $state(""); agent: string = $state(""); machine: string = $state(""); + branch: string = $state(""); automation: Automation = $state("all"); report: Report | null = $state(null); loading = $state(false); @@ -65,11 +67,9 @@ class ActivityStore { // aggregation on every event. hasNewData: boolean = $state(false); - // Filter-option lists for the activity controls. Loaded with full - // inclusion (one-shot + automated) so every project/agent/machine - // that can appear in the always-inclusive activity report is also - // selectable here — unlike the sidebar's lists, which honor the - // sidebar include toggles. + // Filter-option lists for the bounded metadata controls. Loaded with full + // inclusion (one-shot + automated), unlike the sidebar lists which honor + // the sidebar include toggles. Branches are searched on demand by the picker. projects: ProjectInfo[] = $state([]); agents: AgentInfo[] = $state([]); machines: string[] = $state([]); @@ -134,6 +134,7 @@ class ActivityStore { timezone: this.timezone, bucket: (this.bucket || undefined) as "5m" | "15m" | "1h" | "1d" | "1w" | undefined, project: this.project || undefined, + gitBranch: this.branch || undefined, agent: this.agent || undefined, machine: this.machine || undefined, automation: this.automation, @@ -246,7 +247,8 @@ class ActivityStore { if (isAbortError(e) || !this.filterOptionsRead.isCurrent(signal)) return false; ok = false; } - const current = ver === this.#filterOptionsVersion && this.filterOptionsRead.isCurrent(signal); + const current = + ver === this.#filterOptionsVersion && this.filterOptionsRead.isCurrent(signal); if (current) { // Cache only a fully successful load so a transient failure is // retried rather than frozen as a permanent empty list. @@ -330,6 +332,11 @@ class ActivityStore { this.project = params.project ?? ""; this.agent = params.agent ?? ""; this.machine = params.machine ?? ""; + this.branch = params.git_branch ?? ""; + if (this.branch && this.project) { + const legacy = splitBranchFilterToken(this.branch); + if (legacy.project && legacy.project !== this.project) this.branch = ""; + } this.automation = AUTOMATIONS.has(params.automation ?? "") ? (params.automation as Automation) : "all"; @@ -339,9 +346,9 @@ class ActivityStore { * Write the current range/preset/filter state to the URL through the router's * single replaceState path. `preset` is always included; `date` is included * for day/week/month when non-empty; `from`/`to` only for the custom preset; - * bucket/project/agent/machine only when non-empty; `automation` only when not - * the "all" default. Empty filters and preset-irrelevant fields are omitted so - * URLs stay minimal and deep-linkable. + * bucket/project/agent/machine/branch only when non-empty; `automation` only + * when not the "all" default. Empty filters and preset-irrelevant fields are + * omitted so URLs stay minimal and deep-linkable. */ writeUrl() { const p: Record = { preset: this.preset }; @@ -358,6 +365,7 @@ class ActivityStore { if (this.project) p.project = this.project; if (this.agent) p.agent = this.agent; if (this.machine) p.machine = this.machine; + if (this.branch) p.git_branch = this.branch; if (this.automation !== "all") p.automation = this.automation; router.replaceParams(p); } @@ -431,6 +439,13 @@ class ActivityStore { setProject(project: string) { this.project = project; + // New branch values are plain names and remain valid under a changed + // project scope. Legacy project-pair URLs are cleared only when their + // embedded project conflicts with the new scope. + if (this.branch) { + const legacy = splitBranchFilterToken(this.branch); + if (legacy.project && legacy.project !== project) this.branch = ""; + } this.writeUrl(); } @@ -444,6 +459,11 @@ class ActivityStore { this.writeUrl(); } + setBranch(branch: string) { + this.branch = branch; + this.writeUrl(); + } + setAutomation(automation: Automation) { this.automation = automation; this.writeUrl(); @@ -458,8 +478,8 @@ export const activity = new ActivityStore(); events.subscribe((event) => activity.handleDataChangedEvent(event)); // Refresh the activity filter options after any sync/import, mirroring the -// sessions store, so newly imported projects/agents/machines appear in the -// activity controls without a full page reload. Only refetch when an +// sessions store, so newly imported projects/agents/machines appear +// in the activity controls without a full page reload. Only refetch when an // ActivityPage is mounted; otherwise the invalidated cache is picked up lazily // by the next mount's loadFilterOptions(). The report itself is deliberately // not refetched here: that is driven by the manual refresh button and the diff --git a/frontend/src/lib/stores/activity.test.ts b/frontend/src/lib/stores/activity.test.ts index 162f2d747..e71547862 100644 --- a/frontend/src/lib/stores/activity.test.ts +++ b/frontend/src/lib/stores/activity.test.ts @@ -7,6 +7,7 @@ const api = vi.hoisted(() => ({ getProjects: vi.fn(), getAgents: vi.fn(), getMachines: vi.fn(), + getBranches: vi.fn(), })); const apiRuntimeMocks = vi.hoisted(() => ({ @@ -23,6 +24,7 @@ vi.mock("../api/generated/index", () => ({ getApiV1Projects: api.getProjects, getApiV1Agents: api.getAgents, getApiV1Machines: api.getMachines, + getApiV1Branches: api.getBranches, }, })); vi.mock("../api/runtime.js", () => ({ @@ -91,6 +93,7 @@ beforeEach(() => { api.getProjects.mockReset(); api.getAgents.mockReset(); api.getMachines.mockReset(); + api.getBranches.mockReset(); apiRuntimeMocks.callGenerated.mockReset(); apiRuntimeMocks.callGenerated.mockImplementation( (request: () => Promise, _signal?: AbortSignal) => request(), @@ -98,6 +101,7 @@ beforeEach(() => { api.getProjects.mockResolvedValue({ projects: [] }); api.getAgents.mockResolvedValue({ agents: [] }); api.getMachines.mockResolvedValue({ machines: [] }); + api.getBranches.mockResolvedValue({ branches: [] }); activity.report = null; activity.loading = false; activity.error = null; @@ -111,6 +115,7 @@ beforeEach(() => { activity.setProject(""); activity.setAgent(""); activity.setMachine(""); + activity.setBranch(""); activity.setAutomation("all"); // Reset the filter-option cache so each test exercises the fetch. activity.invalidateFilterOptions(); @@ -202,16 +207,37 @@ describe("load", () => { expect(activity.error).toBeNull(); }); - it("passes project/agent/machine filters", async () => { + it("passes project/agent/machine/branch filters", async () => { api.getActivityReport.mockResolvedValue(makeReport()); activity.setProject("p1"); activity.setAgent("claude"); activity.setMachine("m1"); + activity.setBranch("p1\x1fmain"); await activity.load(); const arg = api.getActivityReport.mock.calls.at(-1)![0]; expect(arg.project).toBe("p1"); expect(arg.agent).toBe("claude"); expect(arg.machine).toBe("m1"); + expect(arg.gitBranch).toBe("p1\x1fmain"); + }); + + it("clears a branch scoped to another project on project change", async () => { + activity.setProject("p1"); + activity.setBranch("p1\x1fmain"); + activity.setProject("p2"); + expect(activity.branch).toBe(""); + }); + + it("keeps the branch when the project change matches its scope", async () => { + activity.setBranch("p1\x1fmain"); + activity.setProject("p1"); + expect(activity.branch).toBe("p1\x1fmain"); + }); + + it("clears a conflicting legacy branch during URL hydration", () => { + activity.hydrateFromUrl({ project: "p2", git_branch: "p1\x1fmain" }); + expect(activity.project).toBe("p2"); + expect(activity.branch).toBe(""); }); it("defaults the automation class to all", async () => { @@ -340,13 +366,13 @@ describe("loadFilterOptions", () => { api.getMachines.mockResolvedValueOnce({ machines: ["laptop", "desktop"], }); - await activity.loadFilterOptions(); const full = { includeOneShot: true, includeAutomated: true }; expect(api.getProjects).toHaveBeenCalledWith(full); expect(api.getAgents).toHaveBeenCalledWith(full); expect(api.getMachines).toHaveBeenCalledWith(full); + expect(api.getBranches).not.toHaveBeenCalled(); expect(activity.projects).toEqual([{ name: "proj-a", count: 1 }]); expect(activity.agents).toEqual([{ name: "claude", count: 2 }]); diff --git a/frontend/src/lib/stores/analytics.svelte.ts b/frontend/src/lib/stores/analytics.svelte.ts index e2024e777..d4c27f66d 100644 --- a/frontend/src/lib/stores/analytics.svelte.ts +++ b/frontend/src/lib/stores/analytics.svelte.ts @@ -20,6 +20,7 @@ import { import { sessions } from "./sessions.svelte.js"; import { perf, type PerfEntryStatus } from "./perf.svelte.js"; import { rollingRange, today } from "../utils/dates.js"; +import { BRANCH_LIST_SEP } from "../branchFilters.js"; type AnalyticsParams = Parameters< typeof AnalyticsService.getApiV1AnalyticsSummary @@ -62,6 +63,7 @@ class AnalyticsStore { selectedDate: string | null = $state(null); project: string = $state(""); machine: string = $state(""); + branch: string = $state(""); agent: string = $state(""); model: string = $state(""); termination: string = $state(""); @@ -160,6 +162,7 @@ class AnalyticsStore { this.selectedDate !== null || this.project !== "" || this.machine !== "" || + this.branch !== "" || this.agent !== "" || this.model !== "" || this.termination !== "" || @@ -191,6 +194,7 @@ class AnalyticsStore { this.selectedDate = null; this.project = ""; this.machine = ""; + this.branch = ""; this.agent = ""; this.model = ""; this.termination = ""; @@ -203,6 +207,7 @@ class AnalyticsStore { this.selectedHour = null; sessions.filters.project = ""; sessions.filters.machine = ""; + sessions.filters.branch = ""; sessions.filters.agent = ""; sessions.filters.termination = ""; sessions.filters.minUserMessages = 0; @@ -334,6 +339,19 @@ class AnalyticsStore { this.fetchAll(); } + removeBranch(token: string) { + const current = this.branch + ? this.branch.split(BRANCH_LIST_SEP) + : []; + this.branch = current + .filter((t) => t !== token) + .join(BRANCH_LIST_SEP); + sessions.filters.branch = this.branch; + sessions.activeSessionId = null; + sessions.load(); + this.fetchAll(); + } + clearTermination() { this.termination = ""; sessions.filters.termination = ""; @@ -390,6 +408,7 @@ class AnalyticsStore { p.project = this.project; } if (this.machine) p.machine = this.machine; + if (this.branch) p.gitBranch = this.branch; if (this.agent) p.agent = this.agent; if (includeModel && this.model) p.model = this.model; if (this.termination) p.termination = this.termination; @@ -434,6 +453,7 @@ class AnalyticsStore { p.project = this.project; } if (this.machine) p.machine = this.machine; + if (this.branch) p.gitBranch = this.branch; if (this.agent) p.agent = this.agent; if (includeModel && this.model) p.model = this.model; if (this.termination) p.termination = this.termination; diff --git a/frontend/src/lib/stores/analytics.test.ts b/frontend/src/lib/stores/analytics.test.ts index 99e2ade1a..89304ffd8 100644 --- a/frontend/src/lib/stores/analytics.test.ts +++ b/frontend/src/lib/stores/analytics.test.ts @@ -249,6 +249,7 @@ function resetStore() { analytics.selectedHour = null; analytics.project = ""; analytics.machine = ""; + analytics.branch = ""; analytics.agent = ""; analytics.includeAutomated = false; analytics.automatedScope = "human"; @@ -682,6 +683,55 @@ describe("AnalyticsStore machine filter", () => { }); }); +describe("AnalyticsStore branch filter", () => { + const branchTokens = "proj-a\u001fmain\u001eproj-b\u001fdev"; + + it.each([ + { name: "summary", fn: () => analyticsService.getApiV1AnalyticsSummary }, + { name: "activity", fn: () => analyticsService.getApiV1AnalyticsActivity }, + { name: "heatmap", fn: () => analyticsService.getApiV1AnalyticsHeatmap }, + { name: "projects", fn: () => analyticsService.getApiV1AnalyticsProjects }, + { name: "hourOfWeek", fn: () => analyticsService.getApiV1AnalyticsHourOfWeek }, + { name: "sessionShape", fn: () => analyticsService.getApiV1AnalyticsSessions }, + { name: "velocity", fn: () => analyticsService.getApiV1AnalyticsVelocity }, + { name: "tools", fn: () => analyticsService.getApiV1AnalyticsTools }, + { name: "skills", fn: () => analyticsService.getApiV1AnalyticsSkills }, + { name: "topSessions", fn: () => analyticsService.getApiV1AnalyticsTopSessions }, + { name: "signals", fn: () => analyticsService.getApiV1AnalyticsSignals }, + ])("should include gitBranch in $name params", ({ fn }) => { + analytics.branch = branchTokens; + + analytics.fetchAll(); + + const mock = vi.mocked(fn()); + expect(mock).toHaveBeenCalled(); + const params = mock.mock.lastCall?.[0]; + expect(params?.gitBranch).toBe(branchTokens); + }); + + it("should include gitBranch in date drill-down params", () => { + analytics.branch = branchTokens; + analytics.selectDate("2024-01-15"); + + expect(analyticsService.getApiV1AnalyticsSummary).toHaveBeenLastCalledWith( + expect.objectContaining({ + from: "2024-01-15", + to: "2024-01-15", + gitBranch: branchTokens, + }), + ); + }); + + it("removeBranch drops one token and keeps the rest", () => { + analytics.branch = branchTokens; + + analytics.removeBranch("proj-a\u001fmain"); + + expect(analytics.branch).toBe("proj-b\u001fdev"); + expect(analyticsService.getApiV1AnalyticsSummary).toHaveBeenCalled(); + }); +}); + describe("AnalyticsStore automated scope params", () => { it("derives all scope from legacy includeAutomated updates", () => { analytics.includeAutomated = true; diff --git a/frontend/src/lib/stores/router.svelte.ts b/frontend/src/lib/stores/router.svelte.ts index 4e5b198d5..263d1af5e 100644 --- a/frontend/src/lib/stores/router.svelte.ts +++ b/frontend/src/lib/stores/router.svelte.ts @@ -76,6 +76,7 @@ const STICKY_PARAMS = new Set(["desktop"]); const SESSION_ROUTE_PARAMS = new Set([ "project", "machine", + "git_branch", "agent", "termination", "date", diff --git a/frontend/src/lib/stores/sessionRouteParams.test.ts b/frontend/src/lib/stores/sessionRouteParams.test.ts index 92442a20c..ef3f141dc 100644 --- a/frontend/src/lib/stores/sessionRouteParams.test.ts +++ b/frontend/src/lib/stores/sessionRouteParams.test.ts @@ -150,5 +150,6 @@ describe("session route params", () => { it("tracks rolling window and termination as session route params", () => { expect(SESSION_FILTER_KEYS.has("window_days")).toBe(true); expect(SESSION_FILTER_KEYS.has("termination")).toBe(true); + expect(SESSION_FILTER_KEYS.has("git_branch")).toBe(true); }); }); diff --git a/frontend/src/lib/stores/sessionRouteParams.ts b/frontend/src/lib/stores/sessionRouteParams.ts index 10863e6ec..7a8083595 100644 --- a/frontend/src/lib/stores/sessionRouteParams.ts +++ b/frontend/src/lib/stores/sessionRouteParams.ts @@ -6,6 +6,7 @@ export const SESSION_ANALYTICS_WINDOW_PARAM = "window_days"; export const SESSION_FILTER_KEYS: ReadonlySet = new Set([ "project", "machine", + "git_branch", "agent", "termination", "date", diff --git a/frontend/src/lib/stores/sessions.svelte.ts b/frontend/src/lib/stores/sessions.svelte.ts index 97be73941..7d3a2fdde 100644 --- a/frontend/src/lib/stores/sessions.svelte.ts +++ b/frontend/src/lib/stores/sessions.svelte.ts @@ -15,6 +15,8 @@ import type { SidebarSessionIndexResponse, SidebarSessionIndexRow, } from "../api/types.js"; +import { BRANCH_LIST_SEP } from "../branchFilters.js"; +import { toggleListValue } from "../utils/lists.js"; import { sync } from "./sync.svelte.js"; import { events } from "./events.svelte.js"; import { starred } from "./starred.svelte.js"; @@ -91,6 +93,7 @@ export interface RecentlyDeletedSessions { export interface Filters { project: string; machine: string; + branch: string; agent: string; termination: string; date: string; @@ -109,6 +112,7 @@ function defaultFilters(): Filters { return { project: "", machine: "", + branch: "", agent: "", termination: "", date: "", @@ -205,6 +209,7 @@ export function filtersToParams( const p: Record = {}; if (f.project) p["project"] = f.project; if (f.machine) p["machine"] = f.machine; + if (f.branch) p["git_branch"] = f.branch; if (f.agent) p["agent"] = f.agent; if (f.termination) p["termination"] = f.termination; if (f.date) p["date"] = f.date; @@ -275,6 +280,7 @@ export function parseFiltersFromParams( return { project, machine: params["machine"] ?? "", + branch: params["git_branch"] ?? "", agent: params["agent"] ?? "", termination: params["termination"] ?? "", date: params["date"] ?? "", @@ -376,6 +382,7 @@ class SessionsStore { project: f.project || undefined, excludeProject: exclude, machine: f.machine || undefined, + gitBranch: f.branch || undefined, agent: f.agent || undefined, termination: f.termination || undefined, date: f.date || undefined, @@ -1076,16 +1083,7 @@ class SessionsStore { } toggleMachineFilter(machine: string) { - const current = this.filters.machine - ? this.filters.machine.split(",") - : []; - const idx = current.indexOf(machine); - if (idx >= 0) { - current.splice(idx, 1); - } else { - current.push(machine); - } - this.filters.machine = current.join(","); + this.filters.machine = toggleListValue(this.filters.machine, machine, ","); this.setActiveSession(null); this.load(); } @@ -1100,6 +1098,25 @@ class SessionsStore { return this.filters.machine.split(","); } + setBranchFilters(values: string[]) { + this.filters.branch = values.join(BRANCH_LIST_SEP); + this.setActiveSession(null); + this.load(); + } + + toggleBranchFilter(token: string) { + this.setBranchFilters(toggleListValue( + this.filters.branch, + token, + BRANCH_LIST_SEP, + ).split(BRANCH_LIST_SEP).filter(Boolean)); + } + + get selectedBranches(): string[] { + if (!this.filters.branch) return []; + return this.filters.branch.split(BRANCH_LIST_SEP); + } + setAgentFilter(agent: string) { if (this.filters.agent === agent) { this.filters.agent = ""; @@ -1111,16 +1128,7 @@ class SessionsStore { } toggleAgentFilter(agent: string) { - const current = this.filters.agent - ? this.filters.agent.split(",") - : []; - const idx = current.indexOf(agent); - if (idx >= 0) { - current.splice(idx, 1); - } else { - current.push(agent); - } - this.filters.agent = current.join(","); + this.filters.agent = toggleListValue(this.filters.agent, agent, ","); this.setActiveSession(null); this.load(); } @@ -1198,21 +1206,27 @@ class SessionsStore { .includes(status); } - get hasActiveFilters(): boolean { + // Counts active filter dimensions for the sidebar filter-button badge. + // Project is excluded: it is not a control inside the filter dropdown. + // The date trio counts as one "date range" dimension. + get activeFilterCount(): number { const f = this.filters; - return !!( - f.machine || - f.agent || - f.termination || - f.recentlyActive || - f.hideUnknownProject || - f.dateFrom || - f.dateTo || - f.date || - f.minUserMessages > 0 || - !f.includeOneShot || - f.includeAutomated - ); + let n = 0; + if (f.machine) n++; + if (f.branch) n++; + if (f.agent) n++; + if (f.termination) n++; + if (f.recentlyActive) n++; + if (f.hideUnknownProject) n++; + if (f.date || f.dateFrom || f.dateTo) n++; + if (f.minUserMessages > 0) n++; + if (!f.includeOneShot) n++; + if (f.includeAutomated) n++; + return n; + } + + get hasActiveFilters(): boolean { + return this.activeFilterCount > 0; } clearSessionFilters(options: ClearSessionFiltersOptions = {}) { diff --git a/frontend/src/lib/stores/sessions.test.ts b/frontend/src/lib/stores/sessions.test.ts index 213b105ed..2adb7a7d1 100644 --- a/frontend/src/lib/stores/sessions.test.ts +++ b/frontend/src/lib/stores/sessions.test.ts @@ -28,6 +28,7 @@ const api = vi.hoisted(() => ({ getProjects: vi.fn(), getAgents: vi.fn(), getMachines: vi.fn(), + getBranches: vi.fn(), deleteSession: vi.fn(), batchDeleteSessions: vi.fn(), restoreSession: vi.fn(), @@ -91,6 +92,7 @@ vi.mock("../api/generated/index", () => ({ getApiV1Projects: vi.fn((params) => api.getProjects(params)), getApiV1Agents: vi.fn((params) => api.getAgents(params)), getApiV1Machines: vi.fn((params) => api.getMachines(params)), + getApiV1Branches: vi.fn((params) => api.getBranches(params)), getApiV1Stats: vi.fn((params) => api.getStats(params)), }, })); @@ -1538,6 +1540,7 @@ describe("SessionsStore", () => { const f: Filters = { project: "myproj", machine: "host-a", + branch: "myproj\u001ffeature", agent: "claude", termination: "unclean", date: "2024-06-15", @@ -1554,6 +1557,7 @@ describe("SessionsStore", () => { expect(filtersToParams(f)).toEqual({ project: "myproj", machine: "host-a", + git_branch: "myproj\u001ffeature", agent: "claude", termination: "unclean", date: "2024-06-15", @@ -1584,6 +1588,7 @@ describe("SessionsStore", () => { const original: Filters = { project: "myproj", machine: "host-a", + branch: "myproj\u001ffeature", agent: "claude", termination: "unclean", date: "2024-06-15", @@ -2088,6 +2093,62 @@ describe("SessionsStore", () => { expect(sessions.hasActiveFilters).toBe(false); }); + it("counts each active dimension once", () => { + const cases: Array<{ + name: string; + apply: () => void; + want: number; + }> = [ + { name: "none", apply: () => {}, want: 0 }, + { + name: "machine", + apply: () => { + sessions.filters.machine = "host-a"; + }, + want: 1, + }, + { + name: "branch", + apply: () => { + sessions.filters.branch = "projmain"; + }, + want: 1, + }, + { + name: "date trio counts once", + apply: () => { + sessions.filters.date = "2026-01-01"; + sessions.filters.dateFrom = "2026-01-01"; + sessions.filters.dateTo = "2026-01-31"; + }, + want: 1, + }, + { + name: "project not counted", + apply: () => { + sessions.filters.project = "myproj"; + }, + want: 0, + }, + { + name: "combined", + apply: () => { + sessions.filters.machine = "host-a"; + sessions.filters.branch = "projmain"; + sessions.filters.agent = "claude"; + sessions.filters.minUserMessages = 3; + sessions.filters.includeAutomated = true; + }, + want: 5, + }, + ]; + for (const c of cases) { + sessions.filters = parseFiltersFromParams({}); + c.apply(); + expect(sessions.activeFilterCount, c.name).toBe(c.want); + } + }); + it("clears the date yoke before clearing the active session", () => { sessions.activeSessionId = "session-1"; sessions.filters.dateFrom = "2025-05-01"; diff --git a/frontend/src/lib/stores/usage.svelte.ts b/frontend/src/lib/stores/usage.svelte.ts index 709de7b4b..80a96f2f6 100644 --- a/frontend/src/lib/stores/usage.svelte.ts +++ b/frontend/src/lib/stores/usage.svelte.ts @@ -9,6 +9,7 @@ import { UsageService } from "../api/generated/index"; import { ApiError, callGenerated, + configureGeneratedClient, isAbortError, } from "../api/runtime.js"; import { sessions } from "./sessions.svelte.js"; @@ -19,6 +20,14 @@ import { canonicalTokenTypes, type UsageTokenType, } from "./usageTokenTypes.js"; +import { + BRANCH_LIST_SEP, + NO_BRANCH_MATCH_TOKEN, + branchFilterValuesEqual, + intersectBranchFilterValues, + scopeBranchFilterValues, +} from "../branchFilters.js"; +import { toggleListValue } from "../utils/lists.js"; type UsageParams = Parameters[0]; type UsagePairwiseParams = @@ -41,7 +50,7 @@ export interface UsagePairwiseSelection { right: UsagePairwiseSideSelection; } -export type GroupBy = "project" | "model" | "agent"; +export type GroupBy = "project" | "model" | "agent" | "branch"; export type TimeSeriesView = "stacked-area" | "bars" | "lines"; export type AttributionView = "treemap" | "list" | "bars"; @@ -60,7 +69,12 @@ function defaultToggles(): Toggles { } function isGroupBy(value: unknown): value is GroupBy { - return value === "project" || value === "model" || value === "agent"; + return ( + value === "project" || + value === "model" || + value === "agent" || + value === "branch" + ); } function isUnknownProjectKeyError(error: unknown): boolean { @@ -121,6 +135,7 @@ export interface UsageFilterState { excludedProjects: string; excludedProjectKeys?: string; excludedAgents: string; + selectedGitBranch: string; excludedModels: string; selectedModels: string; } @@ -129,11 +144,16 @@ function loadUsageFilters(): UsageFilterState { try { const raw = localStorage.getItem(USAGE_FILTERS_KEY); if (raw) { + // Saved excludedGitBranch exclusion lists from the retired + // exclude-mode branch filter are dropped: an exclusion set cannot + // be mapped onto an include selection, so those views reset to + // "all branches". const saved = JSON.parse(raw) as Partial; return { excludedProjects: saved.excludedProjects ?? "", excludedProjectKeys: "", excludedAgents: saved.excludedAgents ?? "", + selectedGitBranch: saved.selectedGitBranch ?? "", excludedModels: "", selectedModels: saved.selectedModels ?? "", }; @@ -145,6 +165,7 @@ function loadUsageFilters(): UsageFilterState { excludedProjects: "", excludedProjectKeys: "", excludedAgents: "", + selectedGitBranch: "", excludedModels: "", selectedModels: "", }; @@ -155,6 +176,7 @@ function saveUsageFilters(f: UsageFilterState): void { const data: UsageFilterState = { excludedProjects: f.excludedProjects, excludedAgents: f.excludedAgents, + selectedGitBranch: f.selectedGitBranch, excludedModels: f.excludedModels, selectedModels: f.selectedModels, }; @@ -209,12 +231,16 @@ class UsageStore { ...ALL_TOKEN_TYPES, ]); - // Excluded project items and included model items - // (comma-separated strings). Empty models = all models. + // Excluded project/agent items and included model/branch items + // (separator-joined strings). Empty models/branches = all. + // Branch selection is include-based: an exclusion complement would + // grow with the branch catalog (deselect-all over thousands of + // (project, branch) pairs) instead of with the user's selection. // Initialized from localStorage to survive tab switches. excludedProjects: string = $state(""); excludedProjectKeys: string = $state(""); excludedAgents: string = $state(""); + selectedGitBranch: string = $state(""); excludedModels: string = $state(""); selectedModels: string = $state(""); @@ -223,11 +249,14 @@ class UsageStore { this.excludedProjects = saved.excludedProjects; this.excludedProjectKeys = saved.excludedProjectKeys ?? ""; this.excludedAgents = saved.excludedAgents; + this.selectedGitBranch = saved.selectedGitBranch; this.excludedModels = saved.excludedModels; this.selectedModels = saved.selectedModels; } summary = $state(null); + private summaryHasBranchBreakdowns = $state(false); + private branchBreakdownRequestPending = false; pairwiseComparison = $state(null); pairwiseSelection = $state( @@ -281,6 +310,7 @@ class UsageStore { timezone: this.timezone, project: sessionFilters.project || undefined, machine: sessionFilters.machine || undefined, + gitBranch: this.effectiveGitBranch(sessionFilters.branch), agent: sessionFilters.agent || undefined, termination: sessionFilters.termination || undefined, minUserMessages: @@ -295,6 +325,8 @@ class UsageStore { Date.now() - 24 * 60 * 60 * 1000, ).toISOString() : undefined, + branchBreakdowns: + this.toggles.attribution.groupBy === "branch" ? true : undefined, }; if ( sessionFilters.hideUnknownProject && @@ -319,6 +351,36 @@ class UsageStore { return p; } + // Plain branch names apply within the separately supplied project scope. + // Legacy project-pair tokens remain valid for stored URLs; only conflicting + // legacy pairs are dropped when a project is pinned. + private projectScopedLocalBranch(): string { + const local = this.selectedGitBranch; + if (!local) return local; + return scopeBranchFilterValues( + local.split(BRANCH_LIST_SEP), + sessions.filters.project, + ).join(BRANCH_LIST_SEP); + } + + // The sidebar branch filter and the usage page's own selection are both + // include lists but share one git_branch API param, so AND them by branch + // name. This lets new plain names interoperate with legacy project-pair URLs. + private effectiveGitBranch( + sidebarBranch: string, + ): string | undefined { + const local = this.projectScopedLocalBranch(); + if (!sidebarBranch) return local || undefined; + if (!local) return sidebarBranch; + const both = intersectBranchFilterValues( + local.split(BRANCH_LIST_SEP), + sidebarBranch.split(BRANCH_LIST_SEP), + ); + return both.length > 0 + ? both.join(BRANCH_LIST_SEP) + : NO_BRANCH_MATCH_TOKEN; + } + get pairwiseModelOptions(): string[] { return (this.summary?.modelTotals ?? []).map((entry) => entry.model); } @@ -449,43 +511,46 @@ class UsageStore { // Toggle an item's exclusion. Clicking an included item // excludes it; clicking an excluded item re-includes it. toggleProject(name: string): void { - this.excludedProjects = this.toggleCsv( - this.excludedProjects, name, + this.excludedProjects = toggleListValue( + this.excludedProjects, name, ",", ); this.fetchAll(); } toggleProjectKey(key: string): void { - this.excludedProjectKeys = this.toggleCsv( - this.excludedProjectKeys, key, + this.excludedProjectKeys = toggleListValue( + this.excludedProjectKeys, key, ",", ); this.fetchAll(); } toggleAgent(name: string): void { - this.excludedAgents = this.toggleCsv( - this.excludedAgents, name, + this.excludedAgents = toggleListValue( + this.excludedAgents, name, ",", ); this.fetchAll(); } toggleModel(name: string): void { - this.selectedModels = this.toggleCsv( - this.selectedModels, name, + this.selectedModels = toggleListValue( + this.selectedModels, name, ",", ); this.excludedModels = ""; this.fetchAll(); } - private toggleCsv(csv: string, name: string): string { - const current = csv ? csv.split(",") : []; - const idx = current.indexOf(name); - if (idx >= 0) { - current.splice(idx, 1); - } else { - current.push(name); - } - return current.join(","); + toggleBranch(value: string): void { + const current = this.selectedGitBranch + ? this.selectedGitBranch.split(BRANCH_LIST_SEP) + : []; + this.selectedGitBranch = current.some((selected) => + branchFilterValuesEqual(selected, value) + ) + ? current.filter((selected) => + !branchFilterValuesEqual(selected, value) + ).join(BRANCH_LIST_SEP) + : [...current, value].join(BRANCH_LIST_SEP); + this.fetchAll(); } // An item is "excluded" if it appears in the excluded CSV. @@ -515,6 +580,13 @@ class UsageStore { return this.selectedModels.split(",").includes(name); } + isBranchSelected(value: string): boolean { + if (!this.selectedGitBranch) return false; + return this.selectedGitBranch.split(BRANCH_LIST_SEP).some((selected) => + branchFilterValuesEqual(selected, value) + ); + } + selectAllProjects(): void { this.excludedProjects = ""; this.excludedProjectKeys = ""; @@ -536,6 +608,11 @@ class UsageStore { this.fetchAll(); } + selectAllBranches(): void { + this.selectedGitBranch = ""; + this.fetchAll(); + } + selectAllModels(): void { this.selectedModels = ""; this.excludedModels = ""; @@ -552,6 +629,7 @@ class UsageStore { this.excludedProjects = ""; this.excludedProjectKeys = ""; this.excludedAgents = ""; + this.selectedGitBranch = ""; this.excludedModels = ""; this.selectedModels = ""; this.fetchAll(); @@ -562,6 +640,7 @@ class UsageStore { this.excludedProjects !== "" || this.excludedProjectKeys !== "" || this.excludedAgents !== "" || + this.selectedGitBranch !== "" || this.selectedModels !== "" ); } @@ -604,6 +683,7 @@ class UsageStore { this.toggles.timeSeries.groupBy = g; this.toggles.attribution.groupBy = g; saveToggles(this.toggles); + if (g === "branch") void this.ensureBranchBreakdowns(); } setTimeSeriesView(v: TimeSeriesView) { @@ -615,6 +695,7 @@ class UsageStore { this.toggles.timeSeries.groupBy = g; this.toggles.attribution.groupBy = g; saveToggles(this.toggles); + if (g === "branch") void this.ensureBranchBreakdowns(); } setAttributionView(v: AttributionView) { @@ -696,12 +777,13 @@ class UsageStore { let status: Extract = "ok"; try { const params = options.params ?? this.baseParams(); - const data = await callGenerated(() => - UsageService.getApiV1UsageSummary(params), + const data = await callGenerated( + () => UsageService.getApiV1UsageSummary(params), signal, ) as unknown as UsageSummaryResponse; if (this.versions.summary === v) { this.summary = data; + this.summaryHasBranchBreakdowns = params.branchBreakdowns === true; this.errors.summary = null; this.ensurePairwiseSelection(); this.clearPairwiseComparisonState(); @@ -767,6 +849,40 @@ class UsageStore { return null; } + private async ensureBranchBreakdowns(): Promise { + if ( + this.summary === null || + this.summaryHasBranchBreakdowns || + this.branchBreakdownRequestPending + ) return; + this.branchBreakdownRequestPending = true; + const summaryVersion = this.versions.summary; + try { + const data = await callGenerated(() => + UsageService.getApiV1UsageSummary({ + ...this.baseParams(), + branchBreakdowns: true, + }) + ) as unknown as UsageSummaryResponse; + if ( + this.versions.summary === summaryVersion && + this.summary !== null + ) { + this.summary = { + ...data, + comparison: this.summary.comparison, + }; + this.summaryHasBranchBreakdowns = true; + } + } catch (e) { + if (this.versions.summary === summaryVersion) { + console.warn("usage.ensureBranchBreakdowns failed:", e); + } + } finally { + this.branchBreakdownRequestPending = false; + } + } + private async fetchComparison( summaryVersion: number, summary: UsageSummaryResponse, @@ -785,7 +901,7 @@ class UsageStore { signal, ) as unknown as UsageComparison; if (this.versions.summary === summaryVersion) { - this.summary = { ...summary, comparison }; + this.summary = { ...(this.summary ?? summary), comparison }; return "ok"; } return "aborted"; @@ -1018,6 +1134,7 @@ export interface UsageUrlState { excludedProjects: string; excludedProjectKeys: string; excludedAgents: string; + selectedGitBranch: string; excludedModels: string; selectedModels: string; } @@ -1062,6 +1179,11 @@ export function buildUsageUrlParams( if (state.excludedAgents) { params["exclude_agent"] = state.excludedAgents; } + // "branch" (not "git_branch") because session filters already own the + // git_branch key when usage and session params merge into one URL. + if (state.selectedGitBranch) { + params["branch"] = state.selectedGitBranch; + } return params; } diff --git a/frontend/src/lib/stores/usage.test.ts b/frontend/src/lib/stores/usage.test.ts index d87d288d6..f60079f87 100644 --- a/frontend/src/lib/stores/usage.test.ts +++ b/frontend/src/lib/stores/usage.test.ts @@ -68,6 +68,7 @@ const usageServiceMocks = vi.hoisted(() => { }, ], agentTotals: [], + branchTotals: [], sessionCounts: { total: 0, byProject: {}, @@ -136,6 +137,14 @@ const usageServiceMocks = vi.hoisted(() => { }; }); +const metadataServiceMocks = vi.hoisted(() => ({ + getApiV1Branches: vi.fn().mockResolvedValue({ + branches: [ + { project: "alpha", branch: "main", session_count: 3 }, + ], + }), +})); + const apiRuntimeMocks = vi.hoisted(() => { class ApiError extends Error { constructor( @@ -166,6 +175,9 @@ vi.mock("../api/generated/index", () => ({ usageServiceMocks.getApiV1UsagePairwiseComparison, getApiV1UsageTopSessions: usageServiceMocks.getApiV1UsageTopSessions, }, + MetadataService: { + getApiV1Branches: metadataServiceMocks.getApiV1Branches, + }, })); const TOGGLES_KEY = "usage-toggles"; @@ -248,6 +260,7 @@ function usageSummary(totalCost = 0): UsageSummaryResponse { }, ], agentTotals: [], + branchTotals: [], sessionCounts: { total: 0, byProject: {}, @@ -313,6 +326,7 @@ function usageSummaryWithOptions(options: { cost: testMoney(0), })), agentTotals: [], + branchTotals: [], sessionCounts: { total: 0, byProject: {}, @@ -395,6 +409,7 @@ describe("UsageStore filter persistence", () => { usage.excludedProjects = "proj-a"; usage.excludedProjectKeys = "pl1:sha256:proj-a"; usage.excludedAgents = "claude"; + usage.selectedGitBranch = "proj-a\u001fmain"; await usage.fetchAll(); const saved = JSON.parse( @@ -403,6 +418,7 @@ describe("UsageStore filter persistence", () => { expect(saved.excludedProjects).toBe("proj-a"); expect(saved.excludedProjectKeys).toBeUndefined(); expect(saved.excludedAgents).toBe("claude"); + expect(saved.selectedGitBranch).toBe("proj-a\u001fmain"); }); it("restores usage filters from localStorage on load", async () => { @@ -498,6 +514,178 @@ describe("UsageStore group-by linking", () => { }); }); +describe("UsageStore lazy branch breakdowns", () => { + beforeEach(() => { + installStorage(); + localStorage.removeItem(TOGGLES_KEY); + vi.clearAllMocks(); + }); + + it("omits branch breakdowns from the ordinary summary", async () => { + const { usage } = await loadStore(); + + await usage.fetchSummary(); + + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenLastCalledWith( + expect.not.objectContaining({ branchBreakdowns: true }), + ); + }); + + it("fetches once when Branch is first selected and reuses the rich summary", async () => { + const { usage } = await loadStore(); + await usage.fetchSummary(); + await Promise.resolve(); + const summaryCalls = usageServiceMocks.getApiV1UsageSummary.mock.calls.length; + const comparisonCalls = usageServiceMocks.getApiV1UsageComparison.mock.calls.length; + const pairwiseCalls = usageServiceMocks.getApiV1UsagePairwiseComparison.mock.calls.length; + const topSessionCalls = usageServiceMocks.getApiV1UsageTopSessions.mock.calls.length; + + usage.setAttributionGroupBy("branch"); + await vi.waitFor(() => + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenLastCalledWith( + expect.objectContaining({ branchBreakdowns: true }), + ) + ); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenCalledTimes( + summaryCalls + 1, + ); + expect(usageServiceMocks.getApiV1UsageComparison).toHaveBeenCalledTimes( + comparisonCalls, + ); + expect( + usageServiceMocks.getApiV1UsagePairwiseComparison, + ).toHaveBeenCalledTimes(pairwiseCalls); + expect(usageServiceMocks.getApiV1UsageTopSessions).toHaveBeenCalledTimes( + topSessionCalls, + ); + + usage.setTimeSeriesGroupBy("model"); + usage.setTimeSeriesGroupBy("branch"); + await Promise.resolve(); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenCalledTimes( + summaryCalls + 1, + ); + }); + + it("preserves completed comparison during branch enrichment", async () => { + const { usage } = await loadStore(); + await usage.fetchSummary(); + await vi.waitFor(() => + expect(usage.summary?.comparison).toEqual(usageComparison()) + ); + usageServiceMocks.getApiV1UsageSummary.mockResolvedValueOnce({ + ...usageSummary(), + branchTotals: [{ + project_key: "pl1:sha256:alpha", + project: "alpha", + branch: "main", + inputTokens: 1, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(0), + }], + }); + + usage.setAttributionGroupBy("branch"); + await vi.waitFor(() => + expect(usage.summary?.branchTotals).toHaveLength(1) + ); + + expect(usage.summary?.comparison).toEqual(usageComparison()); + }); + + it("keeps in-flight comparison and pairwise requests during branch enrichment", async () => { + const signals: (AbortSignal | undefined)[] = []; + apiRuntimeMocks.callGenerated.mockImplementation( + (request: () => Promise, signal?: AbortSignal) => { + signals.push(signal); + return request(); + }, + ); + let resolveComparison: + | ((value: UsageComparison) => void) + | undefined; + let resolvePairwise: + | ((value: UsagePairwiseComparisonResponse) => void) + | undefined; + usageServiceMocks.getApiV1UsageComparison.mockImplementationOnce( + () => new Promise((resolve) => { + resolveComparison = resolve; + }), + ); + usageServiceMocks.getApiV1UsagePairwiseComparison.mockImplementationOnce( + () => new Promise((resolve) => { + resolvePairwise = resolve; + }), + ); + usageServiceMocks.getApiV1UsageSummary + .mockResolvedValueOnce(usageSummary()) + .mockResolvedValueOnce({ + ...usageSummary(), + branchTotals: [{ + project_key: "pl1:sha256:alpha", + project: "alpha", + branch: "main", + inputTokens: 1, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(0), + }], + }); + + const { usage } = await loadStore(); + const refresh = usage.fetchAll(); + await vi.waitFor(() => { + expect( + usageServiceMocks.getApiV1UsagePairwiseComparison, + ).toHaveBeenCalledTimes(1); + }); + const comparisonSignal = signals[2]; + const pairwiseSignal = signals[3]; + expect(comparisonSignal).toBeDefined(); + expect(pairwiseSignal).toBeDefined(); + + usage.setAttributionGroupBy("branch"); + await vi.waitFor(() => + expect(usage.summary?.branchTotals).toHaveLength(1) + ); + const comparisonWasAborted = comparisonSignal?.aborted; + const pairwiseWasAborted = pairwiseSignal?.aborted; + + resolveComparison?.(usageComparison()); + resolvePairwise?.(usagePairwiseComparison()); + await refresh; + + expect(comparisonWasAborted).toBe(false); + expect(pairwiseWasAborted).toBe(false); + expect(usage.summary?.branchTotals).toHaveLength(1); + expect(usage.summary?.comparison).toEqual(usageComparison()); + expect(usage.pairwiseComparison).toEqual(usagePairwiseComparison()); + }); + + it("drops retained branch data on a non-Branch full refresh", async () => { + const { usage } = await loadStore(); + usage.setAttributionGroupBy("branch"); + await usage.fetchSummary(); + usage.setAttributionGroupBy("model"); + + await usage.fetchAll(); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenLastCalledWith( + expect.not.objectContaining({ branchBreakdowns: true }), + ); + + const before = usageServiceMocks.getApiV1UsageSummary.mock.calls.length; + usage.setAttributionGroupBy("branch"); + await vi.waitFor(() => + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenCalledTimes( + before + 1, + ) + ); + }); +}); + describe("UsageStore session filter params", () => { beforeEach(() => { installStorage(); @@ -510,6 +698,7 @@ describe("UsageStore session filter params", () => { sessions.filters.project = "proj-a"; sessions.filters.machine = "host-a,host-b"; + sessions.filters.branch = "proj-a\u001fmain"; sessions.filters.agent = "claude,codex"; sessions.filters.termination = "abandoned"; sessions.filters.minUserMessages = 5; @@ -523,6 +712,7 @@ describe("UsageStore session filter params", () => { expect.objectContaining({ project: "proj-a", machine: "host-a,host-b", + gitBranch: "proj-a\u001fmain", agent: "claude,codex", termination: "abandoned", minUserMessages: 5, @@ -537,6 +727,7 @@ describe("UsageStore session filter params", () => { expect.objectContaining({ project: "proj-a", machine: "host-a,host-b", + gitBranch: "proj-a\u001fmain", agent: "claude,codex", termination: "abandoned", minUserMessages: 5, @@ -658,6 +849,19 @@ describe("UsageStore session filter params", () => { ); }); + it("toggles a project key and refreshes usage", async () => { + const { usage } = await loadStore(); + const fetchAll = vi.spyOn(usage, "fetchAll").mockResolvedValue(); + + usage.toggleProjectKey("pl1:sha256:project"); + expect(usage.excludedProjectKeys).toBe("pl1:sha256:project"); + expect(fetchAll).toHaveBeenCalledTimes(1); + + usage.toggleProjectKey("pl1:sha256:project"); + expect(usage.excludedProjectKeys).toBe(""); + expect(fetchAll).toHaveBeenCalledTimes(2); + }); + it("refreshes response-scoped project selections after archive identity changes", async () => { usageServiceMocks.getApiV1UsageSummary.mockRejectedValueOnce( new apiRuntimeMocks.ApiError( @@ -694,6 +898,124 @@ describe("UsageStore session filter params", () => { expect(usage.summary).not.toBeNull(); }); + it("passes the branch selection to usage endpoints", async () => { + const { usage } = await loadStore(); + + usage.selectedGitBranch = "proj-a\u001fmain\u001eproj-b\u001fdev"; + + await usage.fetchAll(); + + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenLastCalledWith( + expect.objectContaining({ + gitBranch: "proj-a\u001fmain\u001eproj-b\u001fdev", + }), + ); + expect(usageServiceMocks.getApiV1UsageTopSessions).toHaveBeenLastCalledWith( + expect.objectContaining({ + gitBranch: "proj-a\u001fmain\u001eproj-b\u001fdev", + }), + ); + }); + + it("intersects plain local branch names with legacy sidebar tokens", async () => { + const { usage } = await loadStore(); + const { sessions } = await import("./sessions.svelte.js"); + + sessions.filters.branch = "proj-amainproj-bdev"; + usage.selectedGitBranch = "mainfeature"; + await usage.fetchAll(); + + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenLastCalledWith( + expect.objectContaining({ gitBranch: "proj-amain" }), + ); + }); + + it("intersects the sidebar branch filter with the local selection", async () => { + const { usage } = await loadStore(); + const { sessions } = await import("./sessions.svelte.js"); + const tokenA = "proj-a\u001fmain"; + const tokenB = "proj-b\u001fdev"; + + sessions.filters.branch = `${tokenA}\u001e${tokenB}`; + usage.selectedGitBranch = `${tokenB}\u001eproj-c\u001ffeat`; + await usage.fetchAll(); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenLastCalledWith( + expect.objectContaining({ gitBranch: tokenB }), + ); + + // A stale local selection with no overlap stays active and + // sends a fail-closed branch token, so the charts show no data + // instead of ignoring the visible local selection. + usage.selectedGitBranch = "proj-c\u001ffeat"; + await usage.fetchAll(); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenLastCalledWith( + expect.objectContaining({ + gitBranch: "no_branch_match", + }), + ); + }); + + it("scopes the local branch selection to the sidebar project filter", async () => { + const { usage } = await loadStore(); + const { sessions } = await import("./sessions.svelte.js"); + const tokenA = "proj-a\u001fmain"; + const tokenB = "proj-b\u001fdev"; + + sessions.filters.project = "proj-a"; + usage.selectedGitBranch = `main\u001e${tokenA}\u001e${tokenB}`; + await usage.fetchAll(); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenLastCalledWith( + expect.objectContaining({ + gitBranch: `main${tokenA}`, + project: "proj-a", + }), + ); + + // Plain names remain valid when a different project is pinned; only + // conflicting legacy project-pair tokens are removed. + sessions.filters.project = "proj-c"; + await usage.fetchAll(); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenLastCalledWith( + expect.objectContaining({ project: "proj-c" }), + ); + const params = + usageServiceMocks.getApiV1UsageSummary.mock.lastCall?.[0]; + expect(params?.gitBranch).toBe("main"); + }); + + it("preserves a legacy branch token when toggled by plain branch name", async () => { + const { usage } = await loadStore(); + const token = "proj-amain"; + usage.selectedGitBranch = token; + + usage.toggleBranch("feature"); + expect(usage.selectedGitBranch).toBe(`${token}feature`); + + usage.toggleBranch("main"); + expect(usage.selectedGitBranch).toBe("feature"); + }); + + it("toggles the branch selection with the list separator", async () => { + const { usage } = await loadStore(); + const tokenA = "proj-a\u001fmain"; + const tokenB = "proj-b\u001fmain"; + + usage.toggleBranch(tokenA); + usage.toggleBranch(tokenB); + expect(usage.selectedGitBranch).toBe(`${tokenA}\u001e${tokenB}`); + expect(usage.isBranchSelected(tokenA)).toBe(true); + expect(usage.isBranchSelected(tokenB)).toBe(true); + expect(usage.hasActiveFilters).toBe(true); + + usage.toggleBranch(tokenA); + expect(usage.selectedGitBranch).toBe(tokenB); + expect(usage.isBranchSelected(tokenA)).toBe(false); + expect(usage.isBranchSelected(tokenB)).toBe(true); + + usage.selectAllBranches(); + expect(usage.selectedGitBranch).toBe(""); + }); + it("stores pairwise comparison data from the generated API", async () => { const { usage } = await loadStore(); @@ -1476,6 +1798,7 @@ describe("buildUsageUrlParams", () => { excludedProjects: "p1", excludedProjectKeys: "pk1", excludedAgents: "a1", + selectedGitBranch: "", excludedModels: "m1", selectedModels: "m2", }); @@ -1496,6 +1819,7 @@ describe("buildUsageUrlParams", () => { excludedProjects: "", excludedProjectKeys: "", excludedAgents: "", + selectedGitBranch: "", excludedModels: "", selectedModels: "", }); @@ -1505,6 +1829,24 @@ describe("buildUsageUrlParams", () => { }); }); + it("emits branch for selected branch tokens", async () => { + const { buildUsageUrlParams } = await loadStore(); + const tokens = "proj-a\u001fmain\u001eproj-b\u001fdev"; + const params = buildUsageUrlParams({ + from: "", + to: "", + isPinned: false, + windowDays: 30, + excludedProjects: "", + excludedProjectKeys: "", + excludedAgents: "", + selectedGitBranch: tokens, + excludedModels: "", + selectedModels: "", + }); + expect(params).toEqual({ branch: tokens }); + }); + it("returns empty object when nothing is set", async () => { const { buildUsageUrlParams } = await loadStore(); const params = buildUsageUrlParams({ @@ -1515,6 +1857,7 @@ describe("buildUsageUrlParams", () => { excludedProjects: "", excludedProjectKeys: "", excludedAgents: "", + selectedGitBranch: "", excludedModels: "", selectedModels: "", }); @@ -1531,6 +1874,7 @@ describe("buildUsageUrlParams", () => { excludedProjects: "", excludedProjectKeys: "", excludedAgents: "", + selectedGitBranch: "", excludedModels: "", selectedModels: "", }); @@ -1547,6 +1891,7 @@ describe("buildUsageUrlParams", () => { excludedProjects: "", excludedProjectKeys: "", excludedAgents: "", + selectedGitBranch: "", excludedModels: "", selectedModels: "", }); @@ -1563,6 +1908,7 @@ describe("buildUsageUrlParams", () => { excludedProjects: "", excludedProjectKeys: "", excludedAgents: "", + selectedGitBranch: "", excludedModels: "", selectedModels: "", }); diff --git a/frontend/src/lib/utils/lists.ts b/frontend/src/lib/utils/lists.ts new file mode 100644 index 000000000..30bcf3622 --- /dev/null +++ b/frontend/src/lib/utils/lists.ts @@ -0,0 +1,23 @@ +// ABOUTME: Shared helper for separator-joined list state (CSV filters and +// ABOUTME: branch-token lists) used by the sessions and usage stores. + +/** + * Toggle a value's membership in a separator-joined list string: present + * values are removed, absent values appended. The separator must be a + * character that cannot appear inside values (comma for plain names, the + * branch list separator for branch tokens). + */ +export function toggleListValue( + list: string, + value: string, + sep: string, +): string { + const current = list ? list.split(sep) : []; + const idx = current.indexOf(value); + if (idx >= 0) { + current.splice(idx, 1); + } else { + current.push(value); + } + return current.join(sep); +} diff --git a/frontend/src/lib/utils/usageChartColors.test.ts b/frontend/src/lib/utils/usageChartColors.test.ts index e0d18b37f..0fec917a6 100644 --- a/frontend/src/lib/utils/usageChartColors.test.ts +++ b/frontend/src/lib/utils/usageChartColors.test.ts @@ -54,6 +54,7 @@ function tenModelSummary(): UsageSummaryResponse { projectTotals: [], modelTotals, agentTotals: [], + branchTotals: [], sessionCounts: { total: 10, byProject: {}, byAgent: {} }, cacheStats: { cacheReadTokens: 0, diff --git a/frontend/src/lib/utils/usageChartColors.ts b/frontend/src/lib/utils/usageChartColors.ts index cb4f745af..771494112 100644 --- a/frontend/src/lib/utils/usageChartColors.ts +++ b/frontend/src/lib/utils/usageChartColors.ts @@ -1,4 +1,5 @@ import type { UsageSummaryResponse } from "../api/types/usage.js"; +import { branchFilterToken } from "../branchFilters.js"; import { chartSeriesColorMap, type ChartPalette, @@ -8,6 +9,7 @@ export interface UsageChartColorMaps { project: ReadonlyMap; model: ReadonlyMap; agent: ReadonlyMap; + branch: ReadonlyMap; } export function usageChartColorMaps( @@ -17,6 +19,7 @@ export function usageChartColorMaps( const projects = new Set(); const models = new Set(); const agents = new Set(); + const branches = new Set(); for (const item of summary?.projectTotals ?? []) { projects.add(item.project_key); @@ -27,6 +30,12 @@ export function usageChartColorMaps( for (const item of summary?.agentTotals ?? []) { agents.add(item.agent); } + for (const item of summary?.branchTotals ?? []) { + branches.add(branchFilterToken( + item.project_key || item.project, + item.branch, + )); + } for (const day of summary?.daily ?? []) { for (const item of day.projectBreakdowns ?? []) { projects.add(item.project_key); @@ -37,11 +46,18 @@ export function usageChartColorMaps( for (const item of day.agentBreakdowns ?? []) { agents.add(item.agent); } + for (const item of day.branchBreakdowns ?? []) { + branches.add(branchFilterToken( + item.project_key || item.project, + item.branch, + )); + } } return { project: chartSeriesColorMap([...projects].sort(), palette), model: chartSeriesColorMap([...models].sort(), palette), agent: chartSeriesColorMap([...agents].sort(), palette), + branch: chartSeriesColorMap([...branches].sort(), palette), }; } diff --git a/internal/activity/activity.go b/internal/activity/activity.go index beb4f4457..8801e304c 100644 --- a/internal/activity/activity.go +++ b/internal/activity/activity.go @@ -36,6 +36,7 @@ type SessionMeta struct { Project string Agent string Machine string + GitBranch string // "" when unknown StartedAt string // RFC3339 or "" EndedAt string // RFC3339 or "" IsAutomated bool // automated (e.g. roborev) vs interactive session @@ -159,6 +160,7 @@ type Report struct { ByProject []KeyMinutes `json:"by_project"` ByModel []KeyMinutes `json:"by_model"` ByAgent []KeyMinutes `json:"by_agent"` + ByBranch []BranchKeyMinutes `json:"by_branch"` BySession []SessionRow `json:"by_session"` Intervals []ReportInterval `json:"intervals"` } @@ -174,6 +176,11 @@ func SanitizeProjectLabels( report.ByProject[i].Key, ) } + for i := range report.ByBranch { + raw := report.ByBranch[i].Project + report.ByBranch[i].ProjectKey = export.ProjectKeyForEntry(projects[raw]) + report.ByBranch[i].Project = export.SafeProjectDisplayLabel(raw) + } for i := range report.BySession { title := export.SafeProjectDisplayLabel(report.BySession[i].Title) if title == "" { @@ -255,6 +262,20 @@ type KeyMinutes struct { InteractiveCost money.Money `json:"interactive_cost"` } +// BranchKeyMinutes is one (project, branch) breakdown row, matching +// db.BranchBreakdown/service.BranchTotal's typed Project/Branch fields. +type BranchKeyMinutes struct { + ProjectKey string `json:"project_key"` + Project string `json:"project"` + Branch string `json:"branch"` + AgentMinutes float64 `json:"agent_minutes"` + Cost money.Money `json:"cost"` + AutomatedAgentMinutes float64 `json:"automated_agent_minutes"` + InteractiveAgentMinutes float64 `json:"interactive_agent_minutes"` + AutomatedCost money.Money `json:"automated_cost"` + InteractiveCost money.Money `json:"interactive_cost"` +} + type SessionRow struct { SessionID string `json:"session_id"` ProjectKey string `json:"project_key"` @@ -868,9 +889,11 @@ func buildSessionsTable(r *Report, start, end, effEnd time.Time, byProject := map[string]*keyAgg{} byAgent := map[string]*keyAgg{} byModel := map[string]*keyAgg{} + byBranch := map[branchPair]*keyAgg{} r.BySession = make([]SessionRow, 0, len(sessions)) for _, s := range sessions { au := s.IsAutomated + branchKey := branchPair{Project: s.Project, Branch: s.GitBranch} if au { r.Totals.AutomatedSessions++ } else { @@ -895,6 +918,9 @@ func buildSessionsTable(r *Report, start, end, effEnd time.Time, if err := addKey(byAgent, s.Agent, mins, money.Money{}, au); err != nil { return fmt.Errorf("summing activity agent minutes: %w", err) } + if err := addKey(byBranch, branchKey, mins, money.Money{}, au); err != nil { + return fmt.Errorf("summing activity branch minutes: %w", err) + } for m, mm := range a.modelMins { if err := addKey(byModel, m, mm, money.Money{}, au); err != nil { return fmt.Errorf("summing activity model minutes: %w", err) @@ -917,6 +943,9 @@ func buildSessionsTable(r *Report, start, end, effEnd time.Time, if err := addKey(byAgent, s.Agent, 0, c.cost, au); err != nil { return fmt.Errorf("summing activity agent cost: %w", err) } + if err := addKey(byBranch, branchKey, 0, c.cost, au); err != nil { + return fmt.Errorf("summing activity branch cost: %w", err) + } for m, mc := range c.models { if err := addKey(byModel, m, 0, mc, au); err != nil { return fmt.Errorf("summing activity model cost: %w", err) @@ -936,10 +965,16 @@ func buildSessionsTable(r *Report, start, end, effEnd time.Time, r.Totals.DistinctModels = len(modelSet) r.ByProject = breakdownRows(byProject, false) r.ByAgent = breakdownRows(byAgent, false) + r.ByBranch = branchBreakdownRows(byBranch) r.ByModel = breakdownRows(byModel, true) return nil } +type branchPair struct { + Project string + Branch string +} + // keyAgg accumulates a breakdown key's combined agent-minutes and cost plus the // automated/interactive split of each. Minutes come from timed intervals; cost // from deduped usage (all sessions, timed or not). @@ -954,8 +989,8 @@ type keyAgg struct { // addKey accumulates minutes and cost into the key's aggregate, routing the // values into the automated or interactive segment by the session's class. -func addKey( - m map[string]*keyAgg, key string, minutes float64, cost money.Money, +func addKey[K comparable]( + m map[K]*keyAgg, key K, minutes float64, cost money.Money, automated bool, ) error { a := m[key] @@ -1011,6 +1046,35 @@ func breakdownRows(m map[string]*keyAgg, dropModelKeys bool) []KeyMinutes { return out } +func branchBreakdownRows(m map[branchPair]*keyAgg) []BranchKeyMinutes { + out := make([]BranchKeyMinutes, 0, len(m)) + for k, v := range m { + if v.minutes == 0 && v.cost.Microdollars == 0 { + continue + } + out = append(out, BranchKeyMinutes{ + Project: k.Project, + Branch: k.Branch, + AgentMinutes: v.minutes, + Cost: v.cost, + AutomatedAgentMinutes: v.autoMinutes, + InteractiveAgentMinutes: v.interMinutes, + AutomatedCost: v.autoCost, + InteractiveCost: v.interCost, + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].AgentMinutes != out[j].AgentMinutes { + return out[i].AgentMinutes > out[j].AgentMinutes + } + if out[i].Project != out[j].Project { + return out[i].Project < out[j].Project + } + return out[i].Branch < out[j].Branch + }) + return out +} + func minutesOf(s SessionRow) float64 { if s.AgentMinutes == nil { return -1 diff --git a/internal/activity/branch_rollup_test.go b/internal/activity/branch_rollup_test.go new file mode 100644 index 000000000..2a277627e --- /dev/null +++ b/internal/activity/branch_rollup_test.go @@ -0,0 +1,45 @@ +package activity + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/money" +) + +func TestSessionsTable_ByBranch(t *testing.T) { + p := baseParams(t, "2026-06-16", "UTC") + sessions := []SessionMeta{ + {SessionID: "a", Project: "proj1", GitBranch: "main", Agent: "claude"}, + {SessionID: "b", Project: "proj1", GitBranch: "feature-x", Agent: "claude"}, + // Same branch name as "a" but a different project: the (project, branch) + // grain keeps them in separate buckets. + {SessionID: "c", Project: "proj2", GitBranch: "main", Agent: "claude"}, + {SessionID: "d", Project: "proj1", GitBranch: "", Agent: "claude"}, + {SessionID: "e", Project: "proj1", GitBranch: "unknown", Agent: "claude"}, + } + usage := []UsageRow{ + {SessionID: "a", Model: "m", Timestamp: "2026-06-16T11:00:00Z", Cost: money.MustParseDollars("1"), UsageDedupKey: "ka"}, + {SessionID: "b", Model: "m", Timestamp: "2026-06-16T11:00:00Z", Cost: money.MustParseDollars("2"), UsageDedupKey: "kb"}, + {SessionID: "c", Model: "m", Timestamp: "2026-06-16T11:00:00Z", Cost: money.MustParseDollars("3"), UsageDedupKey: "kc"}, + {SessionID: "d", Model: "m", Timestamp: "2026-06-16T11:00:00Z", Cost: money.MustParseDollars("4"), UsageDedupKey: "kd"}, + {SessionID: "e", Model: "m", Timestamp: "2026-06-16T11:00:00Z", Cost: money.MustParseDollars("5"), UsageDedupKey: "ke"}, + } + r, err := Aggregate(p, sessions, nil, usage) + require.NoError(t, err) + + byBranch := map[branchPair]BranchKeyMinutes{} + for _, b := range r.ByBranch { + byBranch[branchPair{Project: b.Project, Branch: b.Branch}] = b + } + require.Len(t, r.ByBranch, 5, "one bucket per distinct (project, branch)") + assert.Equal(t, money.MustParseDollars("1"), byBranch[branchPair{"proj1", "main"}].Cost) + assert.Equal(t, money.MustParseDollars("2"), byBranch[branchPair{"proj1", "feature-x"}].Cost) + assert.Equal(t, money.MustParseDollars("3"), byBranch[branchPair{"proj2", "main"}].Cost, + "proj2/main is distinct from proj1/main") + assert.Equal(t, money.MustParseDollars("4"), byBranch[branchPair{"proj1", ""}].Cost, + "empty branch stays distinct from a branch named unknown") + assert.Equal(t, money.MustParseDollars("5"), byBranch[branchPair{"proj1", "unknown"}].Cost) +} diff --git a/internal/activity/parity_pgtest_test.go b/internal/activity/parity_pgtest_test.go index 6e9fea0aa..e579cf20d 100644 --- a/internal/activity/parity_pgtest_test.go +++ b/internal/activity/parity_pgtest_test.go @@ -52,9 +52,10 @@ const paritySchema = "agentsview_daily_report_parity_test" // the parity fixture. Assistant messages carry token_usage JSON so cost is // exercised through the message-source usage path (no usage_events needed). type parityFixtureSession struct { - id string - project string - model string + id string + project string + gitBranch string + model string // events is an ordered (role, RFC3339-timestamp) list. Assistant rows get // token_usage with the given outputTokens so they contribute cost. events []parityEvent @@ -96,7 +97,7 @@ type parityEvent struct { func parityFixture() []parityFixtureSession { return []parityFixtureSession{ { - id: "parity-a", project: "alpha", model: "model-x", + id: "parity-a", project: "alpha", gitBranch: "main", model: "model-x", outputTokens: 1200, events: []parityEvent{ {role: "user", ts: parityDate + "T10:00:00Z"}, @@ -106,7 +107,9 @@ func parityFixture() []parityFixtureSession { }, }, { - id: "parity-b", project: "beta", model: "model-y", + // Same branch name "main" as parity-a but a different project; the + // (project, branch) grain must keep them in separate buckets. + id: "parity-b", project: "beta", gitBranch: "main", model: "model-y", outputTokens: 800, events: []parityEvent{ {role: "user", ts: parityDate + "T10:01:00Z"}, @@ -116,7 +119,8 @@ func parityFixture() []parityFixtureSession { }, }, { - id: "parity-c", project: "alpha", model: "model-x", + // Second branch within alpha, so a project carries multiple branches. + id: "parity-c", project: "alpha", gitBranch: "feature-x", model: "model-x", outputTokens: 300, events: []parityEvent{ {role: "user", ts: parityDate + "T14:00:00Z"}, @@ -242,6 +246,7 @@ func paritySessionWrite(fs parityFixtureSession) db.SessionBatchWrite { sess := db.Session{ ID: fs.id, Project: fs.project, + GitBranch: fs.gitBranch, Machine: "local", Agent: "claude", FirstMessage: &firstMsg, @@ -384,6 +389,12 @@ func canonicalizeReport(r *activity.Report) { sort.Slice(r.ByAgent, func(i, j int) bool { return r.ByAgent[i].Key < r.ByAgent[j].Key }) + sort.Slice(r.ByBranch, func(i, j int) bool { + if r.ByBranch[i].Project != r.ByBranch[j].Project { + return r.ByBranch[i].Project < r.ByBranch[j].Project + } + return r.ByBranch[i].Branch < r.ByBranch[j].Branch + }) sort.Slice(r.BySession, func(i, j int) bool { return r.BySession[i].SessionID < r.BySession[j].SessionID }) @@ -528,4 +539,21 @@ func assertDayMinuteFixtureSanity(t *testing.T, r activity.Report) { "the later fractional duplicate is dropped") require.Equal(t, "model-x", bySession["parity-f"].PrimaryModel, "zero-cost usage still reports its known model as primary") + + // Empty branches survive only when they carry minutes or cost. + type branchKey struct{ project, branch string } + byBranch := map[branchKey]activity.BranchKeyMinutes{} + for _, b := range r.ByBranch { + byBranch[branchKey{b.Project, b.Branch}] = b + } + require.Contains(t, byBranch, branchKey{"alpha", "main"}, + "alpha/main bucket") + require.Contains(t, byBranch, branchKey{"beta", "main"}, + "beta/main is distinct from alpha/main under the (project, branch) grain") + require.Contains(t, byBranch, branchKey{"alpha", "feature-x"}, + "a project can carry multiple branches") + require.Contains(t, byBranch, branchKey{"gamma", ""}, + "empty branch bucket") + require.NotContains(t, byBranch, branchKey{"delta", ""}, + "zero-cost zero-minute branch bucket is dropped like any zero row") } diff --git a/internal/activity/sessions_test.go b/internal/activity/sessions_test.go index 321a88c00..0319fbf33 100644 --- a/internal/activity/sessions_test.go +++ b/internal/activity/sessions_test.go @@ -10,6 +10,29 @@ import ( "github.com/stretchr/testify/require" ) +func TestSanitizeProjectLabelsKeepsCollidingBranchProjectsDistinct(t *testing.T) { + first := "/Users/example/one/private/repo" + second := "/Users/example/two/private/repo" + report := Report{ByBranch: []BranchKeyMinutes{ + {Project: first, Branch: "main", Cost: money.MustParseDollars("1")}, + {Project: second, Branch: "main", Cost: money.MustParseDollars("2")}, + }} + projects := map[string]export.ProjectMapEntry{ + first: {ProjectKey: "pl1:sha256:first"}, + second: {ProjectKey: "pl1:sha256:second"}, + } + + SanitizeProjectLabels(&report, projects) + + require.Len(t, report.ByBranch, 2) + assert.Equal(t, "pl1:sha256:first", report.ByBranch[0].ProjectKey) + assert.Equal(t, "pl1:sha256:second", report.ByBranch[1].ProjectKey) + assert.Empty(t, report.ByBranch[0].Project) + assert.Empty(t, report.ByBranch[1].Project) + assert.Equal(t, "main", report.ByBranch[0].Branch) + assert.Equal(t, "main", report.ByBranch[1].Branch) +} + func TestSanitizeProjectLabelsSanitizesSessionTitles(t *testing.T) { report := Report{BySession: []SessionRow{ {SessionID: "path", Title: "/Users/alice/private/repo", Project: "/Users/alice/private/repo"}, diff --git a/internal/db/activityreport.go b/internal/db/activityreport.go index b3a252a23..0feee38e6 100644 --- a/internal/db/activityreport.go +++ b/internal/db/activityreport.go @@ -301,7 +301,8 @@ func (db *DB) activityReportSessionsFrom( s.machine, COALESCE(s.started_at, ''), COALESCE(s.ended_at, ''), - COALESCE(s.is_automated, 0) + COALESCE(s.is_automated, 0), + s.git_branch FROM sessions s WHERE ` + where + ` AND COALESCE(NULLIF(s.ended_at, ''), @@ -324,6 +325,7 @@ func (db *DB) activityReportSessionsFrom( if err := rows.Scan( &s.SessionID, &s.Title, &s.Project, &s.Agent, &s.Machine, &s.StartedAt, &s.EndedAt, &s.IsAutomated, + &s.GitBranch, ); err != nil { return nil, nil, fmt.Errorf( "scanning activity report session: %w", err) @@ -497,7 +499,9 @@ func (db *DB) loadActivityReportUsageCandidatesFrom( rowsSQL := dailyUsageRowsSQLWithWhere( usageMessageEligibility+" AND m.session_id IN "+ph, usageEventEligibility+" AND ue.session_id IN "+ph) - query := dailyUsageRowSelectFromRowsWithMachine(rowsSQL, true) + ` + query := dailyUsageRowSelectFromRowsWithBreakdowns( + rowsSQL, true, false, + ) + ` AND u.ts >= ? AND u.ts <= ?` args := make([]any, 0, len(chunkArgs)*2+2) @@ -512,7 +516,7 @@ func (db *DB) loadActivityReportUsageCandidatesFrom( defer rows.Close() for rows.Next() { - r, scanErr := scanDailyUsageRowWithMachine(rows, true) + r, scanErr := scanDailyUsageRowWithBreakdowns(rows, true, false) if scanErr != nil { return fmt.Errorf( "scanning activity report usage: %w", scanErr) diff --git a/internal/db/branch_filter_test.go b/internal/db/branch_filter_test.go index 06f070530..7c2bafefa 100644 --- a/internal/db/branch_filter_test.go +++ b/internal/db/branch_filter_test.go @@ -2,8 +2,12 @@ package db import ( "context" + "strconv" "testing" + "go.kenn.io/agentsview/internal/export" + "go.kenn.io/agentsview/internal/money" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -16,10 +20,11 @@ func branchInfoForTest(project, branch string) BranchInfo { } } -func TestGetDailyUsageGitBranchFilter(t *testing.T) { - d := testDB(t) - ctx := context.Background() - +// seedBranchUsageFixture seeds "" and "unknown" as distinct branch buckets. +// Shared by TestGetDailyUsageBranchBreakdowns and TestGetDailyUsageGitBranchFilter +// so it only needs updating in one place. +func seedBranchUsageFixture(t *testing.T, d *DB) { + t.Helper() seed := []struct { id, project, branch string input, output int @@ -46,6 +51,73 @@ func TestGetDailyUsageGitBranchFilter(t *testing.T) { DedupKey: s.id + "-key", }}), "replace usage event for %s", s.id) } +} + +func TestSanitizeDailyUsageProjectLabelsKeepsBranchIdentity(t *testing.T) { + first := "/Users/example/one/private/repo" + second := "/Users/example/two/private/repo" + result := DailyUsageResult{Daily: []DailyUsageEntry{{ + BranchBreakdowns: []BranchBreakdown{ + {Project: first, Branch: "main", Cost: money.MustParseDollars("1")}, + {Project: second, Branch: "main", Cost: money.MustParseDollars("2")}, + }, + }}} + projects := map[string]export.ProjectMapEntry{ + first: {ProjectKey: "pl1:sha256:first"}, + second: {ProjectKey: "pl1:sha256:second"}, + } + + SanitizeDailyUsageProjectLabelsWithCatalog(&result, projects) + + require.Len(t, result.Daily, 1) + require.Len(t, result.Daily[0].BranchBreakdowns, 2) + assert.Equal(t, "pl1:sha256:first", result.Daily[0].BranchBreakdowns[0].ProjectKey) + assert.Equal(t, "pl1:sha256:second", result.Daily[0].BranchBreakdowns[1].ProjectKey) + assert.Empty(t, result.Daily[0].BranchBreakdowns[0].Project) + assert.Empty(t, result.Daily[0].BranchBreakdowns[1].Project) +} + +func TestGetDailyUsageBranchBreakdowns(t *testing.T) { + d := testDB(t) + ctx := context.Background() + seedBranchUsageFixture(t, d) + + withoutBranches, err := d.GetDailyUsage(ctx, UsageFilter{ + From: "2026-05-14", + To: "2026-05-14", + Breakdowns: true, + }) + require.NoError(t, err, "GetDailyUsage without branch breakdowns") + require.Len(t, withoutBranches.Daily, 1, "one day") + assert.Empty(t, withoutBranches.Daily[0].BranchBreakdowns) + assert.NotEmpty(t, withoutBranches.Daily[0].ProjectBreakdowns) + + daily, err := d.GetDailyUsage(ctx, UsageFilter{ + From: "2026-05-14", + To: "2026-05-14", + Breakdowns: true, + BranchBreakdowns: true, + }) + require.NoError(t, err, "GetDailyUsage") + require.Len(t, daily.Daily, 1, "one day") + assert.Equal(t, withoutBranches.Totals, daily.Totals) + + byKey := map[BranchInfo]BranchBreakdown{} + for _, b := range daily.Daily[0].BranchBreakdowns { + byKey[BranchInfo{Project: b.Project, Branch: b.Branch}] = b + } + require.Len(t, byKey, 5, "one bucket per distinct (project, branch)") + assert.Equal(t, 100, byKey[BranchInfo{Project: "proj-a", Branch: "main"}].InputTokens) + assert.Equal(t, 200, byKey[BranchInfo{Project: "proj-a", Branch: "feature-x"}].InputTokens) + assert.Equal(t, 300, byKey[BranchInfo{Project: "proj-b", Branch: "main"}].InputTokens) + assert.Equal(t, 400, byKey[BranchInfo{Project: "proj-a", Branch: ""}].InputTokens) + assert.Equal(t, 500, byKey[BranchInfo{Project: "proj-a", Branch: "unknown"}].InputTokens) +} + +func TestGetDailyUsageGitBranchFilter(t *testing.T) { + d := testDB(t) + ctx := context.Background() + seedBranchUsageFixture(t, d) daily, err := d.GetDailyUsage(ctx, UsageFilter{ From: "2026-05-14", @@ -58,6 +130,87 @@ func TestGetDailyUsageGitBranchFilter(t *testing.T) { "usage filter uses scoped (project, branch), not branch name alone") } +// API contract: a git_branch value whose tokens all lack the pair +// separator decodes to an empty pair set and fails closed to zero rows. +// The frontend relies on this by sending a deliberately separator-less +// token when the sidebar branch filter and the usage page's local +// selection have no overlap, so the disjoint filters must yield an +// empty result rather than an error or unfiltered totals. +func TestGetDailyUsageMalformedGitBranchTokenFailsClosed(t *testing.T) { + d := testDB(t) + ctx := context.Background() + seedBranchUsageFixture(t, d) + + daily, err := d.GetDailyUsage(ctx, UsageFilter{ + From: "2026-05-14", + To: "2026-05-14", + GitBranch: NoBranchMatchToken, + }) + require.NoError(t, err, "GetDailyUsage") + assert.Empty(t, daily.Daily, + "separator-less branch token must fail closed, not broaden") + assert.Zero(t, daily.Totals.InputTokens) + assert.Zero(t, daily.Totals.TotalCost) +} + +func TestGetDailyUsageExcludeGitBranchFilter(t *testing.T) { + tests := []struct { + name string + gitBranch string + excludeGitBranch string + wantInput int + }{ + { + name: "single pair excluded", + excludeGitBranch: EncodeBranchFilterToken("proj-a", "main"), + wantInput: 1400, + }, + { + name: "multiple pairs excluded", + excludeGitBranch: encodeBranchFilterTokensForTest( + BranchInfo{Project: "proj-a", Branch: "main"}, + BranchInfo{Project: "proj-b", Branch: "main"}, + ), + wantInput: 1100, + }, + { + name: "same-named branch in another project stays", + excludeGitBranch: EncodeBranchFilterToken("proj-b", "main"), + wantInput: 1200, + }, + { + name: "malformed token excludes nothing", + excludeGitBranch: "no-separator-here", + wantInput: 1500, + }, + { + name: "combined with include filter", + gitBranch: encodeBranchFilterTokensForTest( + BranchInfo{Project: "proj-a", Branch: "main"}, + BranchInfo{Project: "proj-a", Branch: "feature-x"}, + ), + excludeGitBranch: EncodeBranchFilterToken("proj-a", "main"), + wantInput: 200, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := testDB(t) + seedBranchUsageFixture(t, d) + + daily, err := d.GetDailyUsage(context.Background(), UsageFilter{ + From: "2026-05-14", + To: "2026-05-14", + GitBranch: tt.gitBranch, + ExcludeGitBranch: tt.excludeGitBranch, + }) + require.NoError(t, err, "GetDailyUsage") + require.Len(t, daily.Daily, 1, "one day") + assert.Equal(t, tt.wantInput, daily.Daily[0].InputTokens) + }) + } +} + func TestSplitBranchFilterTokens(t *testing.T) { tests := []struct { name string @@ -104,46 +257,140 @@ func TestSplitBranchFilterTokens(t *testing.T) { } } +func TestBranchFilterToken(t *testing.T) { + tok, err := BranchFilterToken("proj", "") + require.NoError(t, err) + assert.Empty(t, tok, "empty branch means no filter") + + _, err = BranchFilterToken("", "main") + assert.ErrorIs(t, err, ErrBranchWithoutProject) + + tok, err = BranchFilterToken("proj", "main") + require.NoError(t, err) + assert.Equal(t, EncodeBranchFilterToken("proj", "main"), tok) +} + func TestGetBranches(t *testing.T) { d := testDB(t) insertSession(t, d, "s1", "alpha", func(s *Session) { s.GitBranch = "main" s.UserMessageCount = 5 + s.EndedAt = new("2026-06-10T10:00:00Z") + }) + // Older session on the same pair: MAX() keeps alpha/main at its most + // recent activity, not this one. + insertSession(t, d, "s1-old", "alpha", func(s *Session) { + s.GitBranch = "main" + s.UserMessageCount = 5 + s.EndedAt = new("2026-06-01T10:00:00Z") }) insertSession(t, d, "s2", "alpha", func(s *Session) { s.GitBranch = "feat/x" s.UserMessageCount = 5 + s.EndedAt = new("2026-06-12T10:00:00Z") }) + // No ended_at: recency falls back to started_at. insertSession(t, d, "s3", "beta", func(s *Session) { s.GitBranch = "main" s.UserMessageCount = 5 + s.StartedAt = new("2026-06-11T10:00:00Z") }) insertSession(t, d, "s4", "alpha", func(s *Session) { s.GitBranch = "" s.UserMessageCount = 5 + s.EndedAt = new("2026-06-08T10:00:00Z") }) + // Same timestamp as s4: the tie breaks alphabetically by pair. insertSession(t, d, "s5", "gamma", func(s *Session) { s.GitBranch = "solo" s.UserMessageCount = 1 + s.EndedAt = new("2026-06-08T10:00:00Z") + }) + + // Subagent/fork-only pair: visible only under BranchScopeAll, so the + // activity/usage filter controls can offer branches their rollups count. + insertSession(t, d, "s6", "delta", func(s *Session) { + s.GitBranch = "fork-only" + s.UserMessageCount = 5 + s.RelationshipType = "fork" + s.EndedAt = new("2026-06-13T10:00:00Z") }) - all, err := d.GetBranches(context.Background(), false, false) + all, err := d.GetBranches(context.Background(), BranchQuery{ + Scope: BranchScopeRoots, + Limit: 100, + }) require.NoError(t, err, "GetBranches includeAll") - assert.Equal(t, []BranchInfo{ - branchInfoForTest("alpha", ""), - branchInfoForTest("alpha", "feat/x"), - branchInfoForTest("alpha", "main"), - branchInfoForTest("beta", "main"), - branchInfoForTest("gamma", "solo"), - }, all, "distinct (project, branch) pairs, ordered, empty branch included") - - filtered, err := d.GetBranches(context.Background(), true, false) + assert.Equal(t, BranchResult{Branches: []BranchOption{ + {Branch: "feat/x"}, + {Branch: "main"}, + {Branch: ""}, + {Branch: "solo"}, + }}, all, "branch names deduplicated by latest activity, empty branch included") + + withForks, err := d.GetBranches(context.Background(), BranchQuery{ + Scope: BranchScopeAll, + Limit: 100, + }) + require.NoError(t, err, "GetBranches scope all") + assert.Contains(t, withForks.Branches, BranchOption{Branch: "fork-only"}, + "fork-only branch included when scope is all") + + filtered, err := d.GetBranches(context.Background(), BranchQuery{ + Scope: BranchScopeRoots, + ExcludeOneShot: true, + Limit: 100, + }) require.NoError(t, err, "GetBranches excludeOneShot") - assert.NotContains(t, filtered, branchInfoForTest("gamma", "solo"), + assert.NotContains(t, filtered.Branches, BranchOption{Branch: "solo"}, "one-shot branch excluded when excludeOneShot is set") } +func TestGetBranchesProjectsSearchAndLimit(t *testing.T) { + d := testDB(t) + seed := func(id, project, branch, endedAt string) { + insertSession(t, d, id, project, func(s *Session) { + s.GitBranch = branch + s.UserMessageCount = 5 + s.EndedAt = new(endedAt) + }) + } + + seed("alpha-new", "alpha", "feature-new", "2026-06-13T10:00:00Z") + seed("beta-last", "beta", "feature-last", "2026-06-11T10:00:00Z") + seed("alpha-shared", "alpha", "feature-shared", "2026-06-10T10:00:00Z") + seed("beta-shared", "beta", "feature-shared", "2026-06-09T10:00:00Z") + seed("gamma-shared", "gamma", "feature-shared", "2026-06-20T10:00:00Z") + seed("beta-miss", "beta", "bugfix", "2026-06-30T10:00:00Z") + seed("project-name-match", "feature-project", "main", "2026-07-01T10:00:00Z") + seed("alpha-empty", "alpha", "", "2026-06-08T10:00:00Z") + + got, err := d.GetBranches(context.Background(), BranchQuery{ + Projects: []string{"alpha", "beta", "feature-project"}, + Search: "FEATURE", + Limit: 2, + }) + require.NoError(t, err) + assert.Equal(t, BranchResult{ + Branches: []BranchOption{{Branch: "feature-new"}, {Branch: "feature-last"}}, + HasMore: true, + }, got, "project filter applies before dedupe and recency ordering") + + empty, err := d.GetBranches(context.Background(), BranchQuery{ + Projects: []string{"alpha"}, + Limit: 100, + }) + require.NoError(t, err) + assert.Contains(t, empty.Branches, BranchOption{Branch: ""}) +} + +func TestNormalizeBranchQueryDefaultsAndCapsLimit(t *testing.T) { + assert.Equal(t, 100, NormalizeBranchQuery(BranchQuery{}).Limit) + assert.Equal(t, 100, NormalizeBranchQuery(BranchQuery{Limit: 101}).Limit) + assert.Equal(t, 1, NormalizeBranchQuery(BranchQuery{Limit: 1}).Limit) +} + func TestSessionFilterGitBranchComposite(t *testing.T) { d := testDB(t) @@ -184,3 +431,40 @@ func TestSessionFilterGitBranchComposite(t *testing.T) { GitBranch: EncodeBranchFilterToken("alpha", "unknown"), }, []string{"alpha-unknown"}) } + +// A usage-page deselect-all excludes every known (project, branch) +// pair; over ~1000 pairs a flat OR chain exceeds SQLite's expression +// depth limit ("Expression tree is too large"), so the predicate must +// nest as a balanced tree. Exercised end-to-end through GetDailyUsage +// on both the include and exclude sides. +func TestGetDailyUsageManyBranchPairs(t *testing.T) { + d := testDB(t) + ctx := context.Background() + seedBranchUsageFixture(t, d) + + tokens := make([]BranchInfo, 0, 1500) + for i := range 1500 { + tokens = append(tokens, BranchInfo{ + Project: "proj-a", + Branch: "branch-" + strconv.Itoa(i), + }) + } + tokens = append(tokens, branchInfoForTest("proj-a", "main")) + list := encodeBranchFilterTokensForTest(tokens...) + + included, err := d.GetDailyUsage(ctx, UsageFilter{ + From: "2026-05-14", To: "2026-05-14", + GitBranch: list, + }) + require.NoError(t, err) + assert.Equal(t, 110, included.Totals.InputTokens+included.Totals.OutputTokens, + "include list should match only (proj-a, main)") + + excluded, err := d.GetDailyUsage(ctx, UsageFilter{ + From: "2026-05-14", To: "2026-05-14", + ExcludeGitBranch: list, + }) + require.NoError(t, err) + assert.Equal(t, 1540, excluded.Totals.InputTokens+excluded.Totals.OutputTokens, + "exclude list should drop only (proj-a, main)") +} diff --git a/internal/db/insights_test.go b/internal/db/insights_test.go index 7726900b7..0b0478740 100644 --- a/internal/db/insights_test.go +++ b/internal/db/insights_test.go @@ -68,9 +68,7 @@ func TestInsights_CannedMetadataAndCacheLookup(t *testing.T) { if err != nil { t.Fatalf("GetCachedInsight: %v", err) } - if got == nil { - t.Fatal("expected cached insight") - } + require.NotNil(t, got, "expected cached insight") if got.ID != id || got.Kind != "prompt_maturity_review" || got.SchemaVersion != "llm_insight.v1" || got.ProvenanceJSON == "" || got.StructuredJSON == "" { diff --git a/internal/db/messages.go b/internal/db/messages.go index 7e070b4ae..f8016cc46 100644 --- a/internal/db/messages.go +++ b/internal/db/messages.go @@ -10,6 +10,7 @@ import ( "log" "slices" "strings" + "testing" "time" "unicode" "unicode/utf8" @@ -902,6 +903,18 @@ func insertToolResultEventsTx( const slowOpThreshold = 100 * time.Millisecond +// logSlowOp reports a write that exceeded slowOpThreshold. Suppressed +// under `go test`: the testing framework prints each benchmark result +// line in two halves around the timed run, so a slow-op line emitted +// in between splices into the result line and corrupts the capture +// that cmd/benchgate parses. +func logSlowOp(format string, args ...any) { + if testing.Testing() { + return + } + log.Printf(format, args...) +} + // InsertMessages batch-inserts messages for a session. func (db *DB) InsertMessages(msgs []Message) error { if err := db.requireWritable(); err != nil { @@ -913,7 +926,7 @@ func (db *DB) InsertMessages(msgs []Message) error { t := time.Now() defer func() { if d := time.Since(t); d > slowOpThreshold { - log.Printf( + logSlowOp( "db: InsertMessages (%d msgs): %s", len(msgs), d.Round(time.Millisecond), ) @@ -1003,7 +1016,7 @@ func (db *DB) WriteSessionIncremental( t := time.Now() defer func() { if d := time.Since(t); d > slowOpThreshold { - log.Printf( + logSlowOp( "db: WriteSessionIncremental (%d msgs): %s", len(msgs), d.Round(time.Millisecond), ) @@ -1132,7 +1145,7 @@ func (db *DB) ReplaceSessionMessages( t := time.Now() defer func() { if d := time.Since(t); d > slowOpThreshold { - log.Printf( + logSlowOp( "db: ReplaceSessionMessages %s (%d msgs): %s", sessionID, len(msgs), d.Round(time.Millisecond), diff --git a/internal/db/query_dialect.go b/internal/db/query_dialect.go index 2fcb8a4db..4a7669ef6 100644 --- a/internal/db/query_dialect.go +++ b/internal/db/query_dialect.go @@ -1,6 +1,7 @@ package db import ( + "errors" "fmt" "regexp" "strings" @@ -83,7 +84,7 @@ func SQLiteQueryDialect() QueryDialect { }, dateParam: func(ph string) string { return "julianday(" + ph + ")" }, activityParam: func(ph string) string { return ph }, - cursorActivityExpr: "COALESCE(NULLIF(ended_at, ''), NULLIF(started_at, ''), created_at)", + cursorActivityExpr: activityCoalesceSQLite, cursorParam: func(ph string) string { return ph }, castCursor: func(ph string, _ valueKind) string { return ph }, emptyStringIsNull: true, @@ -762,6 +763,13 @@ func splitCSV(s string) []string { const ( branchFilterSep = "\x1f" branchListSep = "\x1e" + + // NoBranchFilterToken is the plain branch-name filter value for sessions + // whose branch is empty. Empty itself means no active filter in URLs/stores. + NoBranchFilterToken = "\x1dno_branch" + // NoBranchMatchToken is the explicit fail-closed value used when two active + // inclusion filters have no overlap. + NoBranchMatchToken = "\x1dno_branch_match" ) // EncodeBranchFilterToken builds the opaque (project, branch) filter token. @@ -771,6 +779,23 @@ func EncodeBranchFilterToken(project, branch string) string { return project + branchFilterSep + branch } +// ErrBranchWithoutProject flags a branch filter with no project to scope it. +// Surfaces translate it into their own parameter wording. +var ErrBranchWithoutProject = errors.New("branch filter requires a project") + +// BranchFilterToken encodes a plain (project, branch) pair into a filter +// token, or "" (no error) if branch is empty. Errors with +// ErrBranchWithoutProject if branch is set but project is not. +func BranchFilterToken(project, branch string) (string, error) { + if branch == "" { + return "", nil + } + if project == "" { + return "", ErrBranchWithoutProject + } + return EncodeBranchFilterToken(project, branch), nil +} + // SplitBranchFilterTokens decodes a branchListSep-joined list of // EncodeBranchFilterToken values into (project, branch) pairs, dropping blank or // separator-less tokens. Shared across backends so they decode identically. @@ -791,25 +816,75 @@ func SplitBranchFilterTokens(s string) []BranchInfo { return out } -// BranchPairPredicate uses OR-of-ANDs instead of row-value IN for backend -// portability. An empty decoded pair set returns false so invalid filters do -// not broaden to all rows. +// RewriteQualifiedBranchFilterProjects rewrites only the project component of +// project-qualified branch tokens. Plain branch names and sentinel values pass +// through unchanged. +func RewriteQualifiedBranchFilterProjects( + tokens string, + rewrite func(string) (string, error), +) (string, error) { + parts := strings.Split(tokens, branchListSep) + for i, token := range parts { + project, branch, qualified := strings.Cut(token, branchFilterSep) + if !qualified { + continue + } + rewritten, err := rewrite(project) + if err != nil { + return "", err + } + parts[i] = EncodeBranchFilterToken(rewritten, branch) + } + return strings.Join(parts, branchListSep), nil +} + +// BranchPairPredicate accepts current branch-name values and legacy +// (project, branch) tokens. Plain branch names match across the separately +// applied project filter; legacy tokens retain old shared URLs precisely. +// The explicit no-match sentinel fails closed instead of matching a real name. func BranchPairPredicate( projectCol, branchCol, tokens string, placeholder func(string) string, ) string { - pairs := SplitBranchFilterTokens(tokens) - if len(pairs) == 0 { - return "1 = 0" + for token := range strings.SplitSeq(tokens, branchListSep) { + if token == NoBranchMatchToken { + return "1 = 0" + } } - parts := make([]string, len(pairs)) - for i, p := range pairs { - parts[i] = "(" + projectCol + " = " + placeholder(p.Project) + - " AND " + branchCol + " = " + placeholder(p.Branch) + ")" + + parts := make([]string, 0, strings.Count(tokens, branchListSep)+1) + for token := range strings.SplitSeq(tokens, branchListSep) { + if token == "" { + continue + } + if token == NoBranchFilterToken { + parts = append(parts, branchCol+" = "+placeholder("")) + continue + } + project, branch, legacy := strings.Cut(token, branchFilterSep) + if legacy { + parts = append(parts, "("+projectCol+" = "+placeholder(project)+ + " AND "+branchCol+" = "+placeholder(branch)+")") + continue + } + parts = append(parts, branchCol+" = "+placeholder(token)) + } + if len(parts) == 0 { + return "1 = 0" } + return orTree(parts) +} + +// orTree ORs predicates together, nesting them as a balanced tree so +// the parse depth stays logarithmic. A flat OR chain parses left-deep, +// and SQLite caps expression nesting at 1000 — which a branch filter +// can exceed (deselect-all in the usage branch dropdown excludes every +// known (project, branch) pair). +func orTree(parts []string) string { if len(parts) == 1 { return parts[0] } - return "(" + strings.Join(parts, " OR ") + ")" + mid := len(parts) / 2 + return "(" + orTree(parts[:mid]) + " OR " + orTree(parts[mid:]) + ")" } // BranchPairClauseArgs is the raw-args ("?" placeholder) form of @@ -826,6 +901,32 @@ func BranchPairClauseArgs( return clause, args } +// BranchPairExcludePredicate is the exclude-filter counterpart to +// BranchPairPredicate: matches rows whose (project, branch) pair is none of +// the given tokens. An empty or fully malformed token list excludes nothing +// (NOT of the include side's fail-closed "1 = 0") — omitting an exclude +// filter must never narrow results. +func BranchPairExcludePredicate( + projectCol, branchCol, tokens string, placeholder func(string) string, +) string { + return "NOT (" + + BranchPairPredicate(projectCol, branchCol, tokens, placeholder) + ")" +} + +// BranchPairExcludeClauseArgs is the raw-args ("?" placeholder) form of +// BranchPairExcludePredicate. +func BranchPairExcludeClauseArgs( + projectCol, branchCol, tokens string, args []any, +) (string, []any) { + clause := BranchPairExcludePredicate( + projectCol, branchCol, tokens, + func(v string) string { + args = append(args, v) + return "?" + }) + return clause, args +} + func nonEmpty(values []string) []string { out := make([]string, 0, len(values)) for _, v := range values { diff --git a/internal/db/query_dialect_test.go b/internal/db/query_dialect_test.go index 69459a491..6a4ca462c 100644 --- a/internal/db/query_dialect_test.go +++ b/internal/db/query_dialect_test.go @@ -300,6 +300,52 @@ func TestBranchPairClauseArgsKeepsEmptyBranchDistinct(t *testing.T) { assert.Equal(t, []any{"alpha", "", "alpha", "unknown"}, args) } +func TestBranchPairClauseArgsAcceptsBranchNamesAndLegacyPairs(t *testing.T) { + tokens := "main" + branchListSep + + EncodeBranchFilterToken("alpha", "legacy") + branchListSep + + NoBranchFilterToken + + got, args := BranchPairClauseArgs("project", "git_branch", tokens, nil) + + assert.Equal(t, + "(git_branch = ? OR ((project = ? AND git_branch = ?) OR git_branch = ?))", + normalizeSQL(got)) + assert.Equal(t, []any{"main", "alpha", "legacy", ""}, args) +} + +func TestBranchPairClauseArgsTreatsLegacyPrintableSentinelsAsBranchNames(t *testing.T) { + got, args := BranchPairClauseArgs( + "project", "git_branch", + "__agentsview_no_branch__"+branchListSep+ + "__agentsview_no_branch_match__", + nil, + ) + + assert.Equal(t, "(git_branch = ? OR git_branch = ?)", normalizeSQL(got)) + assert.Equal(t, []any{ + "__agentsview_no_branch__", "__agentsview_no_branch_match__", + }, args) +} + +func TestBranchPairClauseArgsMalformedTokenFailsClosed(t *testing.T) { + got, args := BranchPairClauseArgs( + "project", "git_branch", NoBranchMatchToken, nil, + ) + + assert.Equal(t, "1 = 0", normalizeSQL(got)) + assert.Empty(t, args) +} + +func TestBranchPairClauseArgsNoMatchDiscardsEarlierBindings(t *testing.T) { + got, args := BranchPairClauseArgs( + "project", "git_branch", "main"+branchListSep+NoBranchMatchToken, + []any{"existing"}, + ) + + assert.Equal(t, "1 = 0", normalizeSQL(got)) + assert.Equal(t, []any{"existing"}, args) +} + func TestSessionCursorFragmentsAreParameterized(t *testing.T) { cursor := SessionCursor{ EndedAt: "2026-06-08T12:00:00Z", diff --git a/internal/db/sessions.go b/internal/db/sessions.go index a34370061..d57a6fa6e 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -546,11 +546,14 @@ const activeWindow = 10 * time.Minute // idle duration with an orphan tool call, the session is "unclean". const staleWindow = 60 * time.Minute +const activityCoalesceSQLite = "COALESCE(NULLIF(ended_at, ''), " + + "NULLIF(started_at, ''), created_at)" + // activityExprSQLite computes seconds-since-epoch of the most // recent activity timestamp. Used by both sessions and analytics // filters when classifying by status. const activityExprSQLite = "CAST(strftime('%s', " + - "COALESCE(NULLIF(ended_at, ''), NULLIF(started_at, ''), created_at)) AS INTEGER)" + activityCoalesceSQLite + ") AS INTEGER)" const sidebarActivityExprSQLiteS = "COALESCE(" + "NULLIF(s.ended_at, ''), NULLIF(s.started_at, ''), s.created_at)" @@ -3426,46 +3429,122 @@ type BranchInfo struct { Token string `json:"token"` } -// GetBranches returns distinct (project, git_branch) pairs, including the empty -// branch used for sessions with no recorded branch. Scoping matches -// GetProjects/GetAgents (root sessions with messages) so the dropdown reflects -// real work rather than subagents. +// BranchScope selects which session relationships contribute (project, +// branch) pairs to GetBranches. +type BranchScope int + +const ( + // BranchScopeRoots counts only root sessions, matching + // GetProjects/GetAgents, so sidebar dropdowns reflect real work + // rather than subagents. + BranchScopeRoots BranchScope = iota + // BranchScopeAll also counts subagent and fork sessions, matching the + // activity report and usage aggregation scope, so every branch that + // can appear in those rollups is offered (and un-hidable) in their + // filter controls. + BranchScopeAll +) + +const MaxBranchLimit = 100 + +// BranchQuery controls the branch-name picker query. Project filtering is +// applied before branch names are deduplicated across projects. +type BranchQuery struct { + Projects []string + Search string + Limit int + Scope BranchScope + ExcludeOneShot bool + ExcludeAutomated bool +} + +// BranchOption is one unqualified branch name offered by the picker. +type BranchOption struct { + Branch string `json:"branch"` +} + +// BranchResult is one bounded page of branch picker metadata. +type BranchResult struct { + Branches []BranchOption `json:"branches"` + HasMore bool `json:"has_more"` +} + +// NormalizeBranchQuery applies the picker limit contract consistently across +// storage backends. +func NormalizeBranchQuery(q BranchQuery) BranchQuery { + q.Search = strings.TrimSpace(q.Search) + if q.Limit <= 0 || q.Limit > MaxBranchLimit { + q.Limit = MaxBranchLimit + } + return q +} + +// ScanBranchResult scans a limit+1 query and applies the shared has_more +// truncation contract. Rows must contain only git_branch in result order. +func ScanBranchResult(rows *sql.Rows, q BranchQuery) (BranchResult, error) { + q = NormalizeBranchQuery(q) + branches := []BranchOption{} + for rows.Next() { + var branch BranchOption + if err := rows.Scan(&branch.Branch); err != nil { + return BranchResult{}, fmt.Errorf("scanning branch: %w", err) + } + branches = append(branches, branch) + } + if err := rows.Err(); err != nil { + return BranchResult{}, fmt.Errorf("iterating branches: %w", err) + } + hasMore := len(branches) > q.Limit + if hasMore { + branches = branches[:q.Limit] + } + return BranchResult{Branches: branches, HasMore: hasMore}, nil +} + +// GetBranches returns distinct git_branch values, including the empty branch, +// ordered by the latest matching session activity and then branch name. func (db *DB) GetBranches( - ctx context.Context, - excludeOneShot, excludeAutomated bool, -) ([]BranchInfo, error) { - q := `SELECT DISTINCT project, git_branch + ctx context.Context, query BranchQuery, +) (BranchResult, error) { + query = NormalizeBranchQuery(query) + q := `SELECT git_branch FROM sessions WHERE message_count > 0 - AND relationship_type NOT IN ('subagent', 'fork') AND deleted_at IS NULL` - if excludeOneShot { - if !excludeAutomated { + args := []any{} + if query.Scope == BranchScopeRoots { + q += " AND relationship_type NOT IN ('subagent', 'fork')" + } + if query.ExcludeOneShot { + if !query.ExcludeAutomated { q += " AND (user_message_count > 1 OR is_automated = 1)" } else { q += " AND user_message_count > 1" } } - if excludeAutomated { + if query.ExcludeAutomated { q += " AND is_automated = 0" } - q += " ORDER BY project, git_branch" - rows, err := db.getReader().QueryContext(ctx, q) + if len(query.Projects) > 0 { + placeholders, projectArgs := inPlaceholders(query.Projects) + q += " AND project IN " + placeholders + args = append(args, projectArgs...) + } + if query.Search != "" { + q += ` AND git_branch LIKE ? ESCAPE '\'` + args = append(args, "%"+EscapeLikePattern(query.Search)+"%") + } + q += ` GROUP BY git_branch + ORDER BY MAX(` + activityCoalesceSQLite + `) DESC, + git_branch + LIMIT ?` + args = append(args, query.Limit+1) + rows, err := db.getReader().QueryContext(ctx, q, args...) if err != nil { - return nil, fmt.Errorf("querying branches: %w", err) + return BranchResult{}, fmt.Errorf("querying branches: %w", err) } defer rows.Close() - - branches := []BranchInfo{} - for rows.Next() { - var bi BranchInfo - if err := rows.Scan(&bi.Project, &bi.Branch); err != nil { - return nil, fmt.Errorf("scanning branch: %w", err) - } - bi.Token = EncodeBranchFilterToken(bi.Project, bi.Branch) - branches = append(branches, bi) - } - return branches, rows.Err() + return ScanBranchResult(rows, query) } // scanSessionRows iterates rows and scans each using diff --git a/internal/db/store.go b/internal/db/store.go index e4f05bbe0..2b5570fe0 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -64,7 +64,7 @@ type Store interface { GetActiveProjectLabels(ctx context.Context) ([]string, error) GetAgents(ctx context.Context, excludeOneShot, excludeAutomated bool) ([]AgentInfo, error) GetMachines(ctx context.Context, excludeOneShot, excludeAutomated bool) ([]string, error) - GetBranches(ctx context.Context, excludeOneShot, excludeAutomated bool) ([]BranchInfo, error) + GetBranches(ctx context.Context, q BranchQuery) (BranchResult, error) ListProjectIdentityObservations(ctx context.Context, labels []string) ([]export.ProjectIdentityObservation, error) BuildProjectIdentityMap(ctx context.Context, labels []string) (map[string]export.ProjectMapEntry, error) diff --git a/internal/db/usage.go b/internal/db/usage.go index 83dc370c7..b68f2d024 100644 --- a/internal/db/usage.go +++ b/internal/db/usage.go @@ -64,7 +64,10 @@ type UsageFilter struct { ProjectLabels []string ExcludeProjectLabels []string // GitBranch is a branchListSep-joined list of opaque (project, branch) tokens (EncodeBranchFilterToken). - GitBranch string + GitBranch string + // ExcludeGitBranch is a branchListSep-joined list of opaque (project, branch) + // tokens to exclude. Empty excludes nothing. + ExcludeGitBranch string Model string // "" for all; supports comma-separated ExcludeProject string // comma-separated projects to exclude ExcludeAgent string // comma-separated agents to exclude @@ -77,6 +80,7 @@ type UsageFilter struct { ActiveSince string // RFC3339 session recency cutoff Termination string // "", "clean", "unclean", "active", or "stale" Breakdowns bool // populate Project/AgentBreakdowns per day + BranchBreakdowns bool // populate BranchBreakdowns per day SkipSessionCounts bool // skip distinct session counts when callers do not need them // TopSessionsSort ranks GetTopSessionsByCost results: ""/"cost" // (default) or "tokens". Ignored by other usage queries. @@ -110,6 +114,20 @@ func (f UsageFilter) ExcludedProjectFilterLabels() []string { return strings.Split(f.ExcludeProject, ",") } +// RequiresSessionScope reports whether any session-scoped filter is set; +// cursor-imported usage rows carry no session attributes and cannot +// satisfy such filters, so backends must drop them entirely. Termination +// is session-scoped too, but each backend checks it separately because +// its predicate compilation differs per backend. +func (f UsageFilter) RequiresSessionScope() bool { + return len(f.ProjectFilterLabels()) > 0 || + len(f.ExcludedProjectFilterLabels()) > 0 || + f.Machine != "" || f.GitBranch != "" || f.ExcludeGitBranch != "" || + f.MinUserMessages > 0 || + f.ExcludeOneShot || + f.ActiveSince != "" +} + func (f UsageFilter) appendUsageBranchFilterClauses( where string, args []any, modelCol string, ) (string, []any) { @@ -210,6 +228,12 @@ func (f UsageFilter) appendUsageSessionFilterClauses( where, args, "s.project", f.ExcludedProjectFilterLabels(), false, ) where, args = appendCSV(where, args, "s.agent", f.ExcludeAgent, false) + if f.ExcludeGitBranch != "" { + var clause string + clause, args = BranchPairExcludeClauseArgs( + "s.project", "s.git_branch", f.ExcludeGitBranch, args) + where += "\n\tAND " + clause + } if f.MinUserMessages > 0 { where += "\n\tAND s.user_message_count >= ?" @@ -492,7 +516,8 @@ SELECT '' AS usage_dedup_key, s.project, s.agent, - s.machine + s.machine, + s.git_branch FROM messages m JOIN sessions s ON m.session_id = s.id WHERE %s @@ -522,7 +547,8 @@ SELECT END AS usage_dedup_key, s.project, s.agent, - s.machine + s.machine, + s.git_branch FROM usage_events ue JOIN sessions s ON s.id = ue.session_id WHERE %s` @@ -551,7 +577,8 @@ SELECT '' AS usage_dedup_key, s.project, s.agent, - s.machine + s.machine, + s.git_branch FROM %s m JOIN sessions s ON m.session_id = s.id WHERE %s` @@ -580,7 +607,8 @@ SELECT END AS usage_dedup_key, s.project, s.agent, - s.machine + s.machine, + s.git_branch FROM %s ue JOIN sessions s ON s.id = ue.session_id WHERE %s` @@ -716,6 +744,7 @@ type dailyUsageScanRow struct { project string agent string machine string + gitBranch string } type topSessionMetadata struct { @@ -767,15 +796,18 @@ func usageRowSelect() string { } func dailyUsageRowSelectFromRows(rowsSQL string) string { - return dailyUsageRowSelectFromRowsWithMachine(rowsSQL, false) + return dailyUsageRowSelectFromRowsWithBreakdowns(rowsSQL, false, false) } -func dailyUsageRowSelectFromRowsWithMachine( - rowsSQL string, includeMachine bool, +func dailyUsageRowSelectFromRowsWithBreakdowns( + rowsSQL string, includeBreakdowns, includeBranchBreakdowns bool, ) string { - machineColumn := "" - if includeMachine { - machineColumn = ",\n\tu.machine" + breakdownColumns := "" + if includeBreakdowns { + breakdownColumns += ",\n\tu.machine" + } + if includeBranchBreakdowns { + breakdownColumns += ",\n\tu.git_branch" } return ` SELECT @@ -797,7 +829,7 @@ SELECT u.source_uuid, u.usage_dedup_key, u.project, - u.agent` + machineColumn + ` + u.agent` + breakdownColumns + ` FROM (` + rowsSQL + `) u WHERE 1=1` } @@ -977,22 +1009,18 @@ SELECT cu.dedup_key AS usage_dedup_key, '' AS project, 'cursor' AS agent, - '' AS machine + '' AS machine, + '' AS git_branch FROM cursor_usage_events cu WHERE %s` func cursorUsageRowsSQLForBounds( f UsageFilter, b usageBounds, ) (string, []any, bool) { + // A termination filter only counts when it compiles to a predicate; + // unrecognized values are ignored, matching the session-row path. termPred, _ := buildUsageTerminationPredSQLite(f.Termination) - // Cursor usage rows carry no project or git branch and bypass the session - // filter, so any filter they cannot satisfy (project, machine, branch) - // must exclude them entirely rather than let them leak into totals. - if len(f.ProjectFilterLabels()) > 0 || - len(f.ExcludedProjectFilterLabels()) > 0 || - f.Machine != "" || f.GitBranch != "" || f.MinUserMessages > 0 || - f.ExcludeOneShot || termPred != "" || - f.ActiveSince != "" { + if f.RequiresSessionScope() || termPred != "" { return "", nil, false } if f.Agent != "" { @@ -1081,11 +1109,11 @@ func scanUsageRow(rows *sql.Rows) (usageScanRow, error) { } func scanDailyUsageRow(rows *sql.Rows) (dailyUsageScanRow, error) { - return scanDailyUsageRowWithMachine(rows, false) + return scanDailyUsageRowWithBreakdowns(rows, false, false) } -func scanDailyUsageRowWithMachine( - rows *sql.Rows, includeMachine bool, +func scanDailyUsageRowWithBreakdowns( + rows *sql.Rows, includeBreakdowns, includeBranchBreakdowns bool, ) (dailyUsageScanRow, error) { var r dailyUsageScanRow dest := []any{ @@ -1109,9 +1137,12 @@ func scanDailyUsageRowWithMachine( &r.project, &r.agent, } - if includeMachine { + if includeBreakdowns { dest = append(dest, &r.machine) } + if includeBranchBreakdowns { + dest = append(dest, &r.gitBranch) + } err := rows.Scan(dest...) return r, err } @@ -1662,6 +1693,7 @@ type DailyUsageEntry struct { ProjectBreakdowns []ProjectBreakdown `json:"projectBreakdowns"` AgentBreakdowns []AgentBreakdown `json:"agentBreakdowns"` MachineBreakdowns []MachineBreakdown `json:"machineBreakdowns"` + BranchBreakdowns []BranchBreakdown `json:"branchBreakdowns"` } func (e DailyUsageEntry) MarshalJSON() ([]byte, error) { @@ -1682,6 +1714,9 @@ func (e DailyUsageEntry) MarshalJSON() ([]byte, error) { if out.MachineBreakdowns == nil { out.MachineBreakdowns = []MachineBreakdown{} } + if out.BranchBreakdowns == nil { + out.BranchBreakdowns = []BranchBreakdown{} + } return json.Marshal(out) } @@ -1726,6 +1761,32 @@ type MachineBreakdown struct { Cost money.Money `json:"cost"` } +// BranchBreakdown is keyed by the raw (project, branch) pair. +type BranchBreakdown struct { + ProjectKey string `json:"project_key"` + Project string `json:"project"` + Branch string `json:"branch"` + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + CacheCreationTokens int `json:"cacheCreationTokens"` + CacheReadTokens int `json:"cacheReadTokens"` + Cost money.Money `json:"cost"` +} + +// SortBranchBreakdowns orders breakdowns by cost descending, then project, +// then branch. All backends must emit breakdowns in this order. +func SortBranchBreakdowns(bbd []BranchBreakdown) { + sort.Slice(bbd, func(i, j int) bool { + if bbd[i].Cost.Microdollars != bbd[j].Cost.Microdollars { + return bbd[i].Cost.Microdollars > bbd[j].Cost.Microdollars + } + if bbd[i].Project != bbd[j].Project { + return bbd[i].Project < bbd[j].Project + } + return bbd[i].Branch < bbd[j].Branch + }) +} + // UsageTotals holds aggregate token and cost totals. type UsageTotals struct { InputTokens int `json:"inputTokens"` @@ -1766,6 +1827,13 @@ func SanitizeDailyUsageProjectLabelsWithCatalog( result.Daily[i].ProjectBreakdowns[j].Project = export.SafeProjectDisplayLabel(raw) } + for j := range result.Daily[i].BranchBreakdowns { + raw := result.Daily[i].BranchBreakdowns[j].Project + result.Daily[i].BranchBreakdowns[j].ProjectKey = + export.ProjectKeyForEntry(projects[raw]) + result.Daily[i].BranchBreakdowns[j].Project = + export.SafeProjectDisplayLabel(raw) + } } if result.SessionCounts.ByProject != nil { byProject := make(map[string]int, len(result.SessionCounts.ByProject)) @@ -1966,6 +2034,32 @@ func paddedUTCBound(ts string, hours int) string { return t.Add(time.Duration(hours) * time.Hour).Format(time.RFC3339) } +// UsageBucket accumulates token counts and cost for one aggregation key. +// It is shared across backends so their aggregation paths stay identical. +type UsageBucket struct { + InputTok int + OutputTok int + CacheCr int + CacheRd int + Cost money.Money +} + +// AddUsageBucket adds b into the bucket stored under key, field by field. +func AddUsageBucket[K comparable](m map[K]UsageBucket, key K, b UsageBucket) error { + cur := m[key] + cur.InputTok += b.InputTok + cur.OutputTok += b.OutputTok + cur.CacheCr += b.CacheCr + cur.CacheRd += b.CacheRd + var err error + cur.Cost, err = money.Add(cur.Cost, b.Cost) + if err != nil { + return fmt.Errorf("summing daily usage breakdown cost: %w", err) + } + m[key] = cur + return nil +} + // GetDailyUsage returns token usage and cost aggregated by day. // It scans messages with non-empty token_usage JSON blobs, // parses them in Go (faster than SQLite's json_extract per row), @@ -1988,7 +2082,9 @@ func (db *DB) GetDailyUsage( // Pad by +/-14h to cover all timezone offsets; the actual // date filtering happens post-query via localDate. query, args := dailyUsageRowsSQLForBounds(f, usageBoundsForFilter(f), db.hasCursorUsageTable()) - query = dailyUsageRowSelectFromRowsWithMachine(query, f.Breakdowns) + query = dailyUsageRowSelectFromRowsWithBreakdowns( + query, f.Breakdowns, f.BranchBreakdowns, + ) query += ` ORDER BY u.ts ASC, u.session_id ASC, COALESCE(u.message_ordinal, -1) ASC` @@ -1999,19 +2095,12 @@ func (db *DB) GetDailyUsage( } defer rows.Close() - type bucket struct { - inputTok int - outputTok int - cacheCr int - cacheRd int - cost money.Money - } type sessionCost struct { estimated map[usageCostAllocationKey]money.Money authoritative *money.Money } - accum := make(map[usageCostAllocationKey]*bucket) + accum := make(map[usageCostAllocationKey]*UsageBucket) sessionCosts := make(map[string]sessionCost) useAuthoritativeCost := f.Model == "" && f.ExcludeModel == "" @@ -2030,7 +2119,9 @@ func (db *DB) GetDailyUsage( var totalSavings money.Money for rows.Next() { - r, scanErr := scanDailyUsageRowWithMachine(rows, f.Breakdowns) + r, scanErr := scanDailyUsageRowWithBreakdowns( + rows, f.Breakdowns, f.BranchBreakdowns, + ) if scanErr != nil { return DailyUsageResult{}, fmt.Errorf("scanning daily usage row: %w", scanErr) @@ -2080,19 +2171,28 @@ func (db *DB) GetDailyUsage( "summing daily usage cache savings: %w", priceErr) } + // Leave gitBranch out of the accumulator key unless breakdowns are + // requested, so a plain totals query still sums one row per + // (date, project, agent, model) instead of splitting by branch too. + gitBranch := "" + branchAttributed := f.BranchBreakdowns && r.usageSource != "cursor" + if branchAttributed { + gitBranch = r.gitBranch + } key := usageCostAllocationKey{ date: date, project: r.project, agent: r.agent, machine: r.machine, model: r.model, + gitBranch: gitBranch, branchAttributed: branchAttributed, } b, ok := accum[key] if !ok { - b = &bucket{} + b = &UsageBucket{} accum[key] = b } - b.inputTok += inputTok - b.outputTok += outputTok - b.cacheCr += cacheCrTok - b.cacheRd += cacheRdTok + b.InputTok += inputTok + b.OutputTok += outputTok + b.CacheCr += cacheCrTok + b.CacheRd += cacheRdTok sc := sessionCosts[r.sessionID] if sc.estimated == nil { @@ -2128,10 +2228,10 @@ func (db *DB) GetDailyUsage( for key, cost := range costs { b := accum[key] if b == nil { - b = &bucket{} + b = &UsageBucket{} accum[key] = b } - b.cost, err = money.Add(b.cost, cost) + b.Cost, err = money.Add(b.Cost, cost) if err != nil { return DailyUsageResult{}, fmt.Errorf( "summing allocated daily usage cost: %w", err) @@ -2141,10 +2241,10 @@ func (db *DB) GetDailyUsage( for key, cost := range sc.estimated { b := accum[key] if b == nil { - b = &bucket{} + b = &UsageBucket{} accum[key] = b } - b.cost, err = money.Add(b.cost, cost) + b.Cost, err = money.Add(b.Cost, cost) if err != nil { return DailyUsageResult{}, fmt.Errorf( "summing estimated daily usage cost: %w", err) @@ -2156,9 +2256,9 @@ func (db *DB) GetDailyUsage( // Two paths: without breakdowns (CLI, fast) and with breakdowns // (web UI). The fast path uses the original (date, model) // grouping with no extra column reads. The breakdown path adds - // project/agent dimensions and builds three decomposition slices. + // project/agent/branch dimensions and builds four decomposition slices. - if !f.Breakdowns { + if !f.Breakdowns && !f.BranchBreakdowns { // Fast path: group by (date, model) only. type dateModelKey struct { date string @@ -2179,11 +2279,11 @@ func (db *DB) GetDailyUsage( ma = &modelAccum{} dm[dmk] = ma } - ma.inputTok += b.inputTok - ma.outputTok += b.outputTok - ma.cacheCr += b.cacheCr - ma.cacheRd += b.cacheRd - ma.cost, err = money.Add(ma.cost, b.cost) + ma.inputTok += b.InputTok + ma.outputTok += b.OutputTok + ma.cacheCr += b.CacheCr + ma.cacheRd += b.CacheRd + ma.cost, err = money.Add(ma.cost, b.Cost) if err != nil { return DailyUsageResult{}, fmt.Errorf( "summing daily model cost: %w", err) @@ -2287,7 +2387,7 @@ func (db *DB) GetDailyUsage( var aiCredits float64 for key, b := range accum { - aiCredits += AICreditsFromCost(key.agent, b.cost) + aiCredits += AICreditsFromCost(key.agent, b.Cost) } if aiCredits > 0 { totals.CopilotAICredits = aiCredits @@ -2320,72 +2420,53 @@ func (db *DB) GetDailyUsage( }, nil } - // Breakdown path: single walk builds model/project/agent maps. + // Breakdown path: single walk builds model/project/agent/branch maps. + type branchMapKey struct { + project string + branch string + } type dayMaps struct { - models map[string]bucket - projects map[string]bucket - agents map[string]bucket - machines map[string]bucket + models map[string]UsageBucket + projects map[string]UsageBucket + agents map[string]UsageBucket + machines map[string]UsageBucket + branches map[branchMapKey]UsageBucket } days := make(map[string]*dayMaps, 64) for key, b := range accum { dm, ok := days[key.date] if !ok { dm = &dayMaps{ - models: make(map[string]bucket, 4), - projects: make(map[string]bucket, 8), - agents: make(map[string]bucket, 4), - machines: make(map[string]bucket, 4), + models: make(map[string]UsageBucket, 4), + projects: make(map[string]UsageBucket, 8), + agents: make(map[string]UsageBucket, 4), + machines: make(map[string]UsageBucket, 4), + branches: make(map[branchMapKey]UsageBucket, 8), } days[key.date] = dm } - cur := dm.models[key.model] - cur.inputTok += b.inputTok - cur.outputTok += b.outputTok - cur.cacheCr += b.cacheCr - cur.cacheRd += b.cacheRd - cur.cost, err = money.Add(cur.cost, b.cost) - if err != nil { - return DailyUsageResult{}, fmt.Errorf( - "summing daily model breakdown cost: %w", err) - } - dm.models[key.model] = cur - - cur = dm.projects[key.project] - cur.inputTok += b.inputTok - cur.outputTok += b.outputTok - cur.cacheCr += b.cacheCr - cur.cacheRd += b.cacheRd - cur.cost, err = money.Add(cur.cost, b.cost) - if err != nil { - return DailyUsageResult{}, fmt.Errorf( - "summing daily project breakdown cost: %w", err) + if err := AddUsageBucket(dm.models, key.model, *b); err != nil { + return DailyUsageResult{}, err } - dm.projects[key.project] = cur - - cur = dm.agents[key.agent] - cur.inputTok += b.inputTok - cur.outputTok += b.outputTok - cur.cacheCr += b.cacheCr - cur.cacheRd += b.cacheRd - cur.cost, err = money.Add(cur.cost, b.cost) - if err != nil { - return DailyUsageResult{}, fmt.Errorf( - "summing daily agent breakdown cost: %w", err) + if f.Breakdowns { + if err := AddUsageBucket(dm.projects, key.project, *b); err != nil { + return DailyUsageResult{}, err + } + if err := AddUsageBucket(dm.agents, key.agent, *b); err != nil { + return DailyUsageResult{}, err + } + if err := AddUsageBucket(dm.machines, key.machine, *b); err != nil { + return DailyUsageResult{}, err + } } - dm.agents[key.agent] = cur - - cur = dm.machines[key.machine] - cur.inputTok += b.inputTok - cur.outputTok += b.outputTok - cur.cacheCr += b.cacheCr - cur.cacheRd += b.cacheRd - cur.cost, err = money.Add(cur.cost, b.cost) - if err != nil { - return DailyUsageResult{}, fmt.Errorf( - "summing daily machine breakdown cost: %w", err) + if f.BranchBreakdowns && key.branchAttributed { + if err := AddUsageBucket(dm.branches, branchMapKey{ + project: key.project, + branch: key.gitBranch, + }, *b); err != nil { + return DailyUsageResult{}, err + } } - dm.machines[key.machine] = cur } dateKeys := make([]string, 0, len(days)) @@ -2412,8 +2493,8 @@ func (db *DB) GetDailyUsage( sort.Slice(modelNames, func(i, j int) bool { left := dm.models[modelNames[i]] right := dm.models[modelNames[j]] - ci := left.cost - cj := right.cost + ci := left.Cost + cj := right.Cost if ci.Microdollars != cj.Microdollars { return ci.Microdollars > cj.Microdollars } @@ -2428,22 +2509,22 @@ func (db *DB) GetDailyUsage( if !ok { continue } - entry.InputTokens += b.inputTok - entry.OutputTokens += b.outputTok - entry.CacheCreationTokens += b.cacheCr - entry.CacheReadTokens += b.cacheRd - entry.TotalCost, err = money.Add(entry.TotalCost, b.cost) + entry.InputTokens += b.InputTok + entry.OutputTokens += b.OutputTok + entry.CacheCreationTokens += b.CacheCr + entry.CacheReadTokens += b.CacheRd + entry.TotalCost, err = money.Add(entry.TotalCost, b.Cost) if err != nil { return DailyUsageResult{}, fmt.Errorf( "summing daily breakdown entry cost: %w", err) } mbd = append(mbd, ModelBreakdown{ ModelName: m, - InputTokens: b.inputTok, - OutputTokens: b.outputTok, - CacheCreationTokens: b.cacheCr, - CacheReadTokens: b.cacheRd, - Cost: b.cost, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, }) } entry.ModelBreakdowns = mbd @@ -2454,11 +2535,11 @@ func (db *DB) GetDailyUsage( for p, b := range dm.projects { pbd = append(pbd, ProjectBreakdown{ Project: p, - InputTokens: b.inputTok, - OutputTokens: b.outputTok, - CacheCreationTokens: b.cacheCr, - CacheReadTokens: b.cacheRd, - Cost: b.cost, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, }) } sort.Slice(pbd, func(i, j int) bool { @@ -2475,11 +2556,11 @@ func (db *DB) GetDailyUsage( for a, b := range dm.agents { abd = append(abd, AgentBreakdown{ Agent: a, - InputTokens: b.inputTok, - OutputTokens: b.outputTok, - CacheCreationTokens: b.cacheCr, - CacheReadTokens: b.cacheRd, - Cost: b.cost, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, }) } sort.Slice(abd, func(i, j int) bool { @@ -2496,11 +2577,11 @@ func (db *DB) GetDailyUsage( for machine, b := range dm.machines { machineBreakdowns = append(machineBreakdowns, MachineBreakdown{ MachineName: machine, - InputTokens: b.inputTok, - OutputTokens: b.outputTok, - CacheCreationTokens: b.cacheCr, - CacheReadTokens: b.cacheRd, - Cost: b.cost, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, }) } sort.Slice(machineBreakdowns, func(i, j int) bool { @@ -2511,6 +2592,23 @@ func (db *DB) GetDailyUsage( }) entry.MachineBreakdowns = machineBreakdowns + bbd := make( + []BranchBreakdown, 0, len(dm.branches), + ) + for bk, b := range dm.branches { + bbd = append(bbd, BranchBreakdown{ + Project: bk.project, + Branch: bk.branch, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, + }) + } + SortBranchBreakdowns(bbd) + entry.BranchBreakdowns = bbd + daily = append(daily, entry) totals.InputTokens += entry.InputTokens diff --git a/internal/db/usage_cost_allocation.go b/internal/db/usage_cost_allocation.go index 3bd3592ad..6141c3250 100644 --- a/internal/db/usage_cost_allocation.go +++ b/internal/db/usage_cost_allocation.go @@ -10,11 +10,13 @@ import ( // usageCostAllocationKey is the daily usage breakdown granularity used when // distributing an authoritative session total. type usageCostAllocationKey struct { - date string - project string - agent string - machine string - model string + date string + project string + agent string + machine string + model string + gitBranch string + branchAttributed bool } func sortedUsageCostAllocationKeys( @@ -38,7 +40,13 @@ func sortedUsageCostAllocationKeys( if a.machine != b.machine { return a.machine < b.machine } - return a.model < b.model + if a.model != b.model { + return a.model < b.model + } + if a.gitBranch != b.gitBranch { + return a.gitBranch < b.gitBranch + } + return !a.branchAttributed && b.branchAttributed }) return keys } diff --git a/internal/db/usage_test.go b/internal/db/usage_test.go index 76efd806b..67b07431d 100644 --- a/internal/db/usage_test.go +++ b/internal/db/usage_test.go @@ -873,6 +873,7 @@ func TestGetDailyUsageIncludesCursorUsageEvents(t *testing.T) { require.Len(t, day.AgentBreakdowns, 1) assert.Equal(t, "cursor", day.AgentBreakdowns[0].Agent) assert.Equal(t, money.MustParseDollars("0.1566"), day.AgentBreakdowns[0].Cost) + assert.Empty(t, day.BranchBreakdowns, "cursor-only usage has no branch attribution") assert.Empty(t, result.Projects, "cursor-only usage should not emit project identities") assert.NotContains(t, result.Projects, "") assert.Equal(t, 0, result.SessionCounts.Total, "cursor rows should not count as sessions") @@ -940,6 +941,33 @@ func TestGetDailyUsageSkipsCursorUsageEventsForExcludeOneShot(t *testing.T) { assert.Zero(t, result.SessionCounts.Total, "cursor rows should not count as sessions") } +func TestGetDailyUsageSkipsCursorUsageEventsForExcludeGitBranch(t *testing.T) { + d := testDB(t) + ctx := context.Background() + + require.NoError(t, d.InsertCursorUsageEvents([]CursorUsageEvent{{ + OccurredAt: "2026-05-14T10:05:00Z", + Model: "claude-4.6-opus-high-thinking", + Kind: "USAGE_EVENT_KIND_USAGE_BASED", + InputTokens: 1234, + OutputTokens: 567, + CacheReadTokens: 8901, + Charged: money.MustParseDollars("0.1566"), + CursorTokenFee: money.MustParseDollars("0.0332"), + UserID: "152683922", + UserEmail: "member@example.com", + }}), "InsertCursorUsageEvents") + + result, err := d.GetDailyUsage(ctx, UsageFilter{ + From: "2026-05-14", + To: "2026-05-14", + ExcludeGitBranch: EncodeBranchFilterToken("proj", "main"), + }) + require.NoError(t, err, "GetDailyUsage cursor exclude git branch") + assert.Empty(t, result.Daily, "daily entries should be empty") + assert.Zero(t, result.Totals.InputTokens, "InputTokens") +} + func TestGetDailyUsageSkipsCursorUsageEventsForTerminationFilter(t *testing.T) { d := testDB(t) ctx := context.Background() diff --git a/internal/duckdb/activityreport.go b/internal/duckdb/activityreport.go index 373805961..a8712dd62 100644 --- a/internal/duckdb/activityreport.go +++ b/internal/duckdb/activityreport.go @@ -330,7 +330,8 @@ func (s *Store) activityReportSessions( s.machine, s.started_at, s.ended_at, - COALESCE(s.is_automated, false) AS is_automated + COALESCE(s.is_automated, false) AS is_automated, + s.git_branch FROM sessions s WHERE ` + where + ` AND COALESCE(s.ended_at, @@ -354,6 +355,7 @@ func (s *Store) activityReportSessions( if err := rows.Scan( &m.SessionID, &m.Title, &m.Project, &m.Agent, &m.Machine, &startedAt, &endedAt, &m.IsAutomated, + &m.GitBranch, ); err != nil { return nil, nil, fmt.Errorf( "scanning duckdb activity report session: %w", err) diff --git a/internal/duckdb/analytics_usage.go b/internal/duckdb/analytics_usage.go index 34ac4e1d7..6e57886d9 100644 --- a/internal/duckdb/analytics_usage.go +++ b/internal/duckdb/analytics_usage.go @@ -3167,6 +3167,12 @@ func appendDuckUsageSessionFilterClauses( where, args, "s.project", f.ExcludedProjectFilterLabels(), false, ) where, args = appendDuckUsageCSVFilter(where, args, "s.agent", f.ExcludeAgent, false) + if f.ExcludeGitBranch != "" { + var clause string + clause, args = db.BranchPairExcludeClauseArgs( + "s.project", "s.git_branch", f.ExcludeGitBranch, args) + where += "\n\t\t\tAND " + clause + } if sessionID != "" { where += "\n\t\t\tAND s.id = ?" args = append(args, sessionID) @@ -3229,6 +3235,7 @@ SELECT '' AS project, 'cursor' AS agent, '' AS machine, + '' AS git_branch, 0 AS user_message_count, cu.is_headless AS is_automated, '' AS display_name, @@ -3305,6 +3312,7 @@ func duckUsageRawSQL(f db.UsageFilter, sessionID string) (string, []any) { COALESCE(TRY_CAST(json_extract_string(m.token_usage, '$.reasoning_tokens') AS BIGINT), 0) AS reasoning_tokens, NULL AS cost_microdollars, '' AS cost_source, s.project AS project, s.agent AS agent, s.machine AS machine, + s.git_branch AS git_branch, s.user_message_count AS user_message_count, s.is_automated AS is_automated, COALESCE(s.display_name, s.session_name, s.first_message, s.project, s.id) AS display_name, s.started_at AS started_at, @@ -3329,6 +3337,7 @@ func duckUsageRawSQL(f db.UsageFilter, sessionID string) (string, []any) { ue.cost_microdollars AS cost_microdollars, ue.cost_source AS cost_source, s.project AS project, s.agent AS agent, s.machine AS machine, + s.git_branch AS git_branch, s.user_message_count AS user_message_count, s.is_automated AS is_automated, COALESCE(s.display_name, s.session_name, s.first_message, s.project, s.id) AS display_name, s.started_at AS started_at, @@ -3377,15 +3386,10 @@ func duckMatchingUsageRawSQL(f db.UsageFilter) (string, []any) { func duckCursorUsageRowsSQLForBounds( f db.UsageFilter, b duckUsageBounds, ) (string, []any, bool) { + // Any explicit termination filter other than "all" drops cursor rows, + // even values the session-row path would ignore as unrecognized. hasTermFilter := f.Termination != "" && f.Termination != "all" - // Cursor usage rows carry no project or git branch and bypass the session - // filter, so any filter they cannot satisfy (project, machine, branch) - // must exclude them entirely rather than let them leak into totals. - if len(f.ProjectFilterLabels()) > 0 || - len(f.ExcludedProjectFilterLabels()) > 0 || - f.Machine != "" || f.GitBranch != "" || f.MinUserMessages > 0 || - f.ExcludeOneShot || hasTermFilter || - f.ActiveSince != "" { + if f.RequiresSessionScope() || hasTermFilter { return "", nil, false } if f.Agent != "" { @@ -3570,31 +3574,25 @@ func duckUsageCTEFromRaw( return query, args } -type duckUsageBucket struct { - inputTok int - outputTok int - cacheCr int - cacheRd int - cost money.Money -} - type duckUsageAggregateRow struct { - date string - sessionID string - project string - agent string - machine string - model string - priceModel string - source string - messageOrdinal sql.NullInt64 - displayName string - startedAt string - inputTok int - outputTok int - cacheCr int - cacheRd int - billableInput int + date string + sessionID string + project string + agent string + machine string + model string + gitBranch string + branchAttributed bool + priceModel string + source string + messageOrdinal sql.NullInt64 + displayName string + startedAt string + inputTok int + outputTok int + cacheCr int + cacheRd int + billableInput int // Output-rate billable tokens. SQL folds reasoning-only rows into this // value before grouping because reasoning is otherwise a row-level choice. billableOutput int @@ -3814,15 +3812,24 @@ func (s *Store) forEachDailyUsageAggregateRow( cte, args := duckDailyUsageCTE(f) machineSelect := "'' AS machine" machineOrder := "" + branchSelect := "'' AS git_branch" + branchAttributedSelect := "FALSE AS branch_attributed" + branchOrder := "" if f.Breakdowns { machineSelect = "machine" machineOrder = ", machine ASC" } + if f.BranchBreakdowns { + branchSelect = "CASE WHEN source = 'cursor' THEN '' ELSE git_branch END AS git_branch" + branchAttributedSelect = "source != 'cursor' AS branch_attributed" + branchOrder = ", git_branch ASC, branch_attributed ASC" + } // Keep one result per deduplicated usage row. CostForTokens quantizes each // row to whole microdollars; grouping token counts before that boundary can // turn several unrepresentable sub-microdollar rows into stored cost. query := cte + ` - SELECT session_id, local_date, project, agent, ` + machineSelect + `, model, price_model, + SELECT session_id, local_date, project, agent, ` + machineSelect + `, model, ` + branchSelect + `, + ` + branchAttributedSelect + `, price_model, source, message_ordinal, input_tokens_norm AS input_tokens, output_tokens_norm AS output_tokens, @@ -3842,7 +3849,7 @@ func (s *Store) forEachDailyUsageAggregateRow( CASE WHEN cost_microdollars IS NOT NULL AND cost_source = 'copilot-reported' THEN cost_microdollars ELSE 0 END AS authoritative_cost, CASE WHEN cost_microdollars IS NOT NULL AND cost_source = 'copilot-reported' THEN 1 ELSE 0 END AS authoritative_cost_rows FROM usage_localized - ORDER BY session_id ASC, local_date ASC, project ASC, agent ASC` + machineOrder + `, model ASC, price_model ASC, ts ASC, COALESCE(message_ordinal, -1) ASC, source ASC, usage_dedup_key ASC` + ORDER BY session_id ASC, local_date ASC, project ASC, agent ASC` + machineOrder + `, model ASC` + branchOrder + `, price_model ASC, ts ASC, COALESCE(message_ordinal, -1) ASC, source ASC, usage_dedup_key ASC` rows, err := s.queryContext(ctx, query, args...) if err != nil { return fmt.Errorf("querying duckdb daily usage aggregates: %w", err) @@ -3852,6 +3859,7 @@ func (s *Store) forEachDailyUsageAggregateRow( var r duckUsageAggregateRow if err := rows.Scan( &r.sessionID, &r.date, &r.project, &r.agent, &r.machine, &r.model, + &r.gitBranch, &r.branchAttributed, &r.priceModel, &r.source, &r.messageOrdinal, &r.inputTok, &r.outputTok, &r.cacheCr, &r.cacheRd, &r.billableInput, &r.billableOutput, &r.billableReason, @@ -3880,13 +3888,15 @@ func (s *Store) GetDailyUsage( } rateResolver := export.NewPricingResolver(duckPricingRows(pricing)) type usageAccumKey struct { - date string - project string - agent string - machine string - model string - } - accum := map[usageAccumKey]*duckUsageBucket{} + date string + project string + agent string + machine string + model string + gitBranch string + branchAttributed bool + } + accum := map[usageAccumKey]*db.UsageBucket{} type sessionCost struct { estimated map[usageAccumKey]money.Money authoritative *money.Money @@ -3901,8 +3911,13 @@ func (s *Store) GetDailyUsage( var totalSavings money.Money err = s.forEachDailyUsageAggregateRow(ctx, f, func(r duckUsageAggregateRow) error { key := usageAccumKey{ - date: r.date, project: r.project, agent: r.agent, - machine: r.machine, model: r.model, + date: r.date, + project: r.project, + agent: r.agent, + machine: r.machine, + model: r.model, + gitBranch: r.gitBranch, + branchAttributed: r.branchAttributed, } if r.project != "" { projectLabels[r.project] = true @@ -3915,7 +3930,7 @@ func (s *Store) GetDailyUsage( } b := accum[key] if b == nil { - b = &duckUsageBucket{} + b = &db.UsageBucket{} accum[key] = b } cost, savings, _, _, priceErr := duckUsageAggregateResolvedCost( @@ -3935,10 +3950,10 @@ func (s *Store) GetDailyUsage( if priceErr != nil { return fmt.Errorf("summing duckdb cache savings: %w", priceErr) } - b.inputTok += r.inputTok - b.outputTok += r.outputTok - b.cacheCr += r.cacheCr - b.cacheRd += r.cacheRd + b.InputTok += r.inputTok + b.OutputTok += r.outputTok + b.CacheCr += r.cacheCr + b.CacheRd += r.cacheRd sc := sessionCosts[r.sessionID] if sc.estimated == nil { sc.estimated = map[usageAccumKey]money.Money{} @@ -3984,7 +3999,10 @@ func (s *Store) GetDailyUsage( if a.machine != b.machine { return a.machine < b.machine } - return a.model < b.model + if a.model != b.model { + return a.model < b.model + } + return a.gitBranch < b.gitBranch }) weights := make([]money.Money, len(keys)) for i, key := range keys { @@ -3994,10 +4012,10 @@ func (s *Store) GetDailyUsage( for i, key := range keys { b := accum[key] if b == nil { - b = &duckUsageBucket{} + b = &db.UsageBucket{} accum[key] = b } - b.cost, err = money.Add(b.cost, costs[i]) + b.Cost, err = money.Add(b.Cost, costs[i]) if err != nil { return db.DailyUsageResult{}, fmt.Errorf( "summing allocated duckdb usage cost: %w", err) @@ -4007,10 +4025,10 @@ func (s *Store) GetDailyUsage( for key, cost := range sc.estimated { b := accum[key] if b == nil { - b = &duckUsageBucket{} + b = &db.UsageBucket{} accum[key] = b } - b.cost, err = money.Add(b.cost, cost) + b.Cost, err = money.Add(b.Cost, cost) if err != nil { return db.DailyUsageResult{}, fmt.Errorf( "summing estimated duckdb usage cost: %w", err) @@ -4019,11 +4037,16 @@ func (s *Store) GetDailyUsage( } } + type branchMapKey struct { + project string + branch string + } type dayMaps struct { - models map[string]duckUsageBucket - projects map[string]duckUsageBucket - agents map[string]duckUsageBucket - machines map[string]duckUsageBucket + models map[string]db.UsageBucket + projects map[string]db.UsageBucket + agents map[string]db.UsageBucket + machines map[string]db.UsageBucket + branches map[branchMapKey]db.UsageBucket totalCost money.Money } days := map[string]*dayMaps{} @@ -4031,29 +4054,38 @@ func (s *Store) GetDailyUsage( day := days[key.date] if day == nil { day = &dayMaps{ - models: map[string]duckUsageBucket{}, - projects: map[string]duckUsageBucket{}, - agents: map[string]duckUsageBucket{}, - machines: map[string]duckUsageBucket{}, + models: map[string]db.UsageBucket{}, + projects: map[string]db.UsageBucket{}, + agents: map[string]db.UsageBucket{}, + machines: map[string]db.UsageBucket{}, + branches: map[branchMapKey]db.UsageBucket{}, } days[key.date] = day } - if err := addUsageBucket(day.models, key.model, *b); err != nil { + if err := db.AddUsageBucket(day.models, key.model, *b); err != nil { return db.DailyUsageResult{}, err } - day.totalCost, err = money.Add(day.totalCost, b.cost) + day.totalCost, err = money.Add(day.totalCost, b.Cost) if err != nil { return db.DailyUsageResult{}, fmt.Errorf( "summing duckdb daily cost: %w", err) } if f.Breakdowns { - if err := addUsageBucket(day.projects, key.project, *b); err != nil { + if err := db.AddUsageBucket(day.projects, key.project, *b); err != nil { + return db.DailyUsageResult{}, err + } + if err := db.AddUsageBucket(day.agents, key.agent, *b); err != nil { return db.DailyUsageResult{}, err } - if err := addUsageBucket(day.agents, key.agent, *b); err != nil { + if err := db.AddUsageBucket(day.machines, key.machine, *b); err != nil { return db.DailyUsageResult{}, err } - if err := addUsageBucket(day.machines, key.machine, *b); err != nil { + } + if f.BranchBreakdowns && key.branchAttributed { + if err := db.AddUsageBucket(day.branches, branchMapKey{ + project: key.project, + branch: key.gitBranch, + }, *b); err != nil { return db.DailyUsageResult{}, err } } @@ -4070,17 +4102,17 @@ func (s *Store) GetDailyUsage( entry.ModelsUsed = modelNames for _, model := range modelNames { b := day.models[model] - entry.InputTokens += b.inputTok - entry.OutputTokens += b.outputTok - entry.CacheCreationTokens += b.cacheCr - entry.CacheReadTokens += b.cacheRd + entry.InputTokens += b.InputTok + entry.OutputTokens += b.OutputTok + entry.CacheCreationTokens += b.CacheCr + entry.CacheReadTokens += b.CacheRd entry.ModelBreakdowns = append(entry.ModelBreakdowns, db.ModelBreakdown{ ModelName: model, - InputTokens: b.inputTok, - OutputTokens: b.outputTok, - CacheCreationTokens: b.cacheCr, - CacheReadTokens: b.cacheRd, - Cost: b.cost, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, }) } entry.TotalCost = day.totalCost @@ -4089,22 +4121,22 @@ func (s *Store) GetDailyUsage( b := day.projects[project] entry.ProjectBreakdowns = append(entry.ProjectBreakdowns, db.ProjectBreakdown{ Project: project, - InputTokens: b.inputTok, - OutputTokens: b.outputTok, - CacheCreationTokens: b.cacheCr, - CacheReadTokens: b.cacheRd, - Cost: b.cost, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, }) } for _, agent := range sortedUsageBucketKeys(day.agents) { b := day.agents[agent] entry.AgentBreakdowns = append(entry.AgentBreakdowns, db.AgentBreakdown{ Agent: agent, - InputTokens: b.inputTok, - OutputTokens: b.outputTok, - CacheCreationTokens: b.cacheCr, - CacheReadTokens: b.cacheRd, - Cost: b.cost, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, }) } for _, machine := range sortedUsageBucketKeys(day.machines) { @@ -4113,15 +4145,31 @@ func (s *Store) GetDailyUsage( entry.MachineBreakdowns, db.MachineBreakdown{ MachineName: machine, - InputTokens: b.inputTok, - OutputTokens: b.outputTok, - CacheCreationTokens: b.cacheCr, - CacheReadTokens: b.cacheRd, - Cost: b.cost, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, }, ) } } + if f.BranchBreakdowns { + branchBreakdowns := make([]db.BranchBreakdown, 0, len(day.branches)) + for bk, b := range day.branches { + branchBreakdowns = append(branchBreakdowns, db.BranchBreakdown{ + Project: bk.project, + Branch: bk.branch, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, + }) + } + db.SortBranchBreakdowns(branchBreakdowns) + entry.BranchBreakdowns = branchBreakdowns + } result.Daily = append(result.Daily, entry) result.Totals.InputTokens += entry.InputTokens result.Totals.OutputTokens += entry.OutputTokens @@ -4138,7 +4186,7 @@ func (s *Store) GetDailyUsage( var aiCredits float64 for key, b := range accum { - aiCredits += db.AICreditsFromCost(key.agent, b.cost) + aiCredits += db.AICreditsFromCost(key.agent, b.Cost) } if aiCredits > 0 { result.Totals.CopilotAICredits = aiCredits @@ -4166,24 +4214,7 @@ func (s *Store) GetDailyUsage( return result, nil } -func addUsageBucket( - m map[string]duckUsageBucket, key string, b duckUsageBucket, -) error { - cur := m[key] - cur.inputTok += b.inputTok - cur.outputTok += b.outputTok - cur.cacheCr += b.cacheCr - cur.cacheRd += b.cacheRd - var err error - cur.cost, err = money.Add(cur.cost, b.cost) - if err != nil { - return fmt.Errorf("summing duckdb usage breakdown cost: %w", err) - } - m[key] = cur - return nil -} - -func sortedUsageBucketKeys(m map[string]duckUsageBucket) []string { +func sortedUsageBucketKeys(m map[string]db.UsageBucket) []string { out := make([]string, 0, len(m)) for key := range m { out = append(out, key) @@ -4191,8 +4222,8 @@ func sortedUsageBucketKeys(m map[string]duckUsageBucket) []string { sort.Slice(out, func(i, j int) bool { left := m[out[i]] right := m[out[j]] - if left.cost.Microdollars != right.cost.Microdollars { - return left.cost.Microdollars > right.cost.Microdollars + if left.Cost.Microdollars != right.Cost.Microdollars { + return left.Cost.Microdollars > right.Cost.Microdollars } return out[i] < out[j] }) diff --git a/internal/duckdb/store.go b/internal/duckdb/store.go index 8ff99d346..71f9911c6 100644 --- a/internal/duckdb/store.go +++ b/internal/duckdb/store.go @@ -364,6 +364,10 @@ func scanSessionRows(rows *sql.Rows) ([]db.Session, error) { return sessions, rows.Err() } +// duckActivityExpr is a session's effective recency, mirroring the SQLite and +// PG activity expressions (DuckDB timestamps are real NULLs, so no NULLIF). +const duckActivityExpr = "COALESCE(ended_at, started_at, created_at)" + func (s *Store) FindSessionIDsByPartial( ctx context.Context, partial string, limit int, ) ([]string, error) { @@ -376,7 +380,7 @@ func (s *Store) FindSessionIDsByPartial( rows, err := s.queryContext(ctx, `SELECT id FROM sessions WHERE strpos(id, ?) > 0 AND deleted_at IS NULL - ORDER BY COALESCE(ended_at, started_at, created_at) DESC + ORDER BY `+duckActivityExpr+` DESC LIMIT ?`, partial, limit, ) @@ -828,32 +832,50 @@ func (s *Store) GetMachines(ctx context.Context, excludeOneShot, excludeAutomate return out, rows.Err() } -func (s *Store) GetBranches(ctx context.Context, excludeOneShot, excludeAutomated bool) ([]db.BranchInfo, error) { - rows, err := s.queryContext(ctx, - `SELECT DISTINCT project, git_branch FROM sessions WHERE `+ - rootSessionWhere(excludeOneShot, excludeAutomated)+ - ` ORDER BY project, git_branch`, - ) +func (s *Store) GetBranches( + ctx context.Context, query db.BranchQuery, +) (db.BranchResult, error) { + query = db.NormalizeBranchQuery(query) + q := `SELECT git_branch FROM sessions WHERE ` + + sessionScopeWhere(query.Scope, query.ExcludeOneShot, query.ExcludeAutomated) + args := []any{} + if len(query.Projects) > 0 { + placeholders := make([]string, len(query.Projects)) + for i, project := range query.Projects { + placeholders[i] = "?" + args = append(args, project) + } + q += " AND project IN (" + strings.Join(placeholders, ",") + ")" + } + if query.Search != "" { + q += ` AND git_branch ILIKE ? ESCAPE '\'` + args = append(args, "%"+db.EscapeLikePattern(query.Search)+"%") + } + q += ` GROUP BY git_branch + ORDER BY MAX(` + duckActivityExpr + `) DESC NULLS LAST, git_branch + LIMIT ?` + args = append(args, query.Limit+1) + rows, err := s.queryContext(ctx, q, args...) if err != nil { - return nil, fmt.Errorf("querying duckdb branches: %w", err) + return db.BranchResult{}, fmt.Errorf("querying duckdb branches: %w", err) } defer rows.Close() - out := []db.BranchInfo{} - for rows.Next() { - var bi db.BranchInfo - if err := rows.Scan(&bi.Project, &bi.Branch); err != nil { - return nil, fmt.Errorf("scanning duckdb branch: %w", err) - } - bi.Token = db.EncodeBranchFilterToken(bi.Project, bi.Branch) - out = append(out, bi) - } - return out, rows.Err() + return db.ScanBranchResult(rows, query) } func rootSessionWhere(excludeOneShot, excludeAutomated bool) string { + return sessionScopeWhere(db.BranchScopeRoots, excludeOneShot, excludeAutomated) +} + +// sessionScopeWhere is rootSessionWhere with a selectable relationship +// scope: BranchScopeAll drops the root-only clause so subagent and fork +// sessions count, matching the activity and usage aggregation scope. +func sessionScopeWhere(scope db.BranchScope, excludeOneShot, excludeAutomated bool) string { filter := `message_count > 0 - AND relationship_type NOT IN ('subagent', 'fork') AND deleted_at IS NULL` + if scope == db.BranchScopeRoots { + filter += ` AND relationship_type NOT IN ('subagent', 'fork')` + } if excludeOneShot { if !excludeAutomated { filter += " AND (user_message_count > 1 OR is_automated = TRUE)" diff --git a/internal/duckdb/store_test.go b/internal/duckdb/store_test.go index 1d22291ac..a5ced1b75 100644 --- a/internal/duckdb/store_test.go +++ b/internal/duckdb/store_test.go @@ -3287,6 +3287,7 @@ func TestPushSyncsCursorUsageEventsIntoDuckDBDailyUsage(t *testing.T) { assert.Empty(t, result.SessionCounts.ByProject) require.Len(t, result.Daily[0].AgentBreakdowns, 1) assert.Equal(t, "cursor", result.Daily[0].AgentBreakdowns[0].Agent) + assert.Empty(t, result.Daily[0].BranchBreakdowns, "cursor-only usage has no branch attribution") } func TestTrendsTermsWordBoundaryAndOverlapParity(t *testing.T) { @@ -3938,20 +3939,27 @@ func TestDuckDBBranchDimension(t *testing.T) { ModelPattern: "claude-test", InputPerMTok: money.MustParseDollars("3"), OutputPerMTok: money.MustParseDollars("15"), }})) + // Timestamps drive the recency ordering; d-d and d-e tie so the pair + // itself breaks the tie alphabetically. seed := []struct { - id, project, branch string - input, output int + id, project, branch, ts string + input, output int }{ - {"d-a", "alpha", "main", 100, 10}, - {"d-b", "alpha", "feature-x", 200, 20}, - {"d-c", "beta", "main", 300, 30}, - {"d-d", "alpha", "", 400, 40}, - {"d-e", "alpha", "unknown", 500, 50}, + {"d-a", "alpha", "main", "2026-02-03T12:00:00.000Z", 100, 10}, + {"d-b", "alpha", "feature-x", "2026-02-05T12:00:00.000Z", 200, 20}, + {"d-c", "beta", "main", "2026-02-04T12:00:00.000Z", 300, 30}, + {"d-d", "alpha", "", "2026-02-01T12:00:00.000Z", 400, 40}, + {"d-e", "alpha", "unknown", "2026-02-01T12:00:00.000Z", 500, 50}, + {"d-f", "delta", "fork-only", "2026-01-30T12:00:00.000Z", 0, 0}, } var writes []db.SessionBatchWrite for _, s := range seed { - sess := syncSession(s.id, s.project, s.id+" first", "2026-02-01T12:00:00.000Z", 1) + sess := syncSession(s.id, s.project, s.id+" first", s.ts, 1) sess.GitBranch = s.branch + // A fork-only pair exercises the GetBranches relationship scopes. + if s.id == "d-f" { + sess.RelationshipType = "fork" + } writes = append(writes, db.SessionBatchWrite{ Session: sess, // A token-free user message so only the usage event below feeds the @@ -3982,38 +3990,56 @@ func TestDuckDBBranchDimension(t *testing.T) { require.NoError(t, err) store := NewStoreFromDB(syncer.DB()) - branches, err := store.GetBranches(ctx, false, false) + branches, err := store.GetBranches(ctx, db.BranchQuery{ + Projects: []string{"alpha", "beta"}, + Search: "MAIN", + Limit: 1, + }) require.NoError(t, err) - assert.Equal(t, []db.BranchInfo{ - { - Project: "alpha", - Branch: "", - Token: db.EncodeBranchFilterToken("alpha", ""), - }, - { - Project: "alpha", - Branch: "feature-x", - Token: db.EncodeBranchFilterToken("alpha", "feature-x"), - }, - { - Project: "alpha", - Branch: "main", - Token: db.EncodeBranchFilterToken("alpha", "main"), - }, - { - Project: "alpha", - Branch: "unknown", - Token: db.EncodeBranchFilterToken("alpha", "unknown"), - }, - { - Project: "beta", - Branch: "main", - Token: db.EncodeBranchFilterToken("beta", "main"), - }, - }, branches) + assert.Equal(t, db.BranchResult{ + Branches: []db.BranchOption{{Branch: "main"}}, + HasMore: false, + }, branches, "same-named branches deduplicate after filtering selected projects") + + withForks, err := store.GetBranches(ctx, db.BranchQuery{ + Scope: db.BranchScopeAll, + Projects: []string{"delta"}, + Limit: 100, + }) + require.NoError(t, err) + assert.Contains(t, withForks.Branches, db.BranchOption{Branch: "fork-only"}, + "fork-only branch included when scope is all") + + wide := db.UsageFilter{From: "2026-01-01", To: "2026-12-31", Breakdowns: true} + withoutBranches, err := store.GetDailyUsage(ctx, wide) + require.NoError(t, err) + require.NotEmpty(t, withoutBranches.Daily) + assert.Empty(t, withoutBranches.Daily[0].BranchBreakdowns) + assert.NotEmpty(t, withoutBranches.Daily[0].ProjectBreakdowns) + + wide.BranchBreakdowns = true + daily, err := store.GetDailyUsage(ctx, wide) + require.NoError(t, err) + assert.Equal(t, withoutBranches.Totals, daily.Totals) + byKey := map[db.BranchInfo]int{} + for _, day := range daily.Daily { + for _, b := range day.BranchBreakdowns { + byKey[db.BranchInfo{Project: b.Project, Branch: b.Branch}] += b.InputTokens + } + } + require.Len(t, byKey, 6, "one bucket per distinct (project, branch)") + assert.Equal(t, 0, byKey[db.BranchInfo{Project: "delta", Branch: "fork-only"}], + "fork sessions count in usage breakdowns even though the root branch scope hides them") + assert.Equal(t, 100, byKey[db.BranchInfo{Project: "alpha", Branch: "main"}]) + assert.Equal(t, 200, byKey[db.BranchInfo{Project: "alpha", Branch: "feature-x"}]) + assert.Equal(t, 300, byKey[db.BranchInfo{Project: "beta", Branch: "main"}], + "beta/main distinct from alpha/main") + assert.Equal(t, 400, byKey[db.BranchInfo{Project: "alpha", Branch: ""}], + "branchless usage keeps the raw empty branch") + assert.Equal(t, 500, byKey[db.BranchInfo{Project: "alpha", Branch: "unknown"}]) filtered, err := store.GetDailyUsage(ctx, db.UsageFilter{ - From: "2026-01-01", To: "2026-12-31", + From: "2026-01-01", To: "2026-12-31", Breakdowns: true, GitBranch: db.EncodeBranchFilterToken("alpha", "main"), }) require.NoError(t, err) @@ -4022,4 +4048,27 @@ func TestDuckDBBranchDimension(t *testing.T) { total += day.InputTokens } assert.Equal(t, 100, total, "branch filter restricts usage to alpha/main") + + excluded, err := store.GetDailyUsage(ctx, db.UsageFilter{ + From: "2026-01-01", To: "2026-12-31", Breakdowns: true, + ExcludeGitBranch: db.EncodeBranchFilterToken("alpha", "main"), + }) + require.NoError(t, err) + total = 0 + for _, day := range excluded.Daily { + total += day.InputTokens + } + assert.Equal(t, 1400, total, + "exclude filter drops only alpha/main, beta/main stays") + + malformed, err := store.GetDailyUsage(ctx, db.UsageFilter{ + From: "2026-01-01", To: "2026-12-31", Breakdowns: true, + ExcludeGitBranch: "no-separator-here", + }) + require.NoError(t, err) + total = 0 + for _, day := range malformed.Daily { + total += day.InputTokens + } + assert.Equal(t, 1500, total, "malformed exclude token excludes nothing") } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index e36c49a35..f1df561e5 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -5,7 +5,9 @@ package mcp import ( "context" + "errors" "fmt" + "strings" "time" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -51,6 +53,14 @@ func sessionActivity(s db.Session) string { return s.CreatedAt } +func mcpBranchToken(project, branch string) (string, error) { + tok, err := db.BranchFilterToken(project, branch) + if errors.Is(err, db.ErrBranchWithoutProject) { + return "", errors.New("git_branch requires project") + } + return tok, err +} + // isSystemMessage reports whether a message is system content that // get_messages and the overview tail must never surface: the persisted // is_system flag, or a legacy system prefix on a user message (the prefix @@ -203,6 +213,7 @@ type listSessionsIn struct { Project string `json:"project,omitempty" jsonschema:"Filter by project name."` Agent string `json:"agent,omitempty" jsonschema:"Filter by agent (e.g. claude, codex, gemini, antigravity)."` Machine string `json:"machine,omitempty" jsonschema:"Filter by machine name."` + GitBranch string `json:"git_branch,omitempty" jsonschema:"Filter by git branch name. Requires project; the branch is scoped to that project."` DateFrom string `json:"date_from,omitempty" jsonschema:"Only sessions on or after this date (YYYY-MM-DD)."` DateTo string `json:"date_to,omitempty" jsonschema:"Only sessions on or before this date (YYYY-MM-DD)."` ActiveSince string `json:"active_since,omitempty" jsonschema:"Only sessions active since this RFC3339 timestamp."` @@ -236,10 +247,15 @@ type listSessionsOut struct { func (t *toolset) listSessions( ctx context.Context, _ *mcp.CallToolRequest, in listSessionsIn, ) (*mcp.CallToolResult, listSessionsOut, error) { + gitBranch, err := mcpBranchToken(in.Project, in.GitBranch) + if err != nil { + return nil, listSessionsOut{}, err + } res, err := t.svc.List(ctx, service.ListFilter{ Project: in.Project, Agent: in.Agent, Machine: in.Machine, + GitBranch: gitBranch, DateFrom: in.DateFrom, DateTo: in.DateTo, ActiveSince: in.ActiveSince, @@ -521,6 +537,7 @@ type searchContentIn struct { Scope string `json:"scope,omitempty" jsonschema:"Semantic/hybrid result scope: top, all, or subordinate (default all). Only valid with mode semantic or hybrid."` Project string `json:"project,omitempty" jsonschema:"Restrict to one project."` Agent string `json:"agent,omitempty" jsonschema:"Restrict to one agent."` + GitBranch string `json:"git_branch,omitempty" jsonschema:"Restrict to one git branch name. Requires project; the branch is scoped to that project."` DateFrom string `json:"date_from,omitempty" jsonschema:"Only sessions on or after this date (YYYY-MM-DD)."` DateTo string `json:"date_to,omitempty" jsonschema:"Only sessions on or before this date (YYYY-MM-DD)."` Limit int `json:"limit,omitempty" jsonschema:"Max matches, default 10, max 30."` @@ -589,17 +606,22 @@ func (t *toolset) searchContent( return nil, searchContentOut{}, fmt.Errorf( "scope is only supported for semantic and hybrid search modes") } + gitBranch, err := mcpBranchToken(in.Project, in.GitBranch) + if err != nil { + return nil, searchContentOut{}, err + } res, err := t.svc.SearchContent(ctx, service.ContentSearchRequest{ - Pattern: in.Pattern, - Mode: in.Mode, - Scope: in.Scope, - Project: in.Project, - Agent: in.Agent, - DateFrom: in.DateFrom, - DateTo: in.DateTo, - Limit: clampLimit(in.Limit, defaultSearchLimit, maxSearchLimit), - Cursor: in.Cursor, - Context: in.Context, + Pattern: in.Pattern, + Mode: in.Mode, + Scope: in.Scope, + Project: in.Project, + Agent: in.Agent, + GitBranch: gitBranch, + DateFrom: in.DateFrom, + DateTo: in.DateTo, + Limit: clampLimit(in.Limit, defaultSearchLimit, maxSearchLimit), + Cursor: in.Cursor, + Context: in.Context, }) if err != nil { return nil, searchContentOut{}, err @@ -647,22 +669,35 @@ func (t *toolset) searchContent( // --- get_usage_summary --- type usageSummaryIn struct { - From string `json:"from,omitempty" jsonschema:"Range start date (YYYY-MM-DD)."` - To string `json:"to,omitempty" jsonschema:"Range end date (YYYY-MM-DD)."` - Project string `json:"project,omitempty" jsonschema:"Filter by project."` - Agent string `json:"agent,omitempty" jsonschema:"Filter by agent."` - Machine string `json:"machine,omitempty" jsonschema:"Filter by machine."` + From string `json:"from,omitempty" jsonschema:"Range start date (YYYY-MM-DD)."` + To string `json:"to,omitempty" jsonschema:"Range end date (YYYY-MM-DD)."` + Project string `json:"project,omitempty" jsonschema:"Filter by project."` + Agent string `json:"agent,omitempty" jsonschema:"Filter by agent."` + Machine string `json:"machine,omitempty" jsonschema:"Filter by machine."` + GitBranch string `json:"git_branch,omitempty" jsonschema:"Filter by git branch name. Requires project; the branch is scoped to that project."` } func (t *toolset) usageSummary( ctx context.Context, _ *mcp.CallToolRequest, in usageSummaryIn, ) (*mcp.CallToolResult, *service.UsageSummaryResult, error) { + // The usage filter accepts a comma-separated project list, but a branch + // token binds to exactly one project; encoding a CSV project into the + // token would AND contradictory predicates and silently return zeros. + if in.GitBranch != "" && strings.Contains(in.Project, ",") { + return nil, nil, errors.New( + "git_branch requires a single project, not a comma-separated list") + } + gitBranch, err := mcpBranchToken(in.Project, in.GitBranch) + if err != nil { + return nil, nil, err + } res, err := t.svc.UsageSummary(ctx, service.UsageRequest{ - From: in.From, - To: in.To, - Project: in.Project, - Agent: in.Agent, - Machine: in.Machine, + From: in.From, + To: in.To, + Project: in.Project, + Agent: in.Agent, + Machine: in.Machine, + GitBranch: gitBranch, // The usage summary surface counts one-shot sessions by default // (matching the REST endpoint), since cost analysis wants every // session, not just multi-turn ones. diff --git a/internal/postgres/activityreport.go b/internal/postgres/activityreport.go index 86fd3f8e6..506557e60 100644 --- a/internal/postgres/activityreport.go +++ b/internal/postgres/activityreport.go @@ -278,7 +278,8 @@ func (s *Store) activityReportSessions( s.machine, s.started_at, s.ended_at, - COALESCE(s.is_automated, false) AS is_automated + COALESCE(s.is_automated, false) AS is_automated, + s.git_branch FROM sessions s WHERE ` + where + ` AND COALESCE(s.ended_at, @@ -304,6 +305,7 @@ func (s *Store) activityReportSessions( if err := rows.Scan( &m.SessionID, &m.Title, &m.Project, &m.Agent, &m.Machine, &startedAt, &endedAt, &m.IsAutomated, + &m.GitBranch, ); err != nil { return nil, nil, fmt.Errorf( "scanning activity report session: %w", err) diff --git a/internal/postgres/push_pgtest_test.go b/internal/postgres/push_pgtest_test.go index deb4cc12b..c99976f81 100644 --- a/internal/postgres/push_pgtest_test.go +++ b/internal/postgres/push_pgtest_test.go @@ -2027,6 +2027,7 @@ func TestPushSyncsCursorUsageEventsIntoPGDailyUsage(t *testing.T) { assert.Empty(t, result.SessionCounts.ByProject) require.Len(t, result.Daily[0].AgentBreakdowns, 1) assert.Equal(t, "cursor", result.Daily[0].AgentBreakdowns[0].Agent) + assert.Empty(t, result.Daily[0].BranchBreakdowns, "cursor-only usage has no branch attribution") } func TestPushCursorUsageEventsDedupsAfterLegacyMoneyMigration(t *testing.T) { diff --git a/internal/postgres/sessions.go b/internal/postgres/sessions.go index 1f7dc3ab0..4035245df 100644 --- a/internal/postgres/sessions.go +++ b/internal/postgres/sessions.go @@ -1175,41 +1175,43 @@ func (s *Store) GetMachines( return machines, rows.Err() } -// GetBranches mirrors db.DB.GetBranches: distinct (project, branch) pairs, -// including the empty no-branch value, scoped to root sessions with messages. +// GetBranches mirrors db.DB.GetBranches for PostgreSQL. func (s *Store) GetBranches( - ctx context.Context, - excludeOneShot, excludeAutomated bool, -) ([]db.BranchInfo, error) { - q := `SELECT DISTINCT project, git_branch FROM sessions + ctx context.Context, query db.BranchQuery, +) (db.BranchResult, error) { + query = db.NormalizeBranchQuery(query) + q := `SELECT git_branch FROM sessions WHERE message_count > 0 - AND relationship_type NOT IN ('subagent', 'fork') AND deleted_at IS NULL` - if excludeOneShot { - if !excludeAutomated { + pb := ¶mBuilder{} + if query.Scope == db.BranchScopeRoots { + q += " AND relationship_type NOT IN ('subagent', 'fork')" + } + if query.ExcludeOneShot { + if !query.ExcludeAutomated { q += " AND (user_message_count > 1 OR is_automated = TRUE)" } else { q += " AND user_message_count > 1" } } - if excludeAutomated { + if query.ExcludeAutomated { q += " AND is_automated = FALSE" } - q += " ORDER BY project, git_branch" - rows, err := s.pg.QueryContext(ctx, q) + if len(query.Projects) > 0 { + q += " AND project IN " + pgInPlaceholders(query.Projects, pb) + } + if query.Search != "" { + q += ` AND git_branch ILIKE ` + pb.add( + "%"+db.EscapeLikePattern(query.Search)+"%") + ` ESCAPE '\'` + } + limitPlaceholder := pb.add(query.Limit + 1) + q += fmt.Sprintf(` GROUP BY git_branch + ORDER BY MAX(%s) DESC NULLS LAST, git_branch + LIMIT %s`, pgActivityExpr, limitPlaceholder) + rows, err := s.pg.QueryContext(ctx, q, pb.args...) if err != nil { - return nil, fmt.Errorf("querying branches: %w", err) + return db.BranchResult{}, fmt.Errorf("querying branches: %w", err) } defer rows.Close() - - branches := []db.BranchInfo{} - for rows.Next() { - var bi db.BranchInfo - if err := rows.Scan(&bi.Project, &bi.Branch); err != nil { - return nil, fmt.Errorf("scanning branch: %w", err) - } - bi.Token = db.EncodeBranchFilterToken(bi.Project, bi.Branch) - branches = append(branches, bi) - } - return branches, rows.Err() + return db.ScanBranchResult(rows, query) } diff --git a/internal/postgres/store_test.go b/internal/postgres/store_test.go index f959a8eab..2e97002b9 100644 --- a/internal/postgres/store_test.go +++ b/internal/postgres/store_test.go @@ -1599,3 +1599,54 @@ func TestStoreWriteSurfaceSplitByCapability(t *testing.T) { }) assert.ErrorIs(t, err, db.ErrReadOnly) } + +func TestStoreGetBranchesPickerQuery(t *testing.T) { + pgURL := testPGURL(t) + ensureStoreSchema(t, pgURL) + + store, err := NewStore(pgURL, testSchema, true) + require.NoError(t, err, "NewStore") + defer store.Close() + + // The ensureStoreSchema seed session (test-project, empty branch) ended + // 2026-03-12T10:30Z. br-3 has no ended_at so recency falls back to + // started_at; br-4/br-5 tie so the pair breaks the tie alphabetically. + _, err = store.DB().Exec(` + INSERT INTO sessions + (id, machine, project, agent, git_branch, + started_at, ended_at, message_count, user_message_count) + VALUES + ('br-1', 'm', 'alpha', 'claude', 'main', + '2026-03-14T09:00:00Z'::timestamptz, + '2026-03-14T10:00:00Z'::timestamptz, 2, 2), + ('br-2', 'm', 'alpha', 'claude', 'feat/x', + '2026-03-15T09:00:00Z'::timestamptz, + '2026-03-15T10:00:00Z'::timestamptz, 2, 2), + ('br-3', 'm', 'beta', 'claude', 'main', + '2026-03-14T12:00:00Z'::timestamptz, NULL, 2, 2), + ('br-4', 'm', 'gamma', 'claude', 'x', + '2026-03-10T09:00:00Z'::timestamptz, + '2026-03-10T10:00:00Z'::timestamptz, 2, 2), + ('br-5', 'm', 'delta', 'claude', 'x', + '2026-03-10T09:00:00Z'::timestamptz, + '2026-03-10T10:00:00Z'::timestamptz, 2, 2), + ('br-6', 'm', 'alpha', 'claude', 'feature-old', + '2026-03-13T09:00:00Z'::timestamptz, + '2026-03-13T10:00:00Z'::timestamptz, 2, 2), + ('br-7', 'm', 'gamma', 'claude', 'feat/x', + '2026-03-20T09:00:00Z'::timestamptz, + '2026-03-20T10:00:00Z'::timestamptz, 2, 2) + `) + require.NoError(t, err, "inserting branch sessions") + + branches, err := store.GetBranches(context.Background(), db.BranchQuery{ + Projects: []string{"alpha", "beta"}, + Search: "FEAT", + Limit: 1, + }) + require.NoError(t, err, "GetBranches") + assert.Equal(t, db.BranchResult{ + Branches: []db.BranchOption{{Branch: "feat/x"}}, + HasMore: true, + }, branches, "filter selected projects before dedupe and limit+1 pagination") +} diff --git a/internal/postgres/usage.go b/internal/postgres/usage.go index 31a887956..67ccc7d8b 100644 --- a/internal/postgres/usage.go +++ b/internal/postgres/usage.go @@ -146,6 +146,11 @@ func appendPGUsageSessionFilterClauses( where, "s.project", f.ExcludedProjectFilterLabels(), false, ) where = appendCSV(where, "s.agent", f.ExcludeAgent, false) + if f.ExcludeGitBranch != "" { + where += "\n\tAND " + db.BranchPairExcludePredicate( + "s.project", "s.git_branch", f.ExcludeGitBranch, + func(s string) string { return pb.add(s) }) + } if f.MinUserMessages > 0 { where += "\n\tAND s.user_message_count >= " + @@ -318,7 +323,8 @@ SELECT '' AS usage_dedup_key, s.project, s.agent, - s.machine + s.machine, + s.git_branch FROM messages m JOIN sessions s ON m.session_id = s.id WHERE %s @@ -348,7 +354,8 @@ SELECT END AS usage_dedup_key, s.project, s.agent, - s.machine + s.machine, + s.git_branch FROM usage_events ue JOIN sessions s ON s.id = ue.session_id WHERE %s` @@ -374,7 +381,8 @@ SELECT '' AS usage_dedup_key, s.project, s.agent, - s.machine + s.machine, + s.git_branch FROM %s m JOIN sessions s ON m.session_id = s.id WHERE %s` @@ -403,7 +411,8 @@ SELECT END AS usage_dedup_key, s.project, s.agent, - s.machine + s.machine, + s.git_branch FROM %s ue JOIN sessions s ON s.id = ue.session_id WHERE %s` @@ -538,6 +547,7 @@ type pgDailyUsageScanRow struct { project string agent string machine string + gitBranch string } type pgTopSessionMetadata struct { @@ -588,15 +598,18 @@ func pgUsageRowSelect() string { } func pgDailyUsageRowSelectFromRows(rowsSQL string) string { - return pgDailyUsageRowSelectFromRowsWithMachine(rowsSQL, false) + return pgDailyUsageRowSelectFromRowsWithBreakdowns(rowsSQL, false, false) } -func pgDailyUsageRowSelectFromRowsWithMachine( - rowsSQL string, includeMachine bool, +func pgDailyUsageRowSelectFromRowsWithBreakdowns( + rowsSQL string, includeBreakdowns, includeBranchBreakdowns bool, ) string { - machineColumn := "" - if includeMachine { - machineColumn = ",\n\tu.machine" + breakdownColumns := "" + if includeBreakdowns { + breakdownColumns += ",\n\tu.machine" + } + if includeBranchBreakdowns { + breakdownColumns += ",\n\tu.git_branch" } return ` SELECT @@ -618,7 +631,7 @@ SELECT u.source_uuid, u.usage_dedup_key, u.project, - u.agent` + machineColumn + ` + u.agent` + breakdownColumns + ` FROM (` + rowsSQL + `) u WHERE 1=1` } @@ -765,21 +778,18 @@ SELECT cu.dedup_key AS usage_dedup_key, '' AS project, 'cursor' AS agent, - '' AS machine + '' AS machine, + '' AS git_branch FROM cursor_usage_events cu WHERE %s` func pgCursorUsageRowsSQLForBounds( pb *paramBuilder, f db.UsageFilter, b pgUsageBounds, ) (string, bool) { + // Any explicit termination filter other than "all" drops cursor rows, + // even values the session-row path would ignore as unrecognized. hasTermFilter := f.Termination != "" && f.Termination != "all" - // Cursor usage rows carry no project or git branch and bypass the session - // filter, so any filter they cannot satisfy (project, machine, branch) - // must exclude them entirely rather than let them leak into totals. - if len(f.ProjectFilterLabels()) > 0 || - len(f.ExcludedProjectFilterLabels()) > 0 || - f.Machine != "" || f.GitBranch != "" || f.MinUserMessages > 0 || - f.ExcludeOneShot || hasTermFilter || f.ActiveSince != "" { + if f.RequiresSessionScope() || hasTermFilter { return "", false } if f.Agent != "" { @@ -824,7 +834,9 @@ func pgDailyUsageRowQuery(pb *paramBuilder, f db.UsageFilter, hasCursorTable boo rowsSQL += "\n\nUNION ALL\n\n" + cursorRowsSQL } } - return pgDailyUsageRowSelectFromRowsWithMachine(rowsSQL, f.Breakdowns) + return pgDailyUsageRowSelectFromRowsWithBreakdowns( + rowsSQL, f.Breakdowns, f.BranchBreakdowns, + ) } func pgTopSessionsUsageRowQuery(pb *paramBuilder, f db.UsageFilter) string { @@ -865,11 +877,11 @@ func scanPGUsageRow(rows *sql.Rows) (pgUsageScanRow, error) { } func scanPGDailyUsageRow(rows *sql.Rows) (pgDailyUsageScanRow, error) { - return scanPGDailyUsageRowWithMachine(rows, false) + return scanPGDailyUsageRowWithBreakdowns(rows, false, false) } -func scanPGDailyUsageRowWithMachine( - rows *sql.Rows, includeMachine bool, +func scanPGDailyUsageRowWithBreakdowns( + rows *sql.Rows, includeBreakdowns, includeBranchBreakdowns bool, ) (pgDailyUsageScanRow, error) { var r pgDailyUsageScanRow dest := []any{ @@ -893,9 +905,12 @@ func scanPGDailyUsageRowWithMachine( &r.project, &r.agent, } - if includeMachine { + if includeBreakdowns { dest = append(dest, &r.machine) } + if includeBranchBreakdowns { + dest = append(dest, &r.gitBranch) + } err := rows.Scan(dest...) return r, err } @@ -1423,24 +1438,19 @@ func (s *Store) GetDailyUsage( defer rows.Close() type accumKey struct { - date string - project string - agent string - machine string - model string - } - type bucket struct { - inputTok int - outputTok int - cacheCr int - cacheRd int - cost money.Money + date string + project string + agent string + machine string + model string + gitBranch string + branchAttributed bool } type sessionCost struct { estimated map[accumKey]money.Money authoritative *money.Money } - accum := make(map[accumKey]*bucket) + accum := make(map[accumKey]*db.UsageBucket) sessionCosts := make(map[string]sessionCost) useAuthoritativeCost := f.Model == "" && f.ExcludeModel == "" seen := make(map[pgUsageDedupToken]struct{}) @@ -1452,7 +1462,9 @@ func (s *Store) GetDailyUsage( var totalSavings money.Money for rows.Next() { - r, scanErr := scanPGDailyUsageRowWithMachine(rows, f.Breakdowns) + r, scanErr := scanPGDailyUsageRowWithBreakdowns( + rows, f.Breakdowns, f.BranchBreakdowns, + ) if scanErr != nil { return db.DailyUsageResult{}, fmt.Errorf("scanning daily usage row: %w", scanErr) @@ -1499,19 +1511,28 @@ func (s *Store) GetDailyUsage( "summing pg daily usage cache savings: %w", priceErr) } + // Leave gitBranch out of the accumulator key unless breakdowns are + // requested, so a plain totals query still sums one row per + // (date, project, agent, model) instead of splitting by branch too. + gitBranch := "" + branchAttributed := f.BranchBreakdowns && r.usageSource != "cursor" + if branchAttributed { + gitBranch = r.gitBranch + } key := accumKey{ date: date, project: r.project, agent: r.agent, machine: r.machine, model: r.model, + gitBranch: gitBranch, branchAttributed: branchAttributed, } b, ok := accum[key] if !ok { - b = &bucket{} + b = &db.UsageBucket{} accum[key] = b } - b.inputTok += inputTok - b.outputTok += outputTok - b.cacheCr += cacheCrTok - b.cacheRd += cacheRdTok + b.InputTok += inputTok + b.OutputTok += outputTok + b.CacheCr += cacheCrTok + b.CacheRd += cacheRdTok sc := sessionCosts[r.sessionID] if sc.estimated == nil { @@ -1561,7 +1582,10 @@ func (s *Store) GetDailyUsage( if a.machine != b.machine { return a.machine < b.machine } - return a.model < b.model + if a.model != b.model { + return a.model < b.model + } + return a.gitBranch < b.gitBranch }) weights := make([]money.Money, len(keys)) for i, key := range keys { @@ -1571,10 +1595,10 @@ func (s *Store) GetDailyUsage( for i, key := range keys { b := accum[key] if b == nil { - b = &bucket{} + b = &db.UsageBucket{} accum[key] = b } - b.cost, err = money.Add(b.cost, costs[i]) + b.Cost, err = money.Add(b.Cost, costs[i]) if err != nil { return db.DailyUsageResult{}, fmt.Errorf( "summing allocated pg daily usage cost: %w", err) @@ -1584,10 +1608,10 @@ func (s *Store) GetDailyUsage( for key, cost := range sc.estimated { b := accum[key] if b == nil { - b = &bucket{} + b = &db.UsageBucket{} accum[key] = b } - b.cost, err = money.Add(b.cost, cost) + b.Cost, err = money.Add(b.Cost, cost) if err != nil { return db.DailyUsageResult{}, fmt.Errorf( "summing estimated pg daily usage cost: %w", err) @@ -1596,7 +1620,7 @@ func (s *Store) GetDailyUsage( } } - if !f.Breakdowns { + if !f.Breakdowns && !f.BranchBreakdowns { type dateModelKey struct { date string model string @@ -1616,11 +1640,11 @@ func (s *Store) GetDailyUsage( ma = &modelAccum{} dm[dmk] = ma } - ma.inputTok += b.inputTok - ma.outputTok += b.outputTok - ma.cacheCr += b.cacheCr - ma.cacheRd += b.cacheRd - ma.cost, err = money.Add(ma.cost, b.cost) + ma.inputTok += b.InputTok + ma.outputTok += b.OutputTok + ma.cacheCr += b.CacheCr + ma.cacheRd += b.CacheRd + ma.cost, err = money.Add(ma.cost, b.Cost) if err != nil { return db.DailyUsageResult{}, fmt.Errorf( "summing pg daily model cost: %w", err) @@ -1714,7 +1738,7 @@ func (s *Store) GetDailyUsage( var aiCredits float64 for key, b := range accum { - aiCredits += db.AICreditsFromCost(key.agent, b.cost) + aiCredits += db.AICreditsFromCost(key.agent, b.Cost) } if aiCredits > 0 { totals.CopilotAICredits = aiCredits @@ -1748,71 +1772,52 @@ func (s *Store) GetDailyUsage( }, nil } + type branchMapKey struct { + project string + branch string + } type dayMaps struct { - models map[string]bucket - projects map[string]bucket - agents map[string]bucket - machines map[string]bucket + models map[string]db.UsageBucket + projects map[string]db.UsageBucket + agents map[string]db.UsageBucket + machines map[string]db.UsageBucket + branches map[branchMapKey]db.UsageBucket } days := make(map[string]*dayMaps, 64) for key, b := range accum { dm, ok := days[key.date] if !ok { dm = &dayMaps{ - models: make(map[string]bucket, 4), - projects: make(map[string]bucket, 8), - agents: make(map[string]bucket, 4), - machines: make(map[string]bucket, 4), + models: make(map[string]db.UsageBucket, 4), + projects: make(map[string]db.UsageBucket, 8), + agents: make(map[string]db.UsageBucket, 4), + machines: make(map[string]db.UsageBucket, 4), + branches: make(map[branchMapKey]db.UsageBucket, 8), } days[key.date] = dm } - cur := dm.models[key.model] - cur.inputTok += b.inputTok - cur.outputTok += b.outputTok - cur.cacheCr += b.cacheCr - cur.cacheRd += b.cacheRd - cur.cost, err = money.Add(cur.cost, b.cost) - if err != nil { - return db.DailyUsageResult{}, fmt.Errorf( - "summing pg model breakdown cost: %w", err) - } - dm.models[key.model] = cur - - cur = dm.projects[key.project] - cur.inputTok += b.inputTok - cur.outputTok += b.outputTok - cur.cacheCr += b.cacheCr - cur.cacheRd += b.cacheRd - cur.cost, err = money.Add(cur.cost, b.cost) - if err != nil { - return db.DailyUsageResult{}, fmt.Errorf( - "summing pg project breakdown cost: %w", err) + if err := db.AddUsageBucket(dm.models, key.model, *b); err != nil { + return db.DailyUsageResult{}, err } - dm.projects[key.project] = cur - - cur = dm.agents[key.agent] - cur.inputTok += b.inputTok - cur.outputTok += b.outputTok - cur.cacheCr += b.cacheCr - cur.cacheRd += b.cacheRd - cur.cost, err = money.Add(cur.cost, b.cost) - if err != nil { - return db.DailyUsageResult{}, fmt.Errorf( - "summing pg agent breakdown cost: %w", err) + if f.Breakdowns { + if err := db.AddUsageBucket(dm.projects, key.project, *b); err != nil { + return db.DailyUsageResult{}, err + } + if err := db.AddUsageBucket(dm.agents, key.agent, *b); err != nil { + return db.DailyUsageResult{}, err + } + if err := db.AddUsageBucket(dm.machines, key.machine, *b); err != nil { + return db.DailyUsageResult{}, err + } } - dm.agents[key.agent] = cur - - cur = dm.machines[key.machine] - cur.inputTok += b.inputTok - cur.outputTok += b.outputTok - cur.cacheCr += b.cacheCr - cur.cacheRd += b.cacheRd - cur.cost, err = money.Add(cur.cost, b.cost) - if err != nil { - return db.DailyUsageResult{}, fmt.Errorf( - "summing pg machine breakdown cost: %w", err) + if f.BranchBreakdowns && key.branchAttributed { + if err := db.AddUsageBucket(dm.branches, branchMapKey{ + project: key.project, + branch: key.gitBranch, + }, *b); err != nil { + return db.DailyUsageResult{}, err + } } - dm.machines[key.machine] = cur } dateKeys := make([]string, 0, len(days)) @@ -1838,8 +1843,8 @@ func (s *Store) GetDailyUsage( sort.Slice(modelNames, func(i, j int) bool { left := dm.models[modelNames[i]] right := dm.models[modelNames[j]] - if left.cost.Microdollars != right.cost.Microdollars { - return left.cost.Microdollars > right.cost.Microdollars + if left.Cost.Microdollars != right.Cost.Microdollars { + return left.Cost.Microdollars > right.Cost.Microdollars } return modelNames[i] < modelNames[j] }) @@ -1847,22 +1852,22 @@ func (s *Store) GetDailyUsage( mbd := make([]db.ModelBreakdown, 0, len(modelNames)) for _, m := range modelNames { b := dm.models[m] - entry.InputTokens += b.inputTok - entry.OutputTokens += b.outputTok - entry.CacheCreationTokens += b.cacheCr - entry.CacheReadTokens += b.cacheRd - entry.TotalCost, err = money.Add(entry.TotalCost, b.cost) + entry.InputTokens += b.InputTok + entry.OutputTokens += b.OutputTok + entry.CacheCreationTokens += b.CacheCr + entry.CacheReadTokens += b.CacheRd + entry.TotalCost, err = money.Add(entry.TotalCost, b.Cost) if err != nil { return db.DailyUsageResult{}, fmt.Errorf( "summing pg breakdown entry cost: %w", err) } mbd = append(mbd, db.ModelBreakdown{ ModelName: m, - InputTokens: b.inputTok, - OutputTokens: b.outputTok, - CacheCreationTokens: b.cacheCr, - CacheReadTokens: b.cacheRd, - Cost: b.cost, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, }) } entry.ModelBreakdowns = mbd @@ -1871,11 +1876,11 @@ func (s *Store) GetDailyUsage( for p, b := range dm.projects { pbd = append(pbd, db.ProjectBreakdown{ Project: p, - InputTokens: b.inputTok, - OutputTokens: b.outputTok, - CacheCreationTokens: b.cacheCr, - CacheReadTokens: b.cacheRd, - Cost: b.cost, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, }) } sort.Slice(pbd, func(i, j int) bool { @@ -1890,11 +1895,11 @@ func (s *Store) GetDailyUsage( for a, b := range dm.agents { abd = append(abd, db.AgentBreakdown{ Agent: a, - InputTokens: b.inputTok, - OutputTokens: b.outputTok, - CacheCreationTokens: b.cacheCr, - CacheReadTokens: b.cacheRd, - Cost: b.cost, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, }) } sort.Slice(abd, func(i, j int) bool { @@ -1911,11 +1916,11 @@ func (s *Store) GetDailyUsage( for machine, b := range dm.machines { machineBreakdowns = append(machineBreakdowns, db.MachineBreakdown{ MachineName: machine, - InputTokens: b.inputTok, - OutputTokens: b.outputTok, - CacheCreationTokens: b.cacheCr, - CacheReadTokens: b.cacheRd, - Cost: b.cost, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, }) } sort.Slice(machineBreakdowns, func(i, j int) bool { @@ -1926,6 +1931,23 @@ func (s *Store) GetDailyUsage( }) entry.MachineBreakdowns = machineBreakdowns + if f.BranchBreakdowns { + bbd := make([]db.BranchBreakdown, 0, len(dm.branches)) + for bk, b := range dm.branches { + bbd = append(bbd, db.BranchBreakdown{ + Project: bk.project, + Branch: bk.branch, + InputTokens: b.InputTok, + OutputTokens: b.OutputTok, + CacheCreationTokens: b.CacheCr, + CacheReadTokens: b.CacheRd, + Cost: b.Cost, + }) + } + db.SortBranchBreakdowns(bbd) + entry.BranchBreakdowns = bbd + } + daily = append(daily, entry) totals.InputTokens += entry.InputTokens totals.OutputTokens += entry.OutputTokens diff --git a/internal/postgres/usage_pgtest_test.go b/internal/postgres/usage_pgtest_test.go index cfec88a45..4d075b775 100644 --- a/internal/postgres/usage_pgtest_test.go +++ b/internal/postgres/usage_pgtest_test.go @@ -137,14 +137,27 @@ func TestStoreGetDailyUsageWithBreakdowns(t *testing.T) { '{"input_tokens":500000,"output_tokens":250000}')`) require.NoError(t, err, "insert messages") - result, err := store.GetDailyUsage(ctx, db.UsageFilter{ + withoutBranches, err := store.GetDailyUsage(ctx, db.UsageFilter{ From: "2026-03-12", To: "2026-03-12", Timezone: "UTC", Breakdowns: true, }) + require.NoError(t, err, "GetDailyUsage without branch breakdowns") + require.Len(t, withoutBranches.Daily, 1) + assert.Empty(t, withoutBranches.Daily[0].BranchBreakdowns) + assert.NotEmpty(t, withoutBranches.Daily[0].ProjectBreakdowns) + + result, err := store.GetDailyUsage(ctx, db.UsageFilter{ + From: "2026-03-12", + To: "2026-03-12", + Timezone: "UTC", + Breakdowns: true, + BranchBreakdowns: true, + }) require.NoError(t, err, "GetDailyUsage") require.Len(t, result.Daily, 1) + assert.Equal(t, withoutBranches.Totals, result.Totals) day := result.Daily[0] assert.Equal(t, 1500000, day.InputTokens) assert.Equal(t, 750000, day.OutputTokens) @@ -1513,3 +1526,139 @@ func TestStoreGetSessionUsage_CopilotUnpricedNoCost(t *testing.T) { assert.Zero(t, u.Cost, "Cost should be zero when unpriced") assert.Equal(t, 0.0, u.AICredits, "AICredits should be 0 when unpriced") } + +// TestStoreGetDailyUsageBranchBreakdowns guards the (project, branch) +// accumulator against a real Postgres server. +func TestStoreGetDailyUsageBranchBreakdowns(t *testing.T) { + _, store := prepareUsageSchema(t, "agentsview_usage_branch_test") + + ctx := context.Background() + _, err := store.DB().ExecContext(ctx, ` + INSERT INTO sessions ( + id, machine, project, agent, git_branch, started_at, + message_count, user_message_count + ) VALUES + ('usage-branch-001', 'test-machine', 'proj-a', 'claude', 'main', + '2026-03-12T10:00:00Z'::timestamptz, 1, 1), + ('usage-branch-002', 'test-machine', 'proj-a', 'claude', 'feature-x', + '2026-03-12T10:05:00Z'::timestamptz, 1, 1), + ('usage-branch-003', 'test-machine', 'proj-b', 'claude', 'main', + '2026-03-12T10:10:00Z'::timestamptz, 1, 1), + ('usage-branch-004', 'test-machine', 'proj-a', 'claude', '', + '2026-03-12T10:15:00Z'::timestamptz, 1, 1)`) + require.NoError(t, err, "insert sessions") + _, err = store.DB().ExecContext(ctx, ` + INSERT INTO messages ( + session_id, ordinal, role, content, timestamp, content_length, + model, token_usage + ) VALUES + ('usage-branch-001', 0, 'assistant', 'one', + '2026-03-12T10:00:00Z'::timestamptz, 3, 'test-model-a', + '{"input_tokens":100000}'), + ('usage-branch-002', 0, 'assistant', 'two', + '2026-03-12T10:05:00Z'::timestamptz, 3, 'test-model-a', + '{"input_tokens":200000}'), + ('usage-branch-003', 0, 'assistant', 'three', + '2026-03-12T10:10:00Z'::timestamptz, 3, 'test-model-a', + '{"input_tokens":300000}'), + ('usage-branch-004', 0, 'assistant', 'four', + '2026-03-12T10:15:00Z'::timestamptz, 3, 'test-model-a', + '{"input_tokens":400000}')`) + require.NoError(t, err, "insert messages") + + result, err := store.GetDailyUsage(ctx, db.UsageFilter{ + From: "2026-03-12", + To: "2026-03-12", + Timezone: "UTC", + Breakdowns: true, + BranchBreakdowns: true, + }) + require.NoError(t, err, "GetDailyUsage") + require.Len(t, result.Daily, 1) + + byKey := map[db.BranchInfo]db.BranchBreakdown{} + for _, b := range result.Daily[0].BranchBreakdowns { + byKey[db.BranchInfo{Project: b.Project, Branch: b.Branch}] = b + } + require.Len(t, byKey, 4, "one bucket per distinct (project, branch)") + assert.Equal(t, 100000, + byKey[db.BranchInfo{Project: "proj-a", Branch: "main"}].InputTokens) + assert.Equal(t, 200000, + byKey[db.BranchInfo{Project: "proj-a", Branch: "feature-x"}].InputTokens) + assert.Equal(t, 300000, + byKey[db.BranchInfo{Project: "proj-b", Branch: "main"}].InputTokens, + "proj-b/main is distinct from proj-a/main") + assert.Equal(t, 400000, + byKey[db.BranchInfo{Project: "proj-a", Branch: ""}].InputTokens, + "empty branch bucket") +} + +// TestStoreGetDailyUsageExcludeGitBranchFilter guards the (project, branch) +// exclude predicate against a real Postgres server. +func TestStoreGetDailyUsageExcludeGitBranchFilter(t *testing.T) { + _, store := prepareUsageSchema(t, "agentsview_usage_exbranch_test") + + ctx := context.Background() + _, err := store.DB().ExecContext(ctx, ` + INSERT INTO sessions ( + id, machine, project, agent, git_branch, started_at, + message_count, user_message_count + ) VALUES + ('usage-exbranch-001', 'test-machine', 'proj-a', 'claude', 'main', + '2026-03-12T10:00:00Z'::timestamptz, 1, 1), + ('usage-exbranch-002', 'test-machine', 'proj-a', 'claude', 'feature-x', + '2026-03-12T10:05:00Z'::timestamptz, 1, 1), + ('usage-exbranch-003', 'test-machine', 'proj-b', 'claude', 'main', + '2026-03-12T10:10:00Z'::timestamptz, 1, 1)`) + require.NoError(t, err, "insert sessions") + _, err = store.DB().ExecContext(ctx, ` + INSERT INTO messages ( + session_id, ordinal, role, content, timestamp, content_length, + model, token_usage + ) VALUES + ('usage-exbranch-001', 0, 'assistant', 'one', + '2026-03-12T10:00:00Z'::timestamptz, 3, 'test-model-a', + '{"input_tokens":100000}'), + ('usage-exbranch-002', 0, 'assistant', 'two', + '2026-03-12T10:05:00Z'::timestamptz, 3, 'test-model-a', + '{"input_tokens":200000}'), + ('usage-exbranch-003', 0, 'assistant', 'three', + '2026-03-12T10:10:00Z'::timestamptz, 3, 'test-model-a', + '{"input_tokens":300000}')`) + require.NoError(t, err, "insert messages") + + tests := []struct { + name string + excludeGitBranch string + wantInput int + }{ + { + name: "single pair excluded, same-named branch stays", + excludeGitBranch: db.EncodeBranchFilterToken("proj-a", "main"), + wantInput: 500000, + }, + { + name: "multiple pairs excluded", + excludeGitBranch: db.EncodeBranchFilterToken("proj-a", "main") + + "\x1e" + db.EncodeBranchFilterToken("proj-b", "main"), + wantInput: 200000, + }, + { + name: "malformed token excludes nothing", + excludeGitBranch: "no-separator-here", + wantInput: 600000, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := store.GetDailyUsage(ctx, db.UsageFilter{ + From: "2026-03-12", + To: "2026-03-12", + Timezone: "UTC", + ExcludeGitBranch: tt.excludeGitBranch, + }) + require.NoError(t, err, "GetDailyUsage") + assert.Equal(t, tt.wantInput, result.Totals.InputTokens) + }) + } +} diff --git a/internal/recall/extract/manager_test.go b/internal/recall/extract/manager_test.go index 008defe06..b27992fcd 100644 --- a/internal/recall/extract/manager_test.go +++ b/internal/recall/extract/manager_test.go @@ -264,9 +264,7 @@ func TestManagerRunPassExtractsMapsAndActivates(t *testing.T) { if err != nil { t.Fatalf("GetRecallEntry: %v", err) } - if entry == nil { - t.Fatal("expected entry at deterministic id for unit 0") - } + require.NotNil(t, entry, "expected entry at deterministic id for unit 0") if entry.Type != "decision" || entry.Title != "t" { t.Fatalf("entry type/title = %s/%s", entry.Type, entry.Title) } diff --git a/internal/server/huma_routes_activity.go b/internal/server/huma_routes_activity.go index ab2924fdf..44a0efdfb 100644 --- a/internal/server/huma_routes_activity.go +++ b/internal/server/huma_routes_activity.go @@ -23,7 +23,7 @@ type activityReportInput struct { Timezone string `query:"timezone" doc:"IANA timezone name"` Bucket string `query:"bucket" enum:"5m,15m,1h,1d,1w" doc:"Timeline bucket size override"` Project string `query:"project" doc:"Filter by project"` - GitBranch string `query:"git_branch" doc:"Filter by git branch; opaque (project, branch) tokens from the /branches endpoint"` + GitBranch string `query:"git_branch" doc:"Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted"` Agent string `query:"agent" doc:"Filter by agent"` Machine string `query:"machine" doc:"Filter by machine"` // Automation classes the report: "all" (default) keeps both, "interactive" diff --git a/internal/server/huma_routes_analytics.go b/internal/server/huma_routes_analytics.go index b6c1778f0..1e48a99b6 100644 --- a/internal/server/huma_routes_analytics.go +++ b/internal/server/huma_routes_analytics.go @@ -39,7 +39,7 @@ type AnalyticsFilterInput struct { Timezone string `query:"timezone" doc:"IANA timezone name"` Machine string `query:"machine" doc:"Filter by machine"` Project string `query:"project" doc:"Filter by project"` - GitBranch string `query:"git_branch" doc:"Filter by git branch; opaque (project, branch) tokens from the /branches endpoint"` + GitBranch string `query:"git_branch" doc:"Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted"` Agent string `query:"agent" doc:"Filter by agent"` Model string `query:"model" doc:"Comma-separated model filter"` DayOfWeek optionalIntParam `query:"dow" minimum:"0" maximum:"6" doc:"Day of week, Monday=0 through Sunday=6"` diff --git a/internal/server/huma_routes_metadata.go b/internal/server/huma_routes_metadata.go index 7e5256aa5..0568da2f4 100644 --- a/internal/server/huma_routes_metadata.go +++ b/internal/server/huma_routes_metadata.go @@ -47,9 +47,7 @@ type machinesResponse struct { Machines []string `json:"machines"` } -type branchesResponse struct { - Branches []db.BranchInfo `json:"branches"` -} +type branchesResponse = db.BranchResult type agentsResponse struct { Agents []db.AgentInfo `json:"agents"` @@ -126,15 +124,36 @@ func (s *Server) humaListMachines( return &jsonOutput[machinesResponse]{Body: machinesResponse{Machines: machines}}, nil } +type branchScopeParam string + +type branchesInput struct { + BoolIncludeInput + Scope branchScopeParam `query:"scope" enum:"roots,all" doc:"Session scope: roots (default) counts only root sessions; all also counts subagent and fork sessions, matching the activity and usage rollups"` + Projects []string `query:"projects,explode" doc:"Restrict to these projects before deduplicating branch names"` + Search string `query:"search" doc:"Case-insensitive branch name substring"` + Limit int `query:"limit" minimum:"1" maximum:"100" default:"100" doc:"Maximum number of branch names"` +} + func (s *Server) humaListBranches( ctx context.Context, - in *statsInput, + in *branchesInput, ) (*jsonOutput[branchesResponse], error) { - branches, err := s.db.GetBranches(ctx, !in.IncludeOneShot, !in.IncludeAutomated) + scope := db.BranchScopeRoots + if in.Scope == "all" { + scope = db.BranchScopeAll + } + branches, err := s.db.GetBranches(ctx, db.BranchQuery{ + Projects: in.Projects, + Search: in.Search, + Limit: in.Limit, + Scope: scope, + ExcludeOneShot: !in.IncludeOneShot, + ExcludeAutomated: !in.IncludeAutomated, + }) if err != nil { return nil, serverError(err) } - return &jsonOutput[branchesResponse]{Body: branchesResponse{Branches: branches}}, nil + return &jsonOutput[branchesResponse]{Body: branches}, nil } func (s *Server) humaListAgents( diff --git a/internal/server/huma_routes_search.go b/internal/server/huma_routes_search.go index acfe1d0e1..38b2dcf44 100644 --- a/internal/server/huma_routes_search.go +++ b/internal/server/huma_routes_search.go @@ -42,7 +42,7 @@ type contentSearchInput struct { Project string `query:"project" doc:"Filter by project"` ExcludeProject string `query:"exclude_project" doc:"Exclude a project"` Machine string `query:"machine" doc:"Filter by machine"` - GitBranch string `query:"git_branch" doc:"Filter by git branch; opaque (project, branch) tokens from the /branches endpoint"` + GitBranch string `query:"git_branch" doc:"Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted"` Agent string `query:"agent" doc:"Filter by agent"` Date string `query:"date" format:"date" doc:"Filter sessions active on this YYYY-MM-DD date"` DateFrom string `query:"date_from" format:"date" doc:"Filter sessions active on or after this date"` diff --git a/internal/server/huma_routes_sessions.go b/internal/server/huma_routes_sessions.go index d0f76a757..ea8cbaffa 100644 --- a/internal/server/huma_routes_sessions.go +++ b/internal/server/huma_routes_sessions.go @@ -63,7 +63,7 @@ type sessionFilterInput struct { Project string `query:"project" doc:"Filter by project"` ExcludeProject string `query:"exclude_project" doc:"Exclude a project"` Machine string `query:"machine" doc:"Filter by machine"` - GitBranch string `query:"git_branch" doc:"Filter by git branch; opaque (project, branch) tokens from the /branches endpoint"` + GitBranch string `query:"git_branch" doc:"Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted"` Agent string `query:"agent" doc:"Filter by agent"` Date string `query:"date" format:"date" doc:"Filter sessions active on this YYYY-MM-DD date"` DateFrom string `query:"date_from" format:"date" doc:"Filter sessions active on or after this date"` diff --git a/internal/server/huma_routes_usage.go b/internal/server/huma_routes_usage.go index 3744608d2..d411e8d8f 100644 --- a/internal/server/huma_routes_usage.go +++ b/internal/server/huma_routes_usage.go @@ -48,7 +48,8 @@ type UsageFilterInput struct { Agent string `query:"agent" doc:"Filter by agent"` Project string `query:"project" doc:"Filter by project"` Machine string `query:"machine" doc:"Filter by machine"` - GitBranch string `query:"git_branch" doc:"Filter by git branch; opaque (project, branch) tokens from the /branches endpoint"` + GitBranch string `query:"git_branch" doc:"Filter by branch name; multiple names use the branch list separator, and legacy project-qualified values remain accepted"` + ExcludeGitBranch string `query:"exclude_git_branch" doc:"Exclude branch names; multiple names use the branch list separator, and legacy project-qualified values remain accepted"` ExcludeProject string `query:"exclude_project" doc:"Exclude a project"` ExcludeProjectKey string `query:"exclude_project_key" doc:"Exclude an opaque project key"` ExcludeAgent string `query:"exclude_agent" doc:"Exclude an agent"` @@ -61,6 +62,7 @@ type UsageFilterInput struct { IncludeAutomated bool `query:"include_automated" doc:"Include automated sessions"` NoDefaultRange bool `query:"no_default_range" doc:"Preserve omitted from/to without applying default range"` Breakdowns bool `query:"breakdowns" default:"true" doc:"Include per-model, per-project, and per-agent breakdowns"` + BranchBreakdowns bool `query:"branch_breakdowns" default:"false" doc:"Include per-project branch breakdowns"` SessionCounts bool `query:"session_counts" default:"true" doc:"Include distinct session counts"` } @@ -95,6 +97,7 @@ func usageRequestFromInput(in UsageFilterInput) service.UsageRequest { Project: in.Project, Machine: in.Machine, GitBranch: in.GitBranch, + ExcludeGitBranch: in.ExcludeGitBranch, ExcludeProject: in.ExcludeProject, ExcludeProjectKey: in.ExcludeProjectKey, ExcludeAgent: in.ExcludeAgent, @@ -107,6 +110,7 @@ func usageRequestFromInput(in UsageFilterInput) service.UsageRequest { IncludeAutomated: in.IncludeAutomated, NoDefaultRange: in.NoDefaultRange, Breakdowns: &in.Breakdowns, + BranchBreakdowns: &in.BranchBreakdowns, SessionCounts: &in.SessionCounts, } } @@ -268,6 +272,7 @@ func (s *Server) computeUsageComparison( priorFilter.ProjectLabels = slices.Clone(f.ProjectLabels) priorFilter.ExcludeProjectLabels = slices.Clone(f.ExcludeProjectLabels) priorFilter.Breakdowns = false + priorFilter.BranchBreakdowns = false priorResult, err := s.db.GetDailyUsage(ctx, priorFilter) if err != nil { return nil, err diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 06a2e2de5..8e18145ef 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1409,6 +1409,17 @@ type projectListResponse struct { Projects []db.ProjectInfo `json:"projects"` } +type branchPickerItem struct { + Branch string `json:"branch"` + Project *string `json:"project"` + Token *string `json:"token"` +} + +type branchPickerResponse struct { + Branches []branchPickerItem `json:"branches"` + HasMore bool `json:"has_more"` +} + type syncStatusResponse struct { LastSync string `json:"last_sync"` Progress *sync.Progress `json:"progress"` @@ -2440,6 +2451,38 @@ func TestSessionStats_DefaultVisibilityMatchesListDefaults(t *testing.T) { assert.Equal(t, 21, resp.Totals.MessagesTotal, "include_all messages_total") } +func TestListBranchesPickerFiltersSearchesDeduplicatesAndPaginates(t *testing.T) { + te := setup(t) + seed := func(id, project, branch, endedAt string) { + te.seedSession(t, id, project, 5, func(s *db.Session) { + s.GitBranch = branch + s.UserMessageCount = 5 + s.EndedAt = new(endedAt) + }) + } + + seed("selected-old", "alpha", "feature-old", "2026-06-13T10:00:00Z") + seed("selected-last", "beta", "feature-last", "2026-06-11T10:00:00Z") + seed("selected-shared-a", "alpha", "feature-shared", "2026-06-10T10:00:00Z") + seed("selected-shared-b", "beta", "feature-shared", "2026-06-09T10:00:00Z") + seed("unselected-shared", "gamma", "feature-shared", "2026-06-20T10:00:00Z") + seed("search-miss", "beta", "bugfix", "2026-06-30T10:00:00Z") + + w := te.get(t, "/api/v1/branches?projects=alpha&projects=beta&search=FEATURE&limit=2") + assertStatus(t, w, http.StatusOK) + resp := decode[branchPickerResponse](t, w) + require.Len(t, resp.Branches, 2) + assert.Equal(t, []string{"feature-old", "feature-last"}, []string{ + resp.Branches[0].Branch, + resp.Branches[1].Branch, + }) + assert.True(t, resp.HasMore) + for _, branch := range resp.Branches { + assert.Nil(t, branch.Project, "picker results are not project-qualified") + assert.Nil(t, branch.Token, "picker results do not expose filter tokens") + } +} + func TestListMachines_ExcludeOneShotDefault(t *testing.T) { te := setup(t) te.seedSession(t, "s1", "my-app", 5, func(s *db.Session) { diff --git a/internal/server/usage.go b/internal/server/usage.go index da2840207..29c3e1ddc 100644 --- a/internal/server/usage.go +++ b/internal/server/usage.go @@ -47,6 +47,18 @@ type AgentTotal struct { Cost money.Money `json:"cost"` } +// BranchTotal holds range-wide token and cost totals per (project, branch). +type BranchTotal struct { + ProjectKey string `json:"project_key"` + Project string `json:"project"` + Branch string `json:"branch"` + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + CacheCreationTokens int `json:"cacheCreationTokens"` + CacheReadTokens int `json:"cacheReadTokens"` + Cost money.Money `json:"cost"` +} + // CacheStats summarizes cache hit/miss for the period. type CacheStats struct { CacheReadTokens int `json:"cacheReadTokens"` @@ -75,6 +87,7 @@ type UsageSummaryResponse struct { ProjectTotals []ProjectTotal `json:"projectTotals"` ModelTotals []ModelTotal `json:"modelTotals"` AgentTotals []AgentTotal `json:"agentTotals"` + BranchTotals []BranchTotal `json:"branchTotals"` SessionCounts db.UsageSessionCounts `json:"sessionCounts"` CacheStats CacheStats `json:"cacheStats"` UnsupportedUsage *UnsupportedUsage `json:"unsupportedUsage,omitempty"` @@ -95,6 +108,7 @@ func usageSummaryResponseFromService( ProjectTotals: projectTotalsFromService(res.ProjectTotals), ModelTotals: modelTotalsFromService(res.ModelTotals), AgentTotals: agentTotalsFromService(res.AgentTotals), + BranchTotals: branchTotalsFromService(res.BranchTotals), SessionCounts: res.SessionCounts, CacheStats: cacheStatsFromService(res.CacheStats), UnsupportedUsage: unsupportedUsageFromService(res.UnsupportedUsage), @@ -156,6 +170,23 @@ func agentTotalsFromService(in []service.AgentTotal) []AgentTotal { return out } +func branchTotalsFromService(in []service.BranchTotal) []BranchTotal { + out := make([]BranchTotal, 0, len(in)) + for _, total := range in { + out = append(out, BranchTotal{ + ProjectKey: total.ProjectKey, + Project: total.Project, + Branch: total.Branch, + InputTokens: total.InputTokens, + OutputTokens: total.OutputTokens, + CacheCreationTokens: total.CacheCreationTokens, + CacheReadTokens: total.CacheReadTokens, + Cost: total.Cost, + }) + } + return out +} + func cacheStatsFromService(in service.CacheStats) CacheStats { return CacheStats{ CacheReadTokens: in.CacheReadTokens, diff --git a/internal/server/usage_internal_test.go b/internal/server/usage_internal_test.go index 1c65bbc31..75fad6cc2 100644 --- a/internal/server/usage_internal_test.go +++ b/internal/server/usage_internal_test.go @@ -72,6 +72,19 @@ func TestUsageSummaryResponseEmitsEmptyProjectsMap(t *testing.T) { assert.Contains(t, string(b), `"projects":{}`) } +func TestUsageSummaryResponsePreservesBranchProjectKey(t *testing.T) { + response := usageSummaryResponseFromService(&service.UsageSummaryResult{ + BranchTotals: []service.BranchTotal{{ + ProjectKey: "pl1:sha256:alpha", + Project: "alpha", + Branch: "main", + }}, + }) + + require.Len(t, response.BranchTotals, 1) + assert.Equal(t, "pl1:sha256:alpha", response.BranchTotals[0].ProjectKey) +} + // assertUsageQueryCalls verifies how many times the usage handler // queried the daily-usage and session-count store methods. func assertUsageQueryCalls( @@ -141,6 +154,20 @@ func TestUsageSummaryDefaultsToBreakdowns(t *testing.T) { require.Len(t, spy.filters, 1) assert.True(t, spy.filters[0].Breakdowns) + assert.False(t, spy.filters[0].BranchBreakdowns) +} + +func TestUsageSummaryCanIncludeBranchBreakdowns(t *testing.T) { + spy := &usageSummaryCountsSpy{} + s := newRoutedTestServerWithStore(t, spy) + + w := serveGet(t, s, + "/api/v1/usage/summary?"+oneDayUsageRange+"&branch_breakdowns=true") + assertRecorderStatus(t, w, http.StatusOK) + + require.Len(t, spy.filters, 1) + assert.True(t, spy.filters[0].Breakdowns) + assert.True(t, spy.filters[0].BranchBreakdowns) } func TestUsageSummaryCanSkipBreakdowns(t *testing.T) { @@ -184,10 +211,13 @@ func TestUsageComparisonScansPriorPeriodOnly(t *testing.T) { w := serveGet(t, s, "/api/v1/usage/comparison?"+oneDayUsageRange+ - "¤t_microdollars=3000000") + "¤t_microdollars=3000000&branch_breakdowns=true") assertRecorderStatus(t, w, http.StatusOK) assertUsageQueryCalls(t, spy, 1, 0, 0) + require.Len(t, spy.filters, 1) + assert.False(t, spy.filters[0].Breakdowns) + assert.False(t, spy.filters[0].BranchBreakdowns) var out Comparison require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out)) @@ -238,6 +268,19 @@ func TestUsageComparisonCopiesResolvedProjectExclusionToPriorPeriod( spy.filters[0].ExcludeProjectLabels) } +func TestUsageComparisonCopiesExcludeGitBranchFilterToPriorPeriod(t *testing.T) { + spy := &usageSummaryCountsSpy{} + s := newRoutedTestServerWithStore(t, spy) + branch := db.EncodeBranchFilterToken("alpha", "main") + + w := serveGet(t, s, + "/api/v1/usage/comparison?"+oneDayUsageRange+"¤t_microdollars=3000000&exclude_git_branch="+url.QueryEscape(branch)) + assertRecorderStatus(t, w, http.StatusOK) + + require.Len(t, spy.filters, 1) + assert.Equal(t, branch, spy.filters[0].ExcludeGitBranch) +} + func TestUsageComparisonRequiresCurrentCost(t *testing.T) { spy := &usageSummaryCountsSpy{} s := newRoutedTestServerWithStore(t, spy) @@ -422,7 +465,8 @@ func TestUsagePairwiseComparisonScansTwoDailyFilters(t *testing.T) { w := serveGet(t, s, "/api/v1/usage/pairwise-comparison?"+oneDayUsageRange+ "&left_dimension=model&left_value=claude-sonnet-4-20250514"+ - "&right_dimension=project&right_value=beta") + "&right_dimension=project&right_value=beta"+ + "&branch_breakdowns=true") assertRecorderStatus(t, w, http.StatusOK) assert.Equal(t, 2, spy.dailyCalls) @@ -431,8 +475,11 @@ func TestUsagePairwiseComparisonScansTwoDailyFilters(t *testing.T) { assert.Equal(t, "", spy.filters[0].Project) assert.Equal(t, "", spy.filters[1].Model) assert.Equal(t, "beta", spy.filters[1].Project) - assert.False(t, spy.filters[0].SkipSessionCounts) - assert.False(t, spy.filters[1].SkipSessionCounts) + for _, filter := range spy.filters { + assert.False(t, filter.Breakdowns) + assert.False(t, filter.BranchBreakdowns) + assert.False(t, filter.SkipSessionCounts) + } } func TestUsagePairwiseComparisonOpenAPIRequiresSideParams(t *testing.T) { diff --git a/internal/service/direct.go b/internal/service/direct.go index 1806ce221..fdc587eec 100644 --- a/internal/service/direct.go +++ b/internal/service/direct.go @@ -731,6 +731,8 @@ func (b *directBackend) UsagePairwiseComparison( } leftFilter.Breakdowns = false rightFilter.Breakdowns = false + leftFilter.BranchBreakdowns = false + rightFilter.BranchBreakdowns = false leftFilter.SkipSessionCounts = false rightFilter.SkipSessionCounts = false diff --git a/internal/service/http.go b/internal/service/http.go index 18caf8b07..16269a599 100644 --- a/internal/service/http.go +++ b/internal/service/http.go @@ -599,6 +599,7 @@ func (b *httpBackend) UsageSummary( "project": req.Project, "machine": req.Machine, "git_branch": req.GitBranch, + "exclude_git_branch": req.ExcludeGitBranch, "exclude_project": req.ExcludeProject, "exclude_project_key": req.ExcludeProjectKey, "exclude_agent": req.ExcludeAgent, @@ -620,6 +621,9 @@ func (b *httpBackend) UsageSummary( if req.Breakdowns != nil { q.Set("breakdowns", strconv.FormatBool(*req.Breakdowns)) } + if req.BranchBreakdowns != nil { + q.Set("branch_breakdowns", strconv.FormatBool(*req.BranchBreakdowns)) + } if req.SessionCounts != nil { q.Set("session_counts", strconv.FormatBool(*req.SessionCounts)) } @@ -657,6 +661,7 @@ func (b *httpBackend) UsagePairwiseComparison( "project": req.Project, "machine": req.Machine, "git_branch": req.GitBranch, + "exclude_git_branch": req.ExcludeGitBranch, "exclude_project": req.ExcludeProject, "exclude_project_key": req.ExcludeProjectKey, "exclude_agent": req.ExcludeAgent, diff --git a/internal/service/usage.go b/internal/service/usage.go index 54eef231e..a22f77ffb 100644 --- a/internal/service/usage.go +++ b/internal/service/usage.go @@ -27,6 +27,7 @@ type UsageRequest struct { Project string `json:"project,omitempty"` Machine string `json:"machine,omitempty"` GitBranch string `json:"git_branch,omitempty"` + ExcludeGitBranch string `json:"exclude_git_branch,omitempty"` ExcludeProject string `json:"exclude_project,omitempty"` ExcludeProjectKey string `json:"exclude_project_key,omitempty"` ExcludeAgent string `json:"exclude_agent,omitempty"` @@ -39,6 +40,7 @@ type UsageRequest struct { IncludeAutomated bool `json:"include_automated,omitempty"` NoDefaultRange bool `json:"no_default_range,omitempty"` Breakdowns *bool `json:"breakdowns,omitempty"` + BranchBreakdowns *bool `json:"branch_breakdowns,omitempty"` SessionCounts *bool `json:"session_counts,omitempty"` // ProjectLabels and ExcludeProjectLabels carry exact internal labels // resolved from opaque keys. Unlike the public string fields, they are @@ -54,23 +56,41 @@ type UsageRequest struct { func ResolveUsageProjectKeys( ctx context.Context, store db.Store, req UsageRequest, ) (UsageRequest, error) { - if req.ExcludeProjectKey == "" { + if req.ExcludeProjectKey == "" && + !strings.Contains(req.GitBranch, "pl1:sha256:") && + !strings.Contains(req.ExcludeGitBranch, "pl1:sha256:") { return req, nil } - resolved, err := resolveUsageProjectKeyLabels( - ctx, store, req.ExcludeProjectKey, + byKey, err := usageProjectLabelsByKey(ctx, store) + if err != nil { + return UsageRequest{}, err + } + if req.ExcludeProjectKey != "" { + resolved, resolveErr := resolveUsageProjectKeyLabelsFromMap( + byKey, req.ExcludeProjectKey, + ) + if resolveErr != nil { + return UsageRequest{}, resolveErr + } + req.ExcludeProjectLabels = append(req.ExcludeProjectLabels, resolved...) + req.ExcludeProjectKey = "" + } + req.GitBranch, err = resolveUsageBranchProjectKeys(req.GitBranch, byKey) + if err != nil { + return UsageRequest{}, err + } + req.ExcludeGitBranch, err = resolveUsageBranchProjectKeys( + req.ExcludeGitBranch, byKey, ) if err != nil { return UsageRequest{}, err } - req.ExcludeProjectLabels = append(req.ExcludeProjectLabels, resolved...) - req.ExcludeProjectKey = "" return req, nil } -func resolveUsageProjectKeyLabels( - ctx context.Context, store db.Store, keys string, -) ([]string, error) { +func usageProjectLabelsByKey( + ctx context.Context, store db.Store, +) (map[string]string, error) { labels, err := store.GetActiveProjectLabels(ctx) if err != nil { return nil, err @@ -85,6 +105,22 @@ func resolveUsageProjectKeyLabels( byKey[entry.ProjectKey] = label } } + return byKey, nil +} + +func resolveUsageProjectKeyLabels( + ctx context.Context, store db.Store, keys string, +) ([]string, error) { + byKey, err := usageProjectLabelsByKey(ctx, store) + if err != nil { + return nil, err + } + return resolveUsageProjectKeyLabelsFromMap(byKey, keys) +} + +func resolveUsageProjectKeyLabelsFromMap( + byKey map[string]string, keys string, +) ([]string, error) { resolved := make([]string, 0) for _, key := range splitCSVTokens(keys) { label, ok := byKey[key] @@ -99,6 +135,27 @@ func resolveUsageProjectKeyLabels( return resolved, nil } +func resolveUsageBranchProjectKeys( + tokens string, byKey map[string]string, +) (string, error) { + return db.RewriteQualifiedBranchFilterProjects( + tokens, + func(project string) (string, error) { + if !strings.HasPrefix(project, "pl1:sha256:") { + return project, nil + } + label, ok := byKey[project] + if !ok { + return "", &UsageInputError{ + Code: UsageErrorCodeUnknownProjectKey, + Msg: "unknown project key", + } + } + return label, nil + }, + ) +} + func ResolveUsagePairwiseProjectKeys( ctx context.Context, store db.Store, req UsagePairwiseComparisonRequest, ) (UsagePairwiseComparisonRequest, error) { @@ -179,6 +236,10 @@ func BuildUsageFilter(req UsageRequest) (db.UsageFilter, error) { if req.Breakdowns != nil { breakdowns = *req.Breakdowns } + branchBreakdowns := false + if req.BranchBreakdowns != nil { + branchBreakdowns = *req.BranchBreakdowns + } sessionCounts := true if req.SessionCounts != nil { sessionCounts = *req.SessionCounts @@ -191,9 +252,10 @@ func BuildUsageFilter(req UsageRequest) (db.UsageFilter, error) { ProjectLabels: mergeResolvedProjectLabels( req.Project, req.ProjectLabels, ), - Machine: req.Machine, - GitBranch: req.GitBranch, - ExcludeProject: req.ExcludeProject, + Machine: req.Machine, + GitBranch: req.GitBranch, + ExcludeGitBranch: req.ExcludeGitBranch, + ExcludeProject: req.ExcludeProject, ExcludeProjectLabels: mergeResolvedProjectLabels( req.ExcludeProject, req.ExcludeProjectLabels, ), @@ -207,6 +269,7 @@ func BuildUsageFilter(req UsageRequest) (db.UsageFilter, error) { ActiveSince: req.ActiveSince, Termination: req.Termination, Breakdowns: breakdowns, + BranchBreakdowns: branchBreakdowns, SkipSessionCounts: !sessionCounts, }, nil } @@ -267,6 +330,18 @@ type AgentTotal struct { Cost money.Money `json:"cost"` } +// BranchTotal holds range-wide token and cost totals per (project, branch). +type BranchTotal struct { + ProjectKey string `json:"project_key"` + Project string `json:"project"` + Branch string `json:"branch"` + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + CacheCreationTokens int `json:"cacheCreationTokens"` + CacheReadTokens int `json:"cacheReadTokens"` + Cost money.Money `json:"cost"` +} + // CacheStats summarizes cache hit/miss for the period. type CacheStats struct { CacheReadTokens int `json:"cacheReadTokens"` @@ -312,6 +387,7 @@ type UsageSummaryResult struct { ProjectTotals []ProjectTotal `json:"projectTotals"` ModelTotals []ModelTotal `json:"modelTotals"` AgentTotals []AgentTotal `json:"agentTotals"` + BranchTotals []BranchTotal `json:"branchTotals"` SessionCounts db.UsageSessionCounts `json:"sessionCounts"` CacheStats CacheStats `json:"cacheStats"` UnsupportedUsage *UnsupportedUsage `json:"unsupportedUsage,omitempty"` @@ -411,6 +487,15 @@ func buildUsageSummary( out.ModelTotals = []ModelTotal{} out.AgentTotals = []AgentTotal{} } + if f.BranchBreakdowns { + var err error + out.BranchTotals, err = foldBranchTotals(result.Daily) + if err != nil { + return nil, err + } + } else { + out.BranchTotals = []BranchTotal{} + } return out, nil } @@ -525,6 +610,55 @@ func foldAgentTotals(daily []db.DailyUsageEntry) ([]AgentTotal, error) { return out, nil } +// foldBranchTotals sums daily (project, branch) breakdowns into range-wide +// totals sorted by cost descending. +func foldBranchTotals(daily []db.DailyUsageEntry) ([]BranchTotal, error) { + type key struct{ projectKey, branch string } + m := make(map[key]*BranchTotal) + for _, d := range daily { + for _, bb := range d.BranchBreakdowns { + k := key{projectKey: bb.ProjectKey, branch: bb.Branch} + bt, ok := m[k] + if !ok { + bt = &BranchTotal{ + ProjectKey: bb.ProjectKey, + Project: bb.Project, + Branch: bb.Branch, + } + m[k] = bt + } + bt.InputTokens += bb.InputTokens + bt.OutputTokens += bb.OutputTokens + bt.CacheCreationTokens += bb.CacheCreationTokens + bt.CacheReadTokens += bb.CacheReadTokens + var err error + bt.Cost, err = money.Add(bt.Cost, bb.Cost) + if err != nil { + return nil, fmt.Errorf("summing usage branch cost: %w", err) + } + } + } + out := make([]BranchTotal, 0, len(m)) + for _, v := range m { + out = append(out, *v) + } + // BranchTotal is not db.BranchBreakdown, so this comparator is a copy; + // keep its ordering in sync with db.SortBranchBreakdowns. + sort.Slice(out, func(i, j int) bool { + if out[i].Cost.Microdollars != out[j].Cost.Microdollars { + return out[i].Cost.Microdollars > out[j].Cost.Microdollars + } + if out[i].ProjectKey != out[j].ProjectKey { + return out[i].ProjectKey < out[j].ProjectKey + } + if out[i].Project != out[j].Project { + return out[i].Project < out[j].Project + } + return out[i].Branch < out[j].Branch + }) + return out, nil +} + // computeCacheStats derives cache hit/miss metrics from totals. // SavingsVsUncached passes through totals.CacheSavings, which the DB // layer computes per-message using each row's actual per-model rates, diff --git a/internal/service/usage_internal_test.go b/internal/service/usage_internal_test.go index e34ae3c1e..111644f95 100644 --- a/internal/service/usage_internal_test.go +++ b/internal/service/usage_internal_test.go @@ -24,6 +24,43 @@ func TestUsageSummaryResultEmitsEmptyProjectsMap(t *testing.T) { assert.Contains(t, string(b), `"projects":{}`) } +func TestFoldBranchTotals(t *testing.T) { + t.Parallel() + daily := []db.DailyUsageEntry{ + { + Date: "2026-05-14", + BranchBreakdowns: []db.BranchBreakdown{ + {ProjectKey: "pl1:sha256:first", Project: "", Branch: "main", InputTokens: 10, OutputTokens: 4, Cost: money.MustParseDollars("1")}, + }, + }, + { + Date: "2026-05-15", + BranchBreakdowns: []db.BranchBreakdown{ + {ProjectKey: "pl1:sha256:second", Project: "", Branch: "main", InputTokens: 20, OutputTokens: 8, Cost: money.MustParseDollars("2")}, + }, + }, + } + + got, err := foldBranchTotals(daily) + require.NoError(t, err) + + assert.Equal(t, []BranchTotal{ + {ProjectKey: "pl1:sha256:second", Project: "", Branch: "main", InputTokens: 20, OutputTokens: 8, Cost: money.MustParseDollars("2")}, + {ProjectKey: "pl1:sha256:first", Project: "", Branch: "main", InputTokens: 10, OutputTokens: 4, Cost: money.MustParseDollars("1")}, + }, got, "keeps colliding display labels distinct by project key") +} + +func TestFoldBranchTotalsEmpty(t *testing.T) { + t.Parallel() + got, err := foldBranchTotals(nil) + require.NoError(t, err) + assert.Empty(t, got) + + got, err = foldBranchTotals([]db.DailyUsageEntry{{Date: "2026-05-14"}}) + require.NoError(t, err) + assert.Empty(t, got) +} + func TestComputeCacheStats_SavingsPassThrough(t *testing.T) { t.Parallel() // SavingsVsUncached is computed per-model in the DB layer; diff --git a/internal/service/usage_test.go b/internal/service/usage_test.go index 4dca18450..62f0853c0 100644 --- a/internal/service/usage_test.go +++ b/internal/service/usage_test.go @@ -84,6 +84,43 @@ func seedPairwiseUsageFixture(t *testing.T, d *db.DB) { } } +func seedCollidingBranchProjectFixture(t *testing.T, d *db.DB) { + t.Helper() + + seeds := []struct { + id string + project string + input int + }{ + {id: "usage-private-first", project: "/Users/example/one/private/repo", input: 10}, + {id: "usage-private-second", project: "/Users/example/two/private/repo", input: 20}, + } + for i, seed := range seeds { + started := fmt.Sprintf("2024-06-01T1%d:00:00Z", i) + assistant := dbtest.AsstMsg(seed.id, 1, "done") + assistant.Timestamp = started + assistant.Model = "test-model" + assistant.TokenUsage = fmt.Appendf(nil, `{"input_tokens":%d}`, seed.input) + dbtest.SeedSessionWithMessages( + t, + d, + seed.id, + seed.project, + []db.Message{ + dbtest.UserMsg(seed.id, 0, "compare branch usage"), + assistant, + }, + dbtest.WithMessageCounts(2, 1), + func(s *db.Session) { + s.Agent = "claude" + s.GitBranch = "main" + s.StartedAt = &started + s.EndedAt = &started + }, + ) + } +} + func seedCommaProjectUsageFixture(t *testing.T, d *db.DB) { t.Helper() started := "2024-06-01T09:00:00Z" @@ -147,20 +184,34 @@ func assistantUsageMsg( func TestBuildUsageFilter_ValidMapping(t *testing.T) { t.Parallel() f, err := service.BuildUsageFilter(service.UsageRequest{ - From: "2024-06-01", - To: "2024-06-15", - Project: "proj", - Agent: "claude", + From: "2024-06-01", + To: "2024-06-15", + Project: "proj", + Agent: "claude", + GitBranch: "proj\x1fmain", + ExcludeGitBranch: "proj\x1fdev", // IncludeOneShot/IncludeAutomated default false -> exclude true. }) require.NoError(t, err) assert.Equal(t, "2024-06-01", f.From) assert.Equal(t, "2024-06-15", f.To) assert.Equal(t, "proj", f.Project) + assert.Equal(t, "proj\x1fmain", f.GitBranch) + assert.Equal(t, "proj\x1fdev", f.ExcludeGitBranch) assert.Equal(t, "UTC", f.Timezone, "empty timezone defaults to UTC") assert.True(t, f.ExcludeOneShot, "IncludeOneShot=false -> ExcludeOneShot=true") assert.True(t, f.ExcludeAutomated, "IncludeAutomated=false -> ExcludeAutomated=true") assert.True(t, f.Breakdowns, "summary needs per-day breakdowns") + assert.False(t, f.BranchBreakdowns, + "branch aggregation is opt-in independently of ordinary breakdowns") + + branchBreakdowns := true + f, err = service.BuildUsageFilter(service.UsageRequest{ + From: "2024-06-01", To: "2024-06-15", + BranchBreakdowns: &branchBreakdowns, + }) + require.NoError(t, err) + assert.True(t, f.BranchBreakdowns) } func TestBuildUsageFilter_IncludeFlagsInvert(t *testing.T) { @@ -292,6 +343,34 @@ func TestHTTPBackend_UsageSummary_SendsExplicitIncludeOneShot(t *testing.T) { } } +func TestHTTPBackend_UsageSummary_SerializesBranchFilters(t *testing.T) { + t.Parallel() + var gitBranch, excludeGitBranch, branchBreakdowns string + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + gitBranch = r.URL.Query().Get("git_branch") + excludeGitBranch = r.URL.Query().Get("exclude_git_branch") + branchBreakdowns = r.URL.Query().Get("branch_breakdowns") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"from":"x","to":"y"}`)) + })) + t.Cleanup(srv.Close) + svc := service.NewHTTPBackend(srv.URL, "", false) + includeBranchBreakdowns := true + + _, err := svc.UsageSummary(context.Background(), service.UsageRequest{ + From: "2024-06-01", + To: "2024-06-02", + GitBranch: "alpha\x1fmain", + ExcludeGitBranch: "alpha\x1fdev", + BranchBreakdowns: &includeBranchBreakdowns, + }) + require.NoError(t, err) + assert.Equal(t, "alpha\x1fmain", gitBranch) + assert.Equal(t, "alpha\x1fdev", excludeGitBranch) + assert.Equal(t, "true", branchBreakdowns) +} + // A read-only daemon (pg serve) returns 501 for usage; the HTTP backend // maps that to the shared db.ErrReadOnly sentinel. func TestHTTPBackend_UsageSummary_ReadOnly(t *testing.T) { @@ -520,6 +599,58 @@ func TestDirectBackend_UsageSummary_ExcludesOpaqueProjectKey(t *testing.T) { assert.Equal(t, "alpha", filtered.ProjectTotals[0].Project) } +func TestDirectBackend_UsageSummary_FiltersProjectKeyQualifiedBranch(t *testing.T) { + t.Parallel() + + d := dbtest.OpenTestDB(t) + seedCollidingBranchProjectFixture(t, d) + be := service.NewDirectBackend(d, nil) + branchBreakdowns := true + base := service.UsageRequest{ + From: "2024-06-01", To: "2024-06-01", Timezone: "UTC", + IncludeOneShot: true, + BranchBreakdowns: &branchBreakdowns, + } + summary, err := be.UsageSummary(context.Background(), base) + require.NoError(t, err) + require.Len(t, summary.BranchTotals, 2) + + var selected service.BranchTotal + for _, branch := range summary.BranchTotals { + if branch.InputTokens == 20 { + selected = branch + } + } + require.NotEmpty(t, selected.ProjectKey) + assert.Empty(t, selected.Project) + + base.GitBranch = db.EncodeBranchFilterToken(selected.ProjectKey, selected.Branch) + filtered, err := be.UsageSummary(context.Background(), base) + require.NoError(t, err) + require.Len(t, filtered.BranchTotals, 1) + assert.Equal(t, selected.ProjectKey, filtered.BranchTotals[0].ProjectKey) + assert.Equal(t, 20, filtered.Totals.InputTokens) +} + +func TestDirectBackend_UsageSummary_RejectsUnknownBranchProjectKey(t *testing.T) { + t.Parallel() + + d := dbtest.OpenTestDB(t) + seedCollidingBranchProjectFixture(t, d) + be := service.NewDirectBackend(d, nil) + branchBreakdowns := true + _, err := be.UsageSummary(context.Background(), service.UsageRequest{ + From: "2024-06-01", To: "2024-06-01", Timezone: "UTC", + IncludeOneShot: true, + BranchBreakdowns: &branchBreakdowns, + GitBranch: db.EncodeBranchFilterToken("pl1:sha256:missing", "main"), + }) + + var inputErr *service.UsageInputError + require.ErrorAs(t, err, &inputErr) + assert.Equal(t, service.UsageErrorCodeUnknownProjectKey, inputErr.Code) +} + func TestDirectBackend_UsageSummary_ExcludesSubagentOnlyProjectKey(t *testing.T) { t.Parallel() @@ -762,6 +893,7 @@ func TestHTTPBackend_UsagePairwiseComparison_SerializesRequest(t *testing.T) { "right_dimension": r.URL.Query().Get("right_dimension"), "right_value": r.URL.Query().Get("right_value"), "git_branch": r.URL.Query().Get("git_branch"), + "exclude_git_branch": r.URL.Query().Get("exclude_git_branch"), "exclude_project_key": r.URL.Query().Get("exclude_project_key"), } w.Header().Set("Content-Type", "application/json") @@ -776,6 +908,7 @@ func TestHTTPBackend_UsagePairwiseComparison_SerializesRequest(t *testing.T) { service.UsagePairwiseComparisonRequest{ UsageRequest: service.UsageRequest{ GitBranch: "alpha/main", + ExcludeGitBranch: "alpha\x1fdev", ExcludeProjectKey: "pl1:sha256:hidden", }, LeftDimension: "project", @@ -792,5 +925,6 @@ func TestHTTPBackend_UsagePairwiseComparison_SerializesRequest(t *testing.T) { assert.Equal(t, "gpt-4o", queryValues["right_value"]) assert.Equal(t, "alpha/main", queryValues["git_branch"]) assert.Equal(t, "pl1:sha256:hidden", queryValues["exclude_project_key"]) + assert.Equal(t, "alpha\x1fdev", queryValues["exclude_git_branch"]) assert.Equal(t, 22, res.Deltas.TotalTokensDelta) } diff --git a/internal/sync/engine_integration_test.go b/internal/sync/engine_integration_test.go index 7a0e711cb..68587381e 100644 --- a/internal/sync/engine_integration_test.go +++ b/internal/sync/engine_integration_test.go @@ -9296,9 +9296,7 @@ func TestResyncAllPreservesTrashedSessionData(t *testing.T) { t.Fatalf("orphan health score = %v, want 94", sess.HealthScore) } qs := sess.StoredQualitySignals() - if qs == nil { - t.Fatal("orphan quality signals were not preserved") - } + require.NotNil(t, qs, "orphan quality signals were not preserved") if qs.Version != db.CurrentQualitySignalVersion || qs.ShortPromptCount != 1 || qs.MissingSuccessCriteriaCount != 1 { diff --git a/testdata/golden/activity_report_v4.json b/testdata/golden/activity_report_v4.json index 223014a70..e6e0fc26a 100644 --- a/testdata/golden/activity_report_v4.json +++ b/testdata/golden/activity_report_v4.json @@ -266,6 +266,42 @@ } } ], + "by_branch": [ + { + "project_key": "pl1:sha256:a998e358fa5132eaf9ac2cb16ee752c99400d52800c8e5c43eb8905909a05106", + "project": "path-project", + "branch": "", + "agent_minutes": 3, + "cost": { + "microdollars": 12500 + }, + "automated_agent_minutes": 0, + "interactive_agent_minutes": 3, + "automated_cost": { + "microdollars": 0 + }, + "interactive_cost": { + "microdollars": 12500 + } + }, + { + "project_key": "pl1:sha256:333e5f19bc8ed34f56fa89e51a9307bbc972d173498993ed02e564d32162196f", + "project": "remote-project", + "branch": "feature/golden", + "agent_minutes": 3, + "cost": { + "microdollars": 4760 + }, + "automated_agent_minutes": 0, + "interactive_agent_minutes": 3, + "automated_cost": { + "microdollars": 0 + }, + "interactive_cost": { + "microdollars": 4760 + } + } + ], "by_session": [ { "session_id": "path-current", diff --git a/testdata/golden/usage_daily_breakdown_v4.json b/testdata/golden/usage_daily_breakdown_v4.json index 9f98be163..770a5528b 100644 --- a/testdata/golden/usage_daily_breakdown_v4.json +++ b/testdata/golden/usage_daily_breakdown_v4.json @@ -158,7 +158,8 @@ "microdollars": 4200 } } - ] + ], + "branchBreakdowns": [] }, { "date": "2026-07-02", @@ -220,7 +221,8 @@ "microdollars": 2880 } } - ] + ], + "branchBreakdowns": [] }, { "date": "2026-07-03", @@ -314,7 +316,8 @@ "microdollars": 17260 } } - ] + ], + "branchBreakdowns": [] } ], "totals": { diff --git a/testdata/golden/usage_daily_v4.json b/testdata/golden/usage_daily_v4.json index 964ed2eca..134862a39 100644 --- a/testdata/golden/usage_daily_v4.json +++ b/testdata/golden/usage_daily_v4.json @@ -124,7 +124,8 @@ ], "projectBreakdowns": [], "agentBreakdowns": [], - "machineBreakdowns": [] + "machineBreakdowns": [], + "branchBreakdowns": [] }, { "date": "2026-07-02", @@ -152,7 +153,8 @@ ], "projectBreakdowns": [], "agentBreakdowns": [], - "machineBreakdowns": [] + "machineBreakdowns": [], + "branchBreakdowns": [] }, { "date": "2026-07-03", @@ -191,7 +193,8 @@ ], "projectBreakdowns": [], "agentBreakdowns": [], - "machineBreakdowns": [] + "machineBreakdowns": [], + "branchBreakdowns": [] } ], "totals": { From 5bc550941cf7ca13b50bc58a0ad933dba2ec0ed9 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sat, 1 Aug 2026 08:50:29 -0400 Subject: [PATCH 2/7] fix(frontend): keep branch attribution complete Branch totals do not cover cursor usage, and server branch discovery is deliberately bounded. Without explicit fallbacks, the UI can understate percentages or hide branchless selection entirely. Represent the unattributed remainder in branch summaries and reserve the branchless picker entry outside capped search results so filters and totals remain usable for large histories. --- .../lib/components/shared/BranchPicker.svelte | 9 ++++- .../components/shared/BranchPicker.test.ts | 40 +++++++++++++++++++ .../components/usage/AttributionPanel.svelte | 40 ++++++++++++++++--- .../components/usage/AttributionPanel.test.ts | 33 +++++++++++++++ 4 files changed, 115 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/shared/BranchPicker.svelte b/frontend/src/lib/components/shared/BranchPicker.svelte index 2b40222d9..dc75686bf 100644 --- a/frontend/src/lib/components/shared/BranchPicker.svelte +++ b/frontend/src/lib/components/shared/BranchPicker.svelte @@ -67,9 +67,14 @@ selected.filter((value) => value !== ""), ); const selectedSet = $derived(new Set(normalizedSelected)); + const pinnedRows = $derived([ + ...(noBranchLabel ? [NO_BRANCH_FILTER_TOKEN] : []), + ...normalizedSelected.filter((branch) => branch !== NO_BRANCH_FILTER_TOKEN), + ]); + const pinnedRowSet = $derived(new Set(pinnedRows)); const rows = $derived([ - ...normalizedSelected, - ...results.filter((branch) => !selectedSet.has(branch)).slice(0, 100), + ...pinnedRows, + ...results.filter((branch) => !pinnedRowSet.has(branch)).slice(0, 100), ]); const buttonLabel = $derived.by(() => { if (normalizedSelected.length === 0) return allLabel; diff --git a/frontend/src/lib/components/shared/BranchPicker.test.ts b/frontend/src/lib/components/shared/BranchPicker.test.ts index a7035fdb8..6dbd97325 100644 --- a/frontend/src/lib/components/shared/BranchPicker.test.ts +++ b/frontend/src/lib/components/shared/BranchPicker.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { mount, tick, unmount } from "svelte"; import { render } from "@testing-library/svelte"; +import { NO_BRANCH_FILTER_TOKEN } from "../../branchFilters.js"; import BranchPicker from "./BranchPicker.svelte"; type Deferred = { @@ -78,6 +79,45 @@ describe("BranchPicker", () => { ); }); + it("pins the no-branch option outside bounded results", async () => { + const search = vi.fn().mockResolvedValue({ + branches: Array.from({ length: 100 }, (_, i) => ({ branch: `branch-${i}` })), + has_more: true, + }); + const onChange = vi.fn(); + component = mount(BranchPicker, { + target: document.body, + props: { + mode: "multi", + selected: [], + projects: [], + search, + label: "Branch", + allLabel: "All branches", + placeholder: "Search branches", + clearSearchLabel: "Clear branch search", + loadingLabel: "Loading branches…", + emptyLabel: "No matching branches", + refineLabel: "More branches exist. Refine your search.", + noBranchLabel: "(no branch)", + onChange, + }, + }); + + document.querySelector(".branch-picker-trigger")!.click(); + await flush(); + + expect(rowNames()).toHaveLength(101); + expect(rowNames()[0]).toBe("(no branch)"); + + const noBranchOption = Array.from( + document.querySelectorAll("[role=option]:not(.all)"), + ).find((row) => row.textContent?.includes("(no branch)")); + noBranchOption!.click(); + + expect(onChange).toHaveBeenCalledWith([NO_BRANCH_FILTER_TOKEN]); + }); + it("pins selected branches above search results without duplicating them", async () => { const search = vi.fn().mockResolvedValue({ branches: [{ branch: "feature" }, { branch: "main" }], diff --git a/frontend/src/lib/components/usage/AttributionPanel.svelte b/frontend/src/lib/components/usage/AttributionPanel.svelte index 5bc757d4c..b13d00ca1 100644 --- a/frontend/src/lib/components/usage/AttributionPanel.svelte +++ b/frontend/src/lib/components/usage/AttributionPanel.svelte @@ -24,6 +24,7 @@ const view = $derived(usage.toggles.attribution.view); const isTokenMode = $derived(usage.mode === "token"); const noBranchLabel = $derived(m.shared_no_branch()); + const UNATTRIBUTED_ID = "__unattributed__"; interface Row { id: string; @@ -31,6 +32,7 @@ value: number; color: string; pct: number; + selectable: boolean; } const rowItems = $derived.by(() => { @@ -41,6 +43,7 @@ id: string; label: string; value: number; + selectable?: boolean; }> = []; if (groupBy === "project") { @@ -67,6 +70,19 @@ ? sumSelectedTokens(b, usage.selectedTokenTypes) : b.cost.microdollars, })); + const attributed = items.reduce((sum, item) => sum + item.value, 0); + const total = isTokenMode + ? sumSelectedTokens(s.totals, usage.selectedTokenTypes) + : s.totals.totalCost.microdollars; + const unattributed = Math.max(0, total - attributed); + if (unattributed > 0) { + items.push({ + id: UNATTRIBUTED_ID, + label: m.usage_unattributed(), + value: unattributed, + selectable: false, + }); + } } else { items = s.agentTotals.map((a) => ({ id: a.agent, @@ -89,8 +105,11 @@ id: d.id, label: d.label, value: d.value, - color: colorMap.get(d.id) ?? "var(--text-muted)", + color: d.id === UNATTRIBUTED_ID + ? "var(--text-muted)" + : colorMap.get(d.id) ?? "var(--text-muted)", pct: total > 0 ? d.value / total : 0, + selectable: d.selectable ?? true, })); }); @@ -132,6 +151,7 @@ ); function handleSelect(id: string) { + if (id === UNATTRIBUTED_ID) return; if (groupBy === "project") { usage.toggleProjectKey(id); } else if (groupBy === "agent") { @@ -157,6 +177,7 @@ } function rowTitle(id: string, label: string): string { + if (id === UNATTRIBUTED_ID) return label; if (!includeBased) return m.usage_click_to_hide({ label }); return isRowSelected(id) ? m.usage_click_to_clear_filter({ label }) @@ -164,6 +185,7 @@ } function rowAriaLabel(id: string, label: string): string { + if (id === UNATTRIBUTED_ID) return label; if (!includeBased) return m.usage_hide_from_chart({ label }); return isRowSelected(id) ? m.usage_clear_filter_item({ label }) @@ -268,6 +290,7 @@
handleSelect(row.id)} @@ -299,6 +322,7 @@
handleSelect(row.id)} @@ -406,12 +430,15 @@ gap: 6px; padding: 3px 4px; border-radius: var(--radius-sm); - cursor: pointer; transition: background 0.1s; box-sizing: border-box; } - .rail-row:hover { + .rail-row.interactive { + cursor: pointer; + } + + .rail-row.interactive:hover { background: var(--bg-surface-hover); } @@ -459,12 +486,15 @@ gap: 8px; padding: 4px 6px; border-radius: var(--radius-sm); - cursor: pointer; transition: background 0.1s; box-sizing: border-box; } - .list-row:hover { + .list-row.interactive { + cursor: pointer; + } + + .list-row.interactive:hover { background: var(--bg-surface-hover); } diff --git a/frontend/src/lib/components/usage/AttributionPanel.test.ts b/frontend/src/lib/components/usage/AttributionPanel.test.ts index 66813fc12..3340031c2 100644 --- a/frontend/src/lib/components/usage/AttributionPanel.test.ts +++ b/frontend/src/lib/components/usage/AttributionPanel.test.ts @@ -450,6 +450,39 @@ describe("AttributionPanel branch mode", () => { vi.restoreAllMocks(); }); + it("shows fully unattributed usage instead of an empty branch panel", async () => { + usage.summary!.branchTotals = []; + const spy = vi + .spyOn(usage, "toggleBranch") + .mockImplementation(() => {}); + const component = mountPanel(); + await tick(); + + expect(document.querySelector(".empty")).toBeNull(); + const row = document.querySelector(".list-row"); + expect(row?.textContent).toContain("Unattributed"); + expect(row?.textContent).toContain("$12.00"); + expect(row?.textContent).toContain("100.0%"); + row?.click(); + expect(spy).not.toHaveBeenCalled(); + unmount(component); + }); + + it("includes the unattributed remainder in branch percentages", async () => { + usage.summary!.branchTotals = [usage.summary!.branchTotals[0]!]; + const component = mountPanel(); + await tick(); + + const rows = Array.from( + document.querySelectorAll(".list-row"), + ).map((row) => row.textContent?.replace(/\s+/g, " ").trim()); + expect(rows).toEqual([ + "1 alpha/main 66.7% $8.00", + "2 Unattributed 33.3% $4.00", + ]); + unmount(component); + }); + it("routes a branch row click into the branch exclusion toggle", async () => { const spy = vi .spyOn(usage, "toggleBranch") From cffeb3f9ded859d9d271df7c3c88b9dcbc7b78a0 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sat, 1 Aug 2026 10:31:57 -0400 Subject: [PATCH 3/7] fix(usage): retain branch enrichment across refreshes Branch selection can change while an ordinary summary is already in flight. Without generation-aware reconciliation, that response can either suppress the requested enrichment or overwrite richer data that completed first. Treat the current grouping as durable intent and only let responses replace branch-rich state when they belong to a newer generation. --- frontend/src/lib/stores/usage.svelte.ts | 29 +++++- frontend/src/lib/stores/usage.test.ts | 122 +++++++++++++++++++----- 2 files changed, 121 insertions(+), 30 deletions(-) diff --git a/frontend/src/lib/stores/usage.svelte.ts b/frontend/src/lib/stores/usage.svelte.ts index 80a96f2f6..1d16d9257 100644 --- a/frontend/src/lib/stores/usage.svelte.ts +++ b/frontend/src/lib/stores/usage.svelte.ts @@ -256,6 +256,7 @@ class UsageStore { summary = $state(null); private summaryHasBranchBreakdowns = $state(false); + private branchBreakdownSummaryVersion = 0; private branchBreakdownRequestPending = false; pairwiseComparison = $state(null); @@ -782,19 +783,38 @@ class UsageStore { signal, ) as unknown as UsageSummaryResponse; if (this.versions.summary === v) { - this.summary = data; - this.summaryHasBranchBreakdowns = params.branchBreakdowns === true; + const responseHasBranchBreakdowns = params.branchBreakdowns === true; + const preserveRichSummary = + !responseHasBranchBreakdowns && + this.toggles.attribution.groupBy === "branch" && + this.summaryHasBranchBreakdowns && + this.branchBreakdownSummaryVersion === v && + this.summary !== null; + const currentSummary = preserveRichSummary ? this.summary! : data; + if (!preserveRichSummary) { + this.summary = data; + this.summaryHasBranchBreakdowns = responseHasBranchBreakdowns; + this.branchBreakdownSummaryVersion = responseHasBranchBreakdowns + ? v + : 0; + } this.errors.summary = null; this.ensurePairwiseSelection(); this.clearPairwiseComparisonState(); const loaded = { version: v, - summary: data, + summary: currentSummary, params, projectScopeRecovered: false, }; + if ( + !responseHasBranchBreakdowns && + this.toggles.attribution.groupBy === "branch" + ) { + void this.ensureBranchBreakdowns(); + } if (loadComparison) { - void this.fetchComparison(v, data, params); + void this.fetchComparison(v, currentSummary, params); void this.fetchPairwise(v, params); } return loaded; @@ -873,6 +893,7 @@ class UsageStore { comparison: this.summary.comparison, }; this.summaryHasBranchBreakdowns = true; + this.branchBreakdownSummaryVersion = summaryVersion; } } catch (e) { if (this.versions.summary === summaryVersion) { diff --git a/frontend/src/lib/stores/usage.test.ts b/frontend/src/lib/stores/usage.test.ts index f60079f87..ec9879c2f 100644 --- a/frontend/src/lib/stores/usage.test.ts +++ b/frontend/src/lib/stores/usage.test.ts @@ -277,6 +277,22 @@ function usageSummary(totalCost = 0): UsageSummaryResponse { }; } +function usageSummaryWithBranch(): UsageSummaryResponse { + return { + ...usageSummary(), + branchTotals: [{ + project_key: "pl1:sha256:alpha", + project: "alpha", + branch: "main", + inputTokens: 1, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(0), + }], + }; +} + function usageComparison(): UsageComparison { return { priorFrom: "2023-12-01", @@ -567,25 +583,91 @@ describe("UsageStore lazy branch breakdowns", () => { ); }); + it("enriches after Branch is selected during the initial summary load", async () => { + let resolveInitial: + | ((value: UsageSummaryResponse) => void) + | undefined; + usageServiceMocks.getApiV1UsageSummary + .mockImplementationOnce( + () => new Promise((resolve) => { + resolveInitial = resolve; + }), + ) + .mockResolvedValueOnce(usageSummaryWithBranch()); + const { usage } = await loadStore(); + + const initialLoad = usage.fetchSummary({ loadComparison: false }); + await vi.waitFor(() => + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenCalledTimes(1) + ); + usage.setAttributionGroupBy("branch"); + resolveInitial?.(usageSummary()); + await initialLoad; + + await vi.waitFor(() => + expect(usage.summary?.branchTotals).toHaveLength(1) + ); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenLastCalledWith( + expect.objectContaining({ branchBreakdowns: true }), + ); + }); + + it("keeps rich branch data when it finishes before a cached refetch", async () => { + const { usage } = await loadStore(); + await usage.fetchSummary({ loadComparison: false }); + const callsBeforeRefetch = + usageServiceMocks.getApiV1UsageSummary.mock.calls.length; + let resolveOrdinary: + | ((value: UsageSummaryResponse) => void) + | undefined; + let resolveRich: + | ((value: UsageSummaryResponse) => void) + | undefined; + usageServiceMocks.getApiV1UsageSummary + .mockImplementationOnce( + () => new Promise((resolve) => { + resolveOrdinary = resolve; + }), + ) + .mockImplementationOnce( + () => new Promise((resolve) => { + resolveRich = resolve; + }), + ); + + const refetch = usage.fetchSummary({ loadComparison: false }); + await vi.waitFor(() => + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenCalledTimes( + callsBeforeRefetch + 1, + ) + ); + usage.setAttributionGroupBy("branch"); + await vi.waitFor(() => + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenCalledTimes( + callsBeforeRefetch + 2, + ) + ); + resolveRich?.(usageSummaryWithBranch()); + await vi.waitFor(() => + expect(usage.summary?.branchTotals).toHaveLength(1) + ); + + resolveOrdinary?.(usageSummary()); + const loaded = await refetch; + + expect(usage.summary?.branchTotals).toHaveLength(1); + expect(loaded?.summary.branchTotals).toHaveLength(1); + }); + it("preserves completed comparison during branch enrichment", async () => { const { usage } = await loadStore(); await usage.fetchSummary(); await vi.waitFor(() => expect(usage.summary?.comparison).toEqual(usageComparison()) ); - usageServiceMocks.getApiV1UsageSummary.mockResolvedValueOnce({ - ...usageSummary(), - branchTotals: [{ - project_key: "pl1:sha256:alpha", - project: "alpha", - branch: "main", - inputTokens: 1, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - cost: testMoney(0), - }], - }); + usageServiceMocks.getApiV1UsageSummary.mockResolvedValueOnce( + usageSummaryWithBranch(), + ); usage.setAttributionGroupBy("branch"); await vi.waitFor(() => @@ -621,19 +703,7 @@ describe("UsageStore lazy branch breakdowns", () => { ); usageServiceMocks.getApiV1UsageSummary .mockResolvedValueOnce(usageSummary()) - .mockResolvedValueOnce({ - ...usageSummary(), - branchTotals: [{ - project_key: "pl1:sha256:alpha", - project: "alpha", - branch: "main", - inputTokens: 1, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - cost: testMoney(0), - }], - }); + .mockResolvedValueOnce(usageSummaryWithBranch()); const { usage } = await loadStore(); const refresh = usage.fetchAll(); From 3061fc8cb18a5d8e7e65636becd44300eb4baa14 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sat, 1 Aug 2026 15:22:12 -0400 Subject: [PATCH 4/7] fix(usage): preserve multi-project branch filters Plain branch selections can represent several project-qualified identities, and persisted opaque keys can outlive the archive that created them. Collapsing either case silently narrows results or leaves cached usage stale after a 400 response. Keep every qualified match, and recover unknown keys in stages so valid precision is retained before falling back to portable branch names. --- frontend/src/lib/branchFilters.test.ts | 28 ++++++ frontend/src/lib/branchFilters.ts | 22 ++++- frontend/src/lib/stores/usage.svelte.ts | 21 +++- frontend/src/lib/stores/usage.test.ts | 123 ++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/branchFilters.test.ts b/frontend/src/lib/branchFilters.test.ts index 399e6acb6..9fdbad4e7 100644 --- a/frontend/src/lib/branchFilters.test.ts +++ b/frontend/src/lib/branchFilters.test.ts @@ -56,6 +56,20 @@ describe("branch filter compatibility", () => { ]); }); + it("intersects a plain branch with every matching qualified branch", () => { + const first = branchFilterToken("proj-a", "main"); + const second = branchFilterToken("proj-b", "main"); + + expect(intersectBranchFilterValues(["main"], [first, second])).toEqual([ + first, + second, + ]); + expect(intersectBranchFilterValues([first, second], ["main"])).toEqual([ + first, + second, + ]); + }); + it("preserves existing legacy values while adding plain picker values", () => { expect(reconcileBranchFilterValues( [ @@ -66,6 +80,20 @@ describe("branch filter compatibility", () => { )).toEqual([branchFilterToken("proj-a", "main"), "feature"]); }); + it("reconciles every qualified value selected by one plain branch", () => { + const first = branchFilterToken("proj-a", "main"); + const second = branchFilterToken("proj-b", "main"); + + expect(reconcileBranchFilterValues( + [first, second], + ["main"], + )).toEqual([first, second]); + expect(reconcileBranchFilterValues( + [second, first], + ["main"], + )).toEqual([second, first]); + }); + it("uses sentinel values that cannot collide with legal Git branch names", () => { expect(NO_BRANCH_FILTER_TOKEN).toMatch(/[\u0000-\u001f]/); expect(NO_BRANCH_MATCH_TOKEN).toMatch(/[\u0000-\u001f]/); diff --git a/frontend/src/lib/branchFilters.ts b/frontend/src/lib/branchFilters.ts index 1e36b07f5..2f38086c4 100644 --- a/frontend/src/lib/branchFilters.ts +++ b/frontend/src/lib/branchFilters.ts @@ -63,20 +63,35 @@ export function branchPickerValues(values: string[]): string[] { return [...new Set(values.map(branchFilterValue))]; } +export function portableBranchFilterValues(values: string[]): string[] { + return [...new Set(values.map((value) => { + const { project } = splitBranchFilterToken(value); + return project.startsWith(OPAQUE_PROJECT_KEY_PREFIX) + ? branchFilterValue(value) + : value; + }))]; +} + export function reconcileBranchFilterValues( current: string[], pickerValues: string[], ): string[] { - const remaining = new Set(pickerValues.map(branchFilterValue)); + const selected = new Set(pickerValues.map(branchFilterValue)); const next: string[] = []; + const seen = new Set(); + const represented = new Set(); for (const value of current) { const name = branchFilterValue(value); - if (!remaining.delete(name)) continue; + if (!selected.has(name) || seen.has(value)) continue; + seen.add(value); + represented.add(name); next.push(value); } for (const value of pickerValues) { const name = branchFilterValue(value); - if (!remaining.delete(name)) continue; + if (represented.has(name) || seen.has(name)) continue; + seen.add(name); + represented.add(name); next.push(name); } return next; @@ -110,7 +125,6 @@ export function intersectBranchFilterValues( seen.add(value); result.push(value); } - break; } } return result; diff --git a/frontend/src/lib/stores/usage.svelte.ts b/frontend/src/lib/stores/usage.svelte.ts index 1d16d9257..1afa9f66f 100644 --- a/frontend/src/lib/stores/usage.svelte.ts +++ b/frontend/src/lib/stores/usage.svelte.ts @@ -25,6 +25,7 @@ import { NO_BRANCH_MATCH_TOKEN, branchFilterValuesEqual, intersectBranchFilterValues, + portableBranchFilterValues, scopeBranchFilterValues, } from "../branchFilters.js"; import { toggleListValue } from "../utils/lists.js"; @@ -826,18 +827,34 @@ class UsageStore { return null; } status = "error"; + const portableGitBranch = portableBranchFilterValues( + this.selectedGitBranch + ? this.selectedGitBranch.split(BRANCH_LIST_SEP) + : [], + ).join(BRANCH_LIST_SEP); + const hasExcludedProjectKeys = this.excludedProjectKeys !== ""; + const hasOpaqueBranchKeys = + portableGitBranch !== this.selectedGitBranch; if ( recoverProjectScope && this.versions.summary === v && - this.excludedProjectKeys !== "" && + (hasExcludedProjectKeys || hasOpaqueBranchKeys) && isUnknownProjectKeyError(e) ) { + // The API intentionally does not reveal which opaque key failed. + // Preserve branch precision by clearing excluded project keys first; + // downgrade branch keys only if a retry proves they are stale too. this.excludedProjectKeys = ""; + if (!hasExcludedProjectKeys) { + this.selectedGitBranch = portableGitBranch; + } + saveUsageFilters(this); this.abortPanel("topSessions"); const loaded = await this.fetchSummary({ loadComparison, params: this.baseParams(), - recoverProjectScope: false, + recoverProjectScope: + hasExcludedProjectKeys && hasOpaqueBranchKeys, }); return loaded === null ? null diff --git a/frontend/src/lib/stores/usage.test.ts b/frontend/src/lib/stores/usage.test.ts index ec9879c2f..242ce3d98 100644 --- a/frontend/src/lib/stores/usage.test.ts +++ b/frontend/src/lib/stores/usage.test.ts @@ -968,6 +968,129 @@ describe("UsageStore session filter params", () => { expect(usage.summary).not.toBeNull(); }); + it("recovers a stale persisted opaque branch selection", async () => { + const staleBranch = "pl1:sha256:stale\u001fmain"; + const storage = installStorage({ + "usage-filters": JSON.stringify({ + selectedGitBranch: staleBranch, + }), + }); + usageServiceMocks.getApiV1UsageSummary + .mockRejectedValueOnce( + new apiRuntimeMocks.ApiError( + 400, + "unknown project key", + "unknown_project_key", + ), + ) + .mockResolvedValueOnce(usageSummary()); + const { usage } = await loadStore(); + + await usage.fetchAll(); + + expect(usage.selectedGitBranch).toBe("main"); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenCalledTimes(2); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ gitBranch: staleBranch }), + ); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ gitBranch: "main" }), + ); + expect(usageServiceMocks.getApiV1UsageTopSessions).toHaveBeenCalledTimes(2); + expect(JSON.parse( + storage.getItem("usage-filters") ?? "{}", + ).selectedGitBranch).toBe("main"); + expect(usage.summary).not.toBeNull(); + }); + + it("recovers a stale opaque URL branch during a cached refetch", async () => { + const staleBranch = "pl1:sha256:stale\u001fmain"; + const { usage } = await loadStore(); + await usage.fetchSummary({ loadComparison: false }); + const summaryCalls = usageServiceMocks.getApiV1UsageSummary.mock.calls.length; + usage.selectedGitBranch = staleBranch; + usageServiceMocks.getApiV1UsageSummary + .mockRejectedValueOnce( + new apiRuntimeMocks.ApiError( + 400, + "unknown project key", + "unknown_project_key", + ), + ) + .mockResolvedValueOnce(usageSummary(2)); + + await usage.fetchAll(); + + expect(usage.selectedGitBranch).toBe("main"); + expect(usage.summary?.totals.totalCost).toEqual(testMoney(2)); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenCalledTimes( + summaryCalls + 2, + ); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenLastCalledWith( + expect.objectContaining({ gitBranch: "main" }), + ); + }); + + it("preserves a valid qualified branch while clearing a stale project key", async () => { + const validBranch = "pl1:sha256:valid\u001fmain"; + usageServiceMocks.getApiV1UsageSummary + .mockRejectedValueOnce( + new apiRuntimeMocks.ApiError( + 400, + "unknown project key", + "unknown_project_key", + ), + ) + .mockResolvedValueOnce(usageSummary()); + const { usage } = await loadStore(); + usage.excludedProjectKeys = "pl1:sha256:stale"; + usage.selectedGitBranch = validBranch; + + await usage.fetchAll(); + + expect(usage.excludedProjectKeys).toBe(""); + expect(usage.selectedGitBranch).toBe(validBranch); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenCalledTimes(2); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenLastCalledWith( + expect.objectContaining({ gitBranch: validBranch }), + ); + }); + + it("falls back after both project and branch keys prove stale", async () => { + const staleBranch = "pl1:sha256:stale-branch\u001fmain"; + usageServiceMocks.getApiV1UsageSummary + .mockRejectedValueOnce( + new apiRuntimeMocks.ApiError( + 400, + "unknown project key", + "unknown_project_key", + ), + ) + .mockRejectedValueOnce( + new apiRuntimeMocks.ApiError( + 400, + "unknown project key", + "unknown_project_key", + ), + ) + .mockResolvedValueOnce(usageSummary()); + const { usage } = await loadStore(); + usage.excludedProjectKeys = "pl1:sha256:stale-project"; + usage.selectedGitBranch = staleBranch; + + await usage.fetchAll(); + + expect(usage.excludedProjectKeys).toBe(""); + expect(usage.selectedGitBranch).toBe("main"); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenCalledTimes(3); + expect(usageServiceMocks.getApiV1UsageSummary).toHaveBeenLastCalledWith( + expect.objectContaining({ gitBranch: "main" }), + ); + expect(usage.summary).not.toBeNull(); + }); + it("passes the branch selection to usage endpoints", async () => { const { usage } = await loadStore(); From b04a2537dfa2ef0fbb6c2e3c09af9f5a74b0b957 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sat, 1 Aug 2026 19:17:03 -0500 Subject: [PATCH 5/7] fix(usage): align branch suggestions and attribution Branch suggestions must use the same one-shot and automation scope as the report they filter; otherwise the picker can offer branches that yield an empty dashboard.\n\nLarge treemaps also need to preserve the omitted share of the total. Aggregate overflow into a non-interactive Other tile so the capped visualization remains proportional without implying that hidden rows do not exist. --- .../components/usage/AttributionPanel.svelte | 39 +++++++++++---- .../components/usage/AttributionPanel.test.ts | 49 ++++++++++++++++++- .../src/lib/components/usage/Treemap.svelte | 20 +++++--- .../src/lib/components/usage/UsagePage.svelte | 4 +- .../lib/components/usage/UsagePage.test.ts | 37 ++++++++++++++ 5 files changed, 128 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/components/usage/AttributionPanel.svelte b/frontend/src/lib/components/usage/AttributionPanel.svelte index b13d00ca1..9e8ca753f 100644 --- a/frontend/src/lib/components/usage/AttributionPanel.svelte +++ b/frontend/src/lib/components/usage/AttributionPanel.svelte @@ -25,6 +25,7 @@ const isTokenMode = $derived(usage.mode === "token"); const noBranchLabel = $derived(m.shared_no_branch()); const UNATTRIBUTED_ID = "__unattributed__"; + const OTHER_ID = "__attribution_other__"; interface Row { id: string; @@ -135,23 +136,41 @@ })); // One SVG group is drawn per tile, and branch grouping can produce - // thousands of (project, branch) rows whose tiles would be sub-pixel; - // cap the treemap to the top slice by cost. The side rail and list view - // scroll, so every row stays visible and clickable there. + // thousands of (project, branch) rows whose tiles would be sub-pixel. + // Reserve the final capped tile for their aggregate so the treemap still + // represents the full total. The side rail and list view retain every row. const TREEMAP_MAX_TILES = 40; - const treemapItems = $derived( - rows.slice(0, TREEMAP_MAX_TILES).map((r) => ({ + const treemapItems = $derived.by(() => { + const visibleCount = rows.length > TREEMAP_MAX_TILES + ? TREEMAP_MAX_TILES - 1 + : TREEMAP_MAX_TILES; + const items = rows.slice(0, visibleCount).map((r) => ({ id: r.id, label: r.label, value: r.value, color: r.color, meta: r.pct > 0 ? `${(r.pct * 100).toFixed(1)}%` : "", - })), - ); + selectable: r.selectable, + })); + const omitted = rows.slice(visibleCount); + if (omitted.length > 0) { + const value = omitted.reduce((sum, row) => sum + row.value, 0); + const pct = omitted.reduce((sum, row) => sum + row.pct, 0); + items.push({ + id: OTHER_ID, + label: m.shared_other(), + value, + color: "var(--text-muted)", + meta: pct > 0 ? `${(pct * 100).toFixed(1)}%` : "", + selectable: false, + }); + } + return items; + }); function handleSelect(id: string) { - if (id === UNATTRIBUTED_ID) return; + if (id === UNATTRIBUTED_ID || id === OTHER_ID) return; if (groupBy === "project") { usage.toggleProjectKey(id); } else if (groupBy === "agent") { @@ -177,7 +196,7 @@ } function rowTitle(id: string, label: string): string { - if (id === UNATTRIBUTED_ID) return label; + if (id === UNATTRIBUTED_ID || id === OTHER_ID) return label; if (!includeBased) return m.usage_click_to_hide({ label }); return isRowSelected(id) ? m.usage_click_to_clear_filter({ label }) @@ -185,7 +204,7 @@ } function rowAriaLabel(id: string, label: string): string { - if (id === UNATTRIBUTED_ID) return label; + if (id === UNATTRIBUTED_ID || id === OTHER_ID) return label; if (!includeBased) return m.usage_hide_from_chart({ label }); return isRowSelected(id) ? m.usage_clear_filter_item({ label }) diff --git a/frontend/src/lib/components/usage/AttributionPanel.test.ts b/frontend/src/lib/components/usage/AttributionPanel.test.ts index 3340031c2..b81453545 100644 --- a/frontend/src/lib/components/usage/AttributionPanel.test.ts +++ b/frontend/src/lib/components/usage/AttributionPanel.test.ts @@ -1,4 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; import { mount, tick, unmount } from "svelte"; import type { UsageSummaryResponse } from "../../api/types/usage.js"; import { testMoney } from "../../test/money.js"; @@ -625,6 +632,46 @@ describe("AttributionPanel branch mode", () => { ).toBe("24000px"); unmount(component); }); + + it("aggregates omitted rows into a non-interactive Other tile", async () => { + usage.summary = usageSummary(); + usage.summary.totals.totalCost = testMoney(80); + usage.summary.branchTotals = Array.from({ length: 41 }, (_, index) => ({ + project_key: `pl1:sha256:${index}`, + project: `project-${index}`, + branch: `branch-${index}`, + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: testMoney(index < 39 ? 2 : 1), + })); + usage.toggles.attribution.view = "treemap"; + const toggleBranch = vi + .spyOn(usage, "toggleBranch") + .mockImplementation(() => {}); + + const component = mountPanel(); + await tick(); + + const tiles = document.querySelectorAll("g.tile"); + expect(tiles).toHaveLength(40); + const otherTile = Array.from(tiles).find((tile) => + tile.querySelector("title")?.textContent === "Other" + ); + expect(otherTile).toBeDefined(); + expect(otherTile?.getAttribute("role")).toBeNull(); + expect(otherTile?.getAttribute("tabindex")).toBeNull(); + + const otherRect = otherTile?.querySelector("rect"); + const area = Number(otherRect?.getAttribute("width")) + * Number(otherRect?.getAttribute("height")); + expect(area / (600 * 260)).toBeCloseTo(2 / 80, 5); + + otherTile?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(toggleBranch).not.toHaveBeenCalled(); + await unmount(component); + }); }); describe("AttributionPanel model mode", () => { diff --git a/frontend/src/lib/components/usage/Treemap.svelte b/frontend/src/lib/components/usage/Treemap.svelte index 3a7ab9846..909501c9b 100644 --- a/frontend/src/lib/components/usage/Treemap.svelte +++ b/frontend/src/lib/components/usage/Treemap.svelte @@ -8,6 +8,7 @@ value: number; color: string; meta?: string; + selectable?: boolean; } interface Props { @@ -55,6 +56,7 @@ value: number; color: string; meta?: string; + selectable: boolean; x: number; y: number; width: number; @@ -79,6 +81,7 @@ value: src.value, color: src.color, meta: src.meta, + selectable: src.selectable ?? true, x: t.x, y: t.y, width: t.width, @@ -119,11 +122,12 @@ onSelect(tile.id)} - onkeydown={(e) => handleKey(e, tile.id)} + onclick={tile.selectable ? () => onSelect(tile.id) : undefined} + onkeydown={tile.selectable ? (e) => handleKey(e, tile.id) : undefined} clip-path="url(#{clipId})" > {titleFor(tile.id, tile.label)} @@ -183,19 +187,19 @@ display: block; } - .tile { + .tile.interactive { cursor: pointer; } - .tile:hover rect { + .tile.interactive:hover rect { opacity: 0.92; } - .tile:focus-visible { + .tile.interactive:focus-visible { outline: none; } - .tile:focus-visible rect { + .tile.interactive:focus-visible rect { stroke: white; stroke-width: 2; } diff --git a/frontend/src/lib/components/usage/UsagePage.svelte b/frontend/src/lib/components/usage/UsagePage.svelte index bc19b017f..1c4ef6a4e 100644 --- a/frontend/src/lib/components/usage/UsagePage.svelte +++ b/frontend/src/lib/components/usage/UsagePage.svelte @@ -111,8 +111,8 @@ }) { return searchBranches({ ...params, - includeOneShot: true, - includeAutomated: true, + includeOneShot: sessions.filters.includeOneShot, + includeAutomated: sessions.filters.includeAutomated, scope: "all", }); } diff --git a/frontend/src/lib/components/usage/UsagePage.test.ts b/frontend/src/lib/components/usage/UsagePage.test.ts index 07dac6943..eee20d1e6 100644 --- a/frontend/src/lib/components/usage/UsagePage.test.ts +++ b/frontend/src/lib/components/usage/UsagePage.test.ts @@ -13,6 +13,7 @@ import { settings } from "../../stores/settings.svelte.js"; import { yokedDates } from "../../stores/yokedDates.svelte.js"; import { testMoney } from "../../test/money.js"; import type { UsageSummaryResponse } from "../../api/types/usage.js"; +import { MetadataService } from "../../api/generated/index.js"; import source from "./UsagePage.svelte?raw"; import UsagePage from "./UsagePage.svelte"; @@ -167,6 +168,8 @@ afterEach(() => { usage.toggles.attribution.view = "treemap"; settings.chartPalette = "agentsview"; sessions.projects = []; + sessions.filters.includeOneShot = true; + sessions.filters.includeAutomated = false; yokedDates.setEnabled(false); localStorage.clear(); }); @@ -490,6 +493,40 @@ describe("UsagePage refresh behavior", () => { expect(loadAgents).toHaveBeenCalled(); }); + it("searches branches with the active usage session filters", async () => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + vi.spyOn(usage, "fetchAll").mockResolvedValue(); + vi.spyOn(sessions, "loadAgents").mockResolvedValue(); + const searchBranches = vi.spyOn(MetadataService, "getApiV1Branches") + .mockResolvedValue({ branches: [], has_more: false }); + router.route = "usage"; + router.params = { + include_one_shot: "false", + include_automated: "true", + }; + + component = mount(UsagePage, { target: document.body }); + await flushEffects(); + document.querySelector('button[title="Branch"]') + ?.click(); + + await vi.waitFor(() => + expect(searchBranches).toHaveBeenCalledWith( + expect.objectContaining({ + includeOneShot: false, + includeAutomated: true, + scope: "all", + }), + ) + ); + }); + it("ignores response-scoped project keys restored from a URL", async () => { vi.stubGlobal( "ResizeObserver", From a6ddc271358d92f3bd8a07fc31d4174344208c53 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sat, 1 Aug 2026 19:17:17 -0500 Subject: [PATCH 6/7] docs: remove unrelated superpowers artifacts The branch-filtering pull request should not carry implementation plans and design notes from unrelated completed work. Removing these branch-only artifacts keeps the review surface focused and matches the requested publication scope. --- .../2026-07-21-authoritative-microdollars.md | 623 -------- .../2026-07-27-artifact-export-reliability.md | 1403 ----------------- .../2026-07-27-configurable-chart-palette.md | 1143 -------------- .../2026-07-21-microdollar-money-design.md | 344 ---- ...7-27-artifact-export-reliability-design.md | 292 ---- ...07-27-configurable-chart-palette-design.md | 208 --- 6 files changed, 4013 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-21-authoritative-microdollars.md delete mode 100644 docs/superpowers/plans/2026-07-27-artifact-export-reliability.md delete mode 100644 docs/superpowers/plans/2026-07-27-configurable-chart-palette.md delete mode 100644 docs/superpowers/specs/2026-07-21-microdollar-money-design.md delete mode 100644 docs/superpowers/specs/2026-07-27-artifact-export-reliability-design.md delete mode 100644 docs/superpowers/specs/2026-07-27-configurable-chart-palette-design.md diff --git a/docs/superpowers/plans/2026-07-21-authoritative-microdollars.md b/docs/superpowers/plans/2026-07-21-authoritative-microdollars.md deleted file mode 100644 index 5628d7a1b..000000000 --- a/docs/superpowers/plans/2026-07-21-authoritative-microdollars.md +++ /dev/null @@ -1,623 +0,0 @@ -# Authoritative Microdollars 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:** Replace every floating-point monetary value with exact signed `int64` -microdollars while preserving ordinary dollar rendering in human-facing CLI and -UI output. - -**Architecture:** Add one shared `internal/money.Money` value object and make it -the only monetary domain type. Convert source decimals at ingestion, migrate -SQLite and PostgreSQL forward to integer columns, rebuild DuckDB at schema v4, -and replace machine JSON money with `{"microdollars": int64}` objects. All -calculation, sorting, comparison, hashing, and synchronization use integer -values; only dimensionless ratios remain floating point. - -**Tech Stack:** Go 1.24, SQLite/FTS5, PostgreSQL/pgx, DuckDB, Svelte 5, -TypeScript, Huma/OpenAPI, testify, Vitest, and Playwright. - -## Global Constraints - -- One US dollar equals exactly 1,000,000 microdollars. -- `Money` serializes only as `{"microdollars": int64}`. -- CLI tables and UI labels render ordinary dollars, never raw microdollars. -- Round imported or calculated values to the nearest microdollar with exact - halves away from zero. -- Round computed pricing once per independently priced usage row after summing - its unrounded token-category products. -- Reject malformed, non-finite, negative source money, and overflow. -- Do not retain float money fields, legacy aliases, dual reads, dual writes, or - compatibility adapters. -- Preserve SQLite/PostgreSQL behavior and query-shape parity. -- DuckDB is rebuilt through schema version 4; it receives no in-place migration. -- Use testify assertions and behavior-focused tests. -- Run `go fmt ./...` and `go vet ./...` after Go changes. - -______________________________________________________________________ - -### Task 1: Exact Money Value and Arithmetic - -**Files:** - -- Create: `internal/money/money.go` -- Create: `internal/money/decimal.go` -- Create: `internal/money/format.go` -- Create: `internal/money/money_test.go` -- Create: `internal/money/decimal_test.go` -- Create: `internal/money/format_test.go` - -**Interfaces:** - -- Produces: `money.Money{Microdollars int64}`. - -- Produces: `money.ParseScaledDecimal(string, uint) (int64, error)`. - -- Produces: `money.ParseDollars(string) (Money, error)` and - `money.ParseCents(string) (Money, error)`. - -- Produces: `money.Add`, `money.Sub`, `money.Sum`, and `money.CostPerMillion` - checked arithmetic. - -- Produces: `money.FormatUSD(Money, money.DisplayPrecision) string`. - -- [ ] **Step 1: Write failing behavior tests** - - Cover literal expected values for zero, signed values, exponents, half-away - rounding, malformed numbers, `int64` boundaries, checked sum overflow, - 128-bit token-rate multiplication, combined-component rounding, and - `math.MinInt64` formatting. A representative cost assertion is: - - ```go - got, err := money.CostPerMillion([]money.RatedTokens{ - {Tokens: 1, Rate: money.Money{Microdollars: 500_000}}, - {Tokens: 1, Rate: money.Money{Microdollars: 500_000}}, - }) - require.NoError(t, err) - assert.Equal(t, money.Money{Microdollars: 1}, got) - ``` - -- [ ] **Step 2: Run the new tests and verify RED** - - Run: - - ```bash - go test ./internal/money -run 'Test(Parse|Cost|Add|Format)' -count=1 - ``` - - Expected: compilation fails because `internal/money` does not yet exist. - -- [ ] **Step 3: Implement the minimal money package** - - Use checked base-10 digit accumulation for decimal parsing. Use - `math/bits.Mul64`, `bits.Add64`, and `bits.Div64` for nonnegative pricing - products, rejecting a quotient outside `int64`. Do not use `float64`, - `math/big`, or decimal dependencies. `FormatUSD` must divide the magnitude - as unsigned arithmetic so `math.MinInt64` is safe. - -- [ ] **Step 4: Run focused and package tests and verify GREEN** - - ```bash - go test ./internal/money -count=1 - ``` - - Expected: PASS. - -- [ ] **Step 5: Commit the focused money package** - - ```bash - git add internal/money - git commit -m "feat: add exact microdollar money" - ``` - -### Task 2: Source, Catalog, and Configuration Boundaries - -**Files:** - -- Modify: `internal/parser/types.go` -- Modify: `internal/parser/grok.go` -- Modify: `internal/parser/hermes.go` -- Modify: `internal/parser/roocode.go` -- Modify: `internal/parser/shelley.go` -- Modify: `internal/parser/vibe.go` -- Modify: relevant parser `*_test.go` files returned by - `rg -l 'CostUSD|TotalCost' internal/parser`. -- Modify: `internal/cursorusage/client.go` -- Modify: `internal/cursorusage/client_test.go` -- Modify: `internal/pricing/catalog/litellm.go` -- Modify: `internal/pricing/litellm_test.go` -- Modify: `internal/pricing/fallback.go` -- Modify: `internal/pricing/fallback_test.go` -- Modify: `internal/pricing/cmd/litellm-snapshot/main.go` -- Modify: `internal/pricing/cmd/litellm-snapshot/main_test.go` -- Modify: `internal/config/config.go` -- Modify: custom-pricing config tests under `internal/config` and - `cmd/agentsview`. - -**Interfaces:** - -- Consumes: Task 1 `money.Money` and decimal parsers. - -- Produces: `parser.ParsedUsageEvent.Cost *money.Money`. - -- Produces: Cursor `Charged` and `CursorTokenFee` as `money.Money`. - -- Produces: catalog and config rates as `money.Money` per million tokens. - -- [ ] **Step 1: Replace parser expectations with exact Money values** - - Update parser fixtures to assert literals such as: - - ```go - require.NotNil(t, event.Cost) - assert.Equal(t, money.Money{Microdollars: 42_413}, *event.Cost) - ``` - - Include explicit zero versus absent cost and Grok tick rounding. - -- [ ] **Step 2: Run parser tests and verify RED** - - ```bash - go test ./internal/parser ./internal/cursorusage ./internal/pricing/... -count=1 - ``` - - Expected: compilation failures on the old float fields. - -- [ ] **Step 3: Convert input boundaries** - - Rename `CostUSD` to `Cost`, decode JSON decimals as `json.Number` or raw JSON, - convert Grok ticks with integer arithmetic, and convert unavoidable upstream - SQLite `REAL` values immediately with the one explicitly named boundary - converter. Change Cursor cents and LiteLLM per-token decimals to exact - scaled parsing. Replace custom rate keys with `*_microdollars_per_mtok` - integers. - -- [ ] **Step 4: Regenerate the embedded pricing snapshot** - - Run the repository's existing pricing snapshot generator so the embedded - snapshot stores integer rate values and its version/digest reflects the new - canonical representation. - -- [ ] **Step 5: Run focused tests and verify GREEN** - - ```bash - go test ./internal/parser ./internal/cursorusage ./internal/pricing/... ./internal/config -count=1 - ``` - - Expected: PASS. - -### Task 3: SQLite Integer Schema and Forward Migration - -**Files:** - -- Modify: `internal/db/schema.sql` -- Modify: `internal/db/db.go` -- Create: `internal/db/money_migration.go` -- Create: `internal/db/money_migration_test.go` -- Modify: `internal/db/legacy_schema_test.go` -- Modify: `internal/db/usage_events.go` -- Modify: `internal/db/cursor_usage_events.go` -- Modify: `internal/db/pricing.go` -- Modify: `internal/db/pricing_list.go` -- Modify: `internal/db/orphaned.go` -- Modify: `internal/db/db_test.go` -- Modify: `internal/db/pricing_test.go` -- Modify: `internal/db/usage_test.go` - -**Interfaces:** - -- Consumes: Task 1 `Money`, Task 2 parsed and catalog money. - -- Produces: final SQLite columns named in the design specification. - -- Produces: one idempotent transactional forward migration from the released - float schema. - -- Produces: `db.UsageEvent.Cost *money.Money`, integer `ModelPricing` rates, and - integer `CursorUsageEvent` money. - -- [ ] **Step 1: Write the legacy migration test** - - Create a released-schema database with explicit zero, null cost, fractional - reported cost, fractional Cursor cents, pricing rates, preserved IDs, and - deduplication keys. Open it through `db.Open` and assert exact integer - values, final declared column types, preserved rows/indexes, and no old - money columns. - -- [ ] **Step 2: Verify the migration test fails** - - ```bash - CGO_ENABLED=1 go test -tags fts5 ./internal/db -run 'TestMoneyMigration' -count=1 - ``` - - Expected: FAIL because the new integer columns and migration are absent. - -- [ ] **Step 3: Implement fresh schemas and the transactional migration** - - Rebuild `usage_events`, `cursor_usage_events`, and `model_pricing` inside one - SQLite transaction. Preflight invalid/range values, copy with nearest-micro - rounding, preserve keys and foreign keys, replace the tables, and recreate - their indexes. Detect fresh, released, completed, and invalid mixed schemas - explicitly. - -- [ ] **Step 4: Convert SQLite reads, writes, copies, and fingerprints** - - Bind and scan `int64` microdollars. Remove `sql.NullFloat64`, `/ 100.0`, - `strconv.FormatFloat`, and float pricing comparisons from owned money paths. - -- [ ] **Step 5: Run SQLite tests and verify GREEN** - - ```bash - CGO_ENABLED=1 go test -tags fts5 ./internal/db -count=1 - ``` - - Expected: PASS. - -### Task 4: PostgreSQL Integer Schema and Push Parity - -**Files:** - -- Modify: `internal/postgres/schema.go` -- Modify: `internal/postgres/pricing.go` -- Modify: `internal/postgres/push.go` -- Modify: `internal/postgres/push_fingerprint.go` -- Modify: `internal/postgres/usage.go` -- Modify: `internal/postgres/activityreport.go` -- Modify: `internal/postgres/schema_test.go` -- Modify: `internal/postgres/pricing_unit_test.go` -- Modify: `internal/postgres/push_test.go` -- Modify: all affected `internal/postgres/*_pgtest_test.go` files. - -**Interfaces:** - -- Consumes: final SQLite money records. - -- Produces: PostgreSQL `BIGINT` money columns with names and semantics identical - to SQLite. - -- Produces: transactional legacy `DOUBLE PRECISION` to `BIGINT` migration. - -- [ ] **Step 1: Write failing PostgreSQL schema and parity tests** - - Seed the released float schema, run `EnsureSchema`, and assert exact converted - values, final `BIGINT` types, old-column removal, preserved deduplication, - and parity with the SQLite fixture. - -- [ ] **Step 2: Verify RED with the real PostgreSQL suite** - - ```bash - make test-postgres - ``` - - Expected: new schema/migration assertions fail. - -- [ ] **Step 3: Implement the PostgreSQL forward migration** - - Run it before queries expect new names. Preflight `NaN`, infinities, - negatives, and scaled range. Convert and rename in one transaction; reject - mixed schemas. Update core DDL for fresh databases. - -- [ ] **Step 4: Convert pricing, push, fingerprint, usage, and activity paths** - - Replace double casts, nullable floats, float expressions, and cents division - with `BIGINT` values and shared Go integer calculation. - -- [ ] **Step 5: Run PostgreSQL unit and integration tests and verify GREEN** - - ```bash - go test ./internal/postgres -count=1 - make test-postgres - ``` - - Expected: PASS. - -### Task 5: DuckDB v4 Rebuild and Integer Analytics - -**Files:** - -- Modify: `internal/duckdb/schema.go` -- Modify: `internal/duckdb/push.go` -- Modify: `internal/duckdb/analytics_usage.go` -- Modify: `internal/duckdb/activityreport.go` -- Modify: `internal/duckdb/schema_test.go` -- Modify: `internal/duckdb/rebuild_test.go` -- Modify: `internal/duckdb/sync_test.go` -- Modify: `internal/duckdb/store_test.go` -- Modify: `internal/duckdb/analytics_usage_test.go` -- Modify: `internal/duckdb/activityreport_test.go` - -**Interfaces:** - -- Consumes: SQLite integer money. - -- Produces: DuckDB schema version 4 with `BIGINT` money. - -- Produces: Quack results using the same `money.Money` contracts as SQLite and - PostgreSQL. - -- [ ] **Step 1: Write failing v4 rebuild and integer parity tests** - - Assert a v3 mirror is rebuilt, integer values are pushed exactly, existing - non-AgentsView files still fail closed, and usage/activity results match the - SQLite fixture. - -- [ ] **Step 2: Verify RED** - - ```bash - go test ./internal/duckdb -count=1 - ``` - - Expected: failures on schema version and float column/value assertions. - -- [ ] **Step 3: Implement schema v4 and integer push/analytics** - - Change only create-time schema, bump `SchemaVersion` to 4, remove float cost - SQL and `roundCost`, and use shared integer row pricing where SQL cannot - preserve the exact rounding contract. - -- [ ] **Step 4: Run DuckDB tests and verify GREEN** - - ```bash - go test ./internal/duckdb -count=1 - ``` - - Expected: PASS. - -### Task 6: Shared Usage, Activity, Export, and Insight Domain Types - -**Files:** - -- Modify: `internal/export/pricing.go` -- Modify: `internal/export/types.go` -- Modify: `internal/export/canonical_json.go` -- Modify: `internal/db/usage.go` -- Modify: `internal/db/activityreport.go` -- Modify: `internal/db/session_export.go` -- Modify: `internal/db/session_stats.go` -- Modify: `internal/db/session_stats_types.go` -- Modify: `internal/activity/activity.go` -- Modify: `internal/service/usage.go` -- Modify: `internal/service/session_usage_rollup.go` -- Modify: `internal/insight/canned.go` -- Modify: `internal/insight/summary.go` -- Modify: `internal/insight/prompt.go` -- Modify: all colocated affected Go tests. - -**Interfaces:** - -- Consumes: backend `Money` rows and integer model rates. - -- Produces: all monetary fields as `money.Money` or `*money.Money`. - -- Produces: dimensionless ratios as floats calculated from microdollar integers. - -- Produces: usage/activity/session export schema version increments. - -- [ ] **Step 1: Change behavior tests to literal Money values** - - Replace epsilon assertions with exact equality. Add a fixture whose sub-micro - components round only after their row sum and assert breakdowns sum exactly - to totals. - -- [ ] **Step 2: Verify RED across shared packages** - - ```bash - go test ./internal/export ./internal/activity ./internal/service ./internal/insight -count=1 - ``` - -- [ ] **Step 3: Replace float aggregation and domain fields** - - Use checked money sums for daily, project, model, agent, session, rollup, - savings, activity, and insight totals. Cost-per-session is a rounded `Money` - division; only `*Ratio` fields remain floats. Canonical pricing JSON hashes - integer money objects. - -- [ ] **Step 4: Run shared package tests and verify GREEN** - - ```bash - CGO_ENABLED=1 go test -tags fts5 ./internal/export ./internal/activity ./internal/service ./internal/insight ./internal/db -count=1 - ``` - -### Task 7: REST, CLI JSON, and Human Dollar Output - -**Files:** - -- Modify: `internal/server/huma_routes_sessions.go` -- Modify: `internal/server/huma_routes_usage.go` -- Modify: `internal/server/usage.go` -- Modify: `internal/server/insights.go` -- Modify: affected `internal/server/*_test.go` files. -- Modify: `cmd/agentsview/session_usage.go` -- Modify: `cmd/agentsview/usage.go` -- Modify: `cmd/agentsview/usage_cursor.go` -- Modify: `cmd/agentsview/stats.go` -- Modify: affected `cmd/agentsview/*_test.go` files. -- Modify: `cmd/testfixture/main.go` - -**Interfaces:** - -- Consumes: Task 6 Money response types. - -- Produces: OpenAPI money schema `{microdollars: int64}`. - -- Produces: machine JSON with no float monetary fields. - -- Produces: unchanged human-facing dollar conventions from integer formatters. - -- [ ] **Step 1: Write failing HTTP and CLI contract tests** - - Decode JSON into maps and assert each money member equals exactly - `map[string]any{"microdollars": float64(420000)}` at the generic decoder - boundary, while old `cost_usd` fields are absent. Assert human output - remains `$0.42`, `<$0.01`, and grouped-dollar output. - -- [ ] **Step 2: Verify RED** - - ```bash - CGO_ENABLED=1 go test -tags fts5 ./internal/server ./cmd/agentsview -count=1 - ``` - -- [ ] **Step 3: Replace transport DTOs and formatters** - - Rename semantic parents such as `cost_usd` to `cost` and `rollup_cost_usd` to - `rollup_cost`; keep established casing on each surface. Use integer request - values where cost is an input. Generate human dollar text directly from - `Money`. - -- [ ] **Step 4: Regenerate OpenAPI clients** - - Run the existing frontend API generation command and verify one reusable - generated `Money` model is referenced by every monetary response field. - -- [ ] **Step 5: Run HTTP and CLI tests and verify GREEN** - - ```bash - CGO_ENABLED=1 go test -tags fts5 ./internal/server ./cmd/agentsview -count=1 - ``` - -### Task 8: Frontend Integer Money and Dollar Presentation - -**Files:** - -- Modify: `frontend/src/lib/api/types/usage.ts` -- Modify: generated models under `frontend/src/lib/api/generated/models/`. -- Create: `frontend/src/lib/utils/money.ts` -- Create: `frontend/src/lib/utils/money.test.ts` -- Modify: `frontend/src/lib/stores/usage.svelte.ts` -- Modify: `frontend/src/lib/stores/usage.test.ts` -- Modify: usage components under `frontend/src/lib/components/usage/`. -- Modify: activity components under `frontend/src/lib/components/activity/`. -- Modify: `frontend/src/lib/components/layout/SessionBreadcrumb.svelte` -- Modify: affected colocated frontend tests and `frontend/e2e/usage.spec.ts`. - -**Interfaces:** - -- Consumes: `{microdollars: number}` Money API objects. - -- Produces: integer add/subtract/compare helpers and dimensionless ratio - helpers. - -- Produces: `formatMoneyUSD(Money)` for all human cost labels. - -- [ ] **Step 1: Write failing frontend money tests** - - Cover exact integer addition/comparison, negative values, `$0.42`, `<$0.01`, - `$1,234.56`, and preservation of existing chart/table display precision. - -- [ ] **Step 2: Verify RED** - - ```bash - cd frontend && npm test -- --run src/lib/utils/money.test.ts - ``` - -- [ ] **Step 3: Convert frontend domain calculations** - - Replace scalar money numbers with `Money` objects. Sort and aggregate by - `.microdollars`; convert to chart-axis dollar numbers only inside visual - presentation adapters. Keep ratios numeric. - -- [ ] **Step 4: Convert all human renderers** - - Route summary cards, charts, attribution, pairwise comparisons, activity - tables, top sessions, and session breadcrumbs through `formatMoneyUSD`. - -- [ ] **Step 5: Run frontend tests and checks and verify GREEN** - - ```bash - cd frontend && npm test -- --run && npm run check - ``` - - Expected: PASS with no new warnings. - -### Task 9: Documentation and Contract Examples - -**Files:** - -- Modify: `README.md` -- Modify: `docs/token-usage.md` -- Modify: `docs/session-api.md` -- Modify: `docs/session-export.md` -- Modify: `docs/commands.md` -- Modify: `docs/configuration.md` -- Modify: any additional documentation returned by - `rg -l 'cost_usd|totalCost|input_cost_per_mtok|chargedCents' README.md docs`. - -**Interfaces:** - -- Consumes: final API, CLI, and configuration names. - -- Produces: examples with only `{"microdollars": ...}` machine money and normal - dollar human output. - -- [ ] **Step 1: Update documentation examples and prose** - - Remove float monetary JSON examples, document integer custom-pricing keys, - explain microdollar rounding/range, and retain `$` examples for UI and human - CLI output. - -- [ ] **Step 2: Format and inspect documentation** - - ```bash - mdformat --wrap 80 README.md docs/token-usage.md docs/session-api.md docs/session-export.md docs/commands.md docs/configuration.md - rg -n 'cost_usd|charged_cents|input_per_mtok' README.md docs internal cmd frontend/src - ``` - - Expected: remaining matches are explicitly nonmonetary, upstream source names, - or migration fixtures; no old public or storage contract remains. - -### Task 10: Full Verification and Final Commit - -**Files:** - -- Verify all changed files from Tasks 1-9. - -**Interfaces:** - -- Produces: one coherent implementation with no float monetary authority. - -- [ ] **Step 1: Run formatting and static checks** - - ```bash - go fmt ./... - go vet ./... - make lint - cd frontend && npm run check - ``` - -- [ ] **Step 2: Run backend and frontend suites** - - ```bash - make test - make test-postgres - go test ./internal/duckdb -count=1 - cd frontend && npm test -- --run - ``` - -- [ ] **Step 3: Run relevant end-to-end coverage** - - ```bash - cd frontend && npx playwright test e2e/usage.spec.ts - ``` - -- [ ] **Step 4: Audit monetary floats and legacy names** - - ```bash - rg -n --glob '!**/*_test.go' --glob '!docs/superpowers/**' \ - 'CostUSD|cost_usd|charged_cents|input_per_mtok|TotalCost[[:space:]]+float64|Cost[[:space:]]+float64' \ - internal cmd frontend/src - ``` - - Inspect every remaining match. Only upstream wire names, migration fixtures, - or nonmonetary dimensionless values may remain. - -- [ ] **Step 5: Review the final diff and commit** - - ```bash - git status --short - git diff --stat - git diff --check - git add cmd frontend internal README.md docs - git commit -m "refactor: make microdollars authoritative" - ``` diff --git a/docs/superpowers/plans/2026-07-27-artifact-export-reliability.md b/docs/superpowers/plans/2026-07-27-artifact-export-reliability.md deleted file mode 100644 index a4ccf50d9..000000000 --- a/docs/superpowers/plans/2026-07-27-artifact-export-reliability.md +++ /dev/null @@ -1,1403 +0,0 @@ -# Artifact Export Reliability 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:** Make artifact publication origin-safe, memory-bounded before complete -session materialization, and able to durably reject one deterministic failure -without starving later queue claims. - -**Architecture:** Keep the existing publication ledger and immutable artifact -formats. Add a database-owned bounded snapshot loader, validate origin inside -the locked publication transaction, and finalize success/rejection outcomes -atomically with checkpoint authority. Deterministic validation failures delete -stale publication authority and become durable generation-scoped diagnostics; -transient failures remain fail-fast. - -**Tech Stack:** Go, `database/sql`, SQLite/FTS5, `testify`, the existing -filesystem-backed artifact store, and Go's `errors.Is` typed-error contract. - -## Global Constraints - -- Follow - `docs/superpowers/specs/2026-07-27-artifact-export-reliability-design.md`. -- Preserve the SQLite archive through non-destructive column migrations; never - drop, truncate, or recreate it. -- Keep preflight limits equal to existing artifact policy: 32,768 messages, - 32,768 usage events, 256 tool calls per message, 65,536 tool calls per - session, 1,024 result events per call, 262,144 result events per session, - 256 MiB raw message/nested bytes, and 16 MiB raw usage bytes. -- `rejected_generation` is diagnostic only. Correctness always compares the - claim against the queue row's current `generation`. -- A deterministic rejection removes prior publication authority; it never - retains a stale manifest as current. -- Store, context, SQLite, origin-mismatch, and stale-claim failures remain - transient/fatal and must not consume claims. -- Every new test uses real SQLite state or the real artifact boundary, uses - `testify`, and must be observed failing before production code is added. -- After Go changes run `go fmt ./...` and `go vet ./...` before committing. -- If a future change tightens an export limit, that change must include a - migration that requeues existing publications. - -______________________________________________________________________ - -## File Map - -- `internal/db/artifact_publication.go`: origin validation, claim outcomes, - rejection diagnostics, and atomic finalization. -- `internal/db/artifact_export_load.go`: bounded snapshot loader and typed - preflight limit errors. -- `internal/db/artifact_export_load_test.go`: cardinality, byte, snapshot, and - scaling regressions for the loader. -- `internal/db/schema.sql`: rejection columns for newly created archives. -- `internal/db/db.go`: non-destructive rejection-column migrations plus queue - trigger/requeue clearing. -- `internal/db/orphaned.go`: resync copy/requeue behavior for rejection state. -- `internal/db/artifact_publication_test.go`: origin, outcome, migration, - mutation-retry, adoption, and stale-generation tests. -- `internal/artifact/export.go`: bounded loader use, typed deterministic - classification, per-claim isolation, and rejected-result count. -- `internal/artifact/limits.go`: deterministic export-rejection sentinel and - mapping from artifact policy to database load limits. -- `internal/artifact/export_limits_test.go`: limit classification and bounded - loader integration. -- `internal/artifact/export_test.go`: mixed success/rejection checkpoint flow - and transient-failure behavior. -- `internal/artifact/origin_test.go`: end-to-end rejection removal, mutation - retry, and origin-adoption behavior. - -______________________________________________________________________ - -### Task 1: Lock Publication Authority to the Persisted Origin - -**Files:** - -- Modify: `internal/db/artifact_publication.go:13-25,157-180` -- Test: `internal/db/artifact_publication_test.go` - -**Interfaces:** - -- Consumes: `lockArtifactPublicationTx(context.Context, *sql.Tx) error` and the - `pg_sync_state` key `artifact_origin_id`. - -- Produces: `db.ErrArtifactOriginMismatch`, returned by - `ApplyArtifactPublicationChanges` before claim validation or mutation. - -- [ ] **Step 1: Write the failing transaction-boundary test** - -Add a test that seeds origin A, creates and claims one local session, then calls -`ApplyArtifactPublicationChanges` with origin B: - -```go -func TestArtifactPublicationChangesRejectMismatchedPersistedOrigin(t *testing.T) { - database := testDB(t) - ctx := t.Context() - require.NoError(t, database.AdoptArtifactOrigin("origin-a1b2c3")) - require.NoError(t, database.UpsertSession(Session{ - ID: "session", Project: "project", Machine: "local", Agent: "claude", - })) - claims, err := database.PendingArtifactExports(ctx, 10) - require.NoError(t, err) - require.Len(t, claims, 1) - - revision, changed, err := database.ApplyArtifactPublicationChanges( - ctx, "origin-d4e5f6", []ArtifactPublicationChange{{ - SessionID: "session", Generation: claims[0].Generation, - ManifestHash: "manifest", SourceFingerprint: "fingerprint", - }}, - ) - require.ErrorIs(t, err, ErrArtifactOriginMismatch) - assert.Zero(t, revision) - assert.False(t, changed) - - var publications []ArtifactPublication - _, streamErr := database.StreamArtifactPublications( - ctx, "origin-d4e5f6", func(row ArtifactPublication) error { - publications = append(publications, row) - return nil - }, - ) - require.NoError(t, streamErr) - assert.Empty(t, publications) - pending, pendingErr := database.PendingArtifactExports(ctx, 10) - require.NoError(t, pendingErr) - require.Equal(t, claims, pending) -} -``` - -Add a missing-origin subtest by deleting the state key after the claim and -asserting the same typed error and unchanged claim. - -- [ ] **Step 2: Run the test and observe the wrong-origin mutation** - -Run: - -```bash -CGO_ENABLED=1 go test -tags fts5 ./internal/db \ - -run '^TestArtifactPublicationChangesRejectMismatchedPersistedOrigin$' \ - -count=1 -v -``` - -Expected: FAIL because the wrong-origin publication is inserted and no -`ErrArtifactOriginMismatch` exists. - -- [ ] **Step 3: Add the typed error and locked validation** - -Add near `ErrArtifactExportClaimStale`: - -```go -var ErrArtifactOriginMismatch = errors.New("artifact origin does not match persisted origin") -``` - -Add: - -```go -func validateArtifactOriginTx( - ctx context.Context, tx *sql.Tx, origin string, -) error { - var persisted string - err := tx.QueryRowContext(ctx, ` - SELECT value FROM pg_sync_state WHERE key = 'artifact_origin_id'`, - ).Scan(&persisted) - if errors.Is(err, sql.ErrNoRows) || err == nil && persisted == "" { - return fmt.Errorf("%w: no persisted artifact origin", ErrArtifactOriginMismatch) - } - if err != nil { - return fmt.Errorf("reading persisted artifact origin: %w", err) - } - if persisted != origin { - return fmt.Errorf( - "%w: requested %s, persisted %s", - ErrArtifactOriginMismatch, origin, persisted, - ) - } - return nil -} -``` - -Immediately after `lockArtifactPublicationTx` in -`ApplyArtifactPublicationChanges`, call: - -```go -if err := validateArtifactOriginTx(ctx, tx, origin); err != nil { - return 0, false, err -} -``` - -Do not rely on an unlocked preflight read for correctness. - -- [ ] **Step 4: Verify matching and mismatching origins** - -Run: - -```bash -CGO_ENABLED=1 go test -tags fts5 ./internal/db \ - -run 'TestArtifactPublicationChangesRejectMismatchedPersistedOrigin|TestArtifactPublicationAtomicLifecycle' \ - -count=1 -v -``` - -Expected: PASS. - -- [ ] **Step 5: Commit Task 1** - -```bash -git add internal/db/artifact_publication.go internal/db/artifact_publication_test.go -git commit -m "fix(artifact): bind publication claims to active origin" \ - -m "The queue is global, so publication authority must verify the configured namespace under the same writer reservation as claim validation. Reject stale exporters before they can advance a dead origin." -``` - -______________________________________________________________________ - -### Task 2: Persist and Atomically Finalize Rejected Generations - -**Files:** - -- Modify: `internal/db/schema.sql:126-139` -- Modify: `internal/db/db.go:artifactSessionQueueTriggerCreatesSQL` -- Modify: `internal/db/db.go:schemaColumnMigrations` -- Modify: `internal/db/db.go:bootstrapArtifactExportQueueSQL` -- Modify: `internal/db/db.go:requeueArtifactExportsSQL` -- Modify: `internal/db/db.go:requeueArtifactOriginExportsSQL` -- Modify: `internal/db/artifact_publication.go:263-439,607-632` -- Modify: `internal/db/orphaned.go:380-445` -- Test: `internal/db/artifact_publication_test.go` - -**Interfaces:** - -- Produces: - -```go -type ArtifactExportOutcome struct { - Item ArtifactExportQueueItem - Rejection string -} - -type ArtifactExportRejection struct { - SessionID string - Generation int64 - Error string - RejectedAt string -} - -func (db *DB) FinalizeArtifactExports( - context.Context, []ArtifactExportOutcome, -) error - -func (db *DB) RecordArtifactCheckpointHeadOutcomes( - context.Context, ArtifactCheckpointHead, []ArtifactExportOutcome, -) error - -func (db *DB) GetArtifactExportRejection( - context.Context, string, -) (ArtifactExportRejection, bool, error) -``` - -- Preserves: `AcknowledgeArtifactExports` and `RecordArtifactCheckpointHead` as - successful-outcome wrappers for existing callers. - -- [ ] **Step 1: Add failing outcome lifecycle tests** - -Add tests using a real database: - -```go -func TestArtifactExportRejectionFinalizesExactGeneration(t *testing.T) { - database := testDB(t) - ctx := t.Context() - require.NoError(t, database.AdoptArtifactOrigin("origin-a1b2c3")) - require.NoError(t, database.UpsertSession(Session{ - ID: "rejected", Project: "project", Machine: "local", Agent: "claude", - })) - claims, err := database.PendingArtifactExports(ctx, 10) - require.NoError(t, err) - require.Len(t, claims, 1) - - require.NoError(t, database.FinalizeArtifactExports(ctx, []ArtifactExportOutcome{{ - Item: claims[0], Rejection: "session message limit exceeded", - }})) - pending, err := database.PendingArtifactExports(ctx, 10) - require.NoError(t, err) - assert.Empty(t, pending) - rejection, ok, err := database.GetArtifactExportRejection(ctx, "rejected") - require.NoError(t, err) - require.True(t, ok) - assert.Equal(t, claims[0].Generation, rejection.Generation) - assert.Equal(t, "session message limit exceeded", rejection.Error) - assert.NotEmpty(t, rejection.RejectedAt) - - require.NoError(t, database.ReplaceSessionMessages("rejected", []Message{{ - SessionID: "rejected", Ordinal: 0, Role: "user", Content: "changed", - }})) - pending, err = database.PendingArtifactExports(ctx, 10) - require.NoError(t, err) - require.Len(t, pending, 1) - assert.Greater(t, pending[0].Generation, claims[0].Generation) - _, ok, err = database.GetArtifactExportRejection(ctx, "rejected") - require.NoError(t, err) - assert.False(t, ok) -} -``` - -Add: - -- `TestArtifactExportRejectionCannotConsumeNewerGeneration`: mutate after taking - a claim, then assert `ErrArtifactExportClaimStale` and that the new - generation remains pending. - -- `TestArtifactCheckpointHeadAndRejectionsCommitAtomically`: record a head with - one successful and one rejected outcome and assert head, clean rows, and - diagnostics; inject a stale generation and assert none of them change. - -- `TestArtifactRequeueLifecycleClearsRejection`: cover divergent origin adoption - and resync, asserting new generations are pending and rejection fields are - empty. - -- Migration coverage opening a database whose queue lacks the three new columns, - then asserting all columns exist without losing the queue row. - -- [ ] **Step 2: Run the lifecycle tests and observe missing schema/API - failures** - -Run: - -```bash -CGO_ENABLED=1 go test -tags fts5 ./internal/db \ - -run 'TestArtifactExportRejection|TestArtifactCheckpointHeadAndRejections|TestArtifactRequeueLifecycle' \ - -count=1 -v -``` - -Expected: build or test FAIL because rejection columns and outcome APIs do not -exist. - -- [ ] **Step 3: Add queue columns and non-destructive migrations** - -Change the queue schema to: - -```sql -CREATE TABLE IF NOT EXISTS artifact_export_queue ( - session_id TEXT PRIMARY KEY, - enqueued_at TEXT NOT NULL - DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - generation INTEGER NOT NULL DEFAULT 1, - pending INTEGER NOT NULL DEFAULT 1 CHECK (pending IN (0, 1)), - rejected_generation INTEGER, - last_error TEXT NOT NULL DEFAULT '', - rejected_at TEXT -); -``` - -Append these entries to `schemaColumnMigrations()`: - -```go -{ - "artifact_export_queue", "rejected_generation", - "ALTER TABLE artifact_export_queue ADD COLUMN rejected_generation INTEGER", -}, -{ - "artifact_export_queue", "last_error", - "ALTER TABLE artifact_export_queue ADD COLUMN last_error TEXT NOT NULL DEFAULT ''", -}, -{ - "artifact_export_queue", "rejected_at", - "ALTER TABLE artifact_export_queue ADD COLUMN rejected_at TEXT", -}, -``` - -Every queue conflict that creates a new generation must add: - -```sql -rejected_generation = NULL, -last_error = '', -rejected_at = NULL -``` - -Apply that reset to all three session triggers, `enqueueArtifactExportTx`, -`requeueArtifactExportsSQL`, and `requeueArtifactOriginExportsSQL`. -`bootstrapArtifactExportQueueSQL` remains `INSERT OR IGNORE`: it does not -advance an existing generation. - -Update the resync queue copy to insert the new columns but deliberately clear -them because it advances every copied generation: - -```sql -INSERT INTO main.artifact_export_queue( - session_id, enqueued_at, generation, pending, - rejected_generation, last_error, rejected_at -) -SELECT session_id, enqueued_at, generation + 1, 1, NULL, '', NULL -FROM old_db.artifact_export_queue WHERE true -ON CONFLICT(session_id) DO UPDATE SET - enqueued_at = CASE - WHEN artifact_export_queue.pending = 1 AND excluded.pending = 1 - THEN min(artifact_export_queue.enqueued_at, excluded.enqueued_at) - WHEN artifact_export_queue.pending = 1 - THEN artifact_export_queue.enqueued_at - ELSE excluded.enqueued_at - END, - generation = max(artifact_export_queue.generation, excluded.generation) + 1, - pending = 1, - rejected_generation = NULL, - last_error = '', - rejected_at = NULL -``` - -- [ ] **Step 4: Implement per-claim finalization** - -Add the outcome and rejection structs exactly as declared in **Interfaces**. -Convert old item slices with: - -```go -func successfulArtifactExportOutcomes( - items []ArtifactExportQueueItem, -) []ArtifactExportOutcome { - outcomes := make([]ArtifactExportOutcome, len(items)) - for i, item := range items { - outcomes[i] = ArtifactExportOutcome{Item: item} - } - return outcomes -} -``` - -Replace the clean-row helper with outcome finalization: - -```go -func finalizeArtifactExportOutcomesTx( - ctx context.Context, tx *sql.Tx, outcomes []ArtifactExportOutcome, -) error { - items := make([]ArtifactExportQueueItem, len(outcomes)) - for i, outcome := range outcomes { - items[i] = outcome.Item - } - unique, err := validateArtifactExportClaimsTx(ctx, tx, items) - if err != nil { - return err - } - bySession := make(map[string]string, len(outcomes)) - for _, outcome := range outcomes { - if previous, ok := bySession[outcome.Item.SessionID]; ok && - previous != outcome.Rejection { - return fmt.Errorf( - "conflicting artifact export outcomes for session %s", - outcome.Item.SessionID, - ) - } - bySession[outcome.Item.SessionID] = outcome.Rejection - } - for _, item := range unique { - rejection := bySession[item.SessionID] - var rejectedGeneration any - var rejectedAt any - if rejection != "" { - rejectedGeneration = item.Generation - rejectedAt = time.Now().UTC().Format(time.RFC3339Nano) - } - result, err := tx.ExecContext(ctx, ` - UPDATE artifact_export_queue SET - pending = 0, - rejected_generation = ?, - last_error = ?, - rejected_at = ? - WHERE session_id = ? AND generation = ? AND pending = 1`, - rejectedGeneration, rejection, rejectedAt, - item.SessionID, item.Generation, - ) - if err != nil { - return fmt.Errorf("finalizing artifact export %s: %w", item.SessionID, err) - } - rows, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("reading artifact export finalization %s: %w", item.SessionID, err) - } - if rows != 1 { - return fmt.Errorf( - "%w: session %s generation %d", - ErrArtifactExportClaimStale, item.SessionID, item.Generation, - ) - } - } - return nil -} -``` - -`rejected_generation` is written from the validated claim only and is never used -to decide staleness. - -Implement `FinalizeArtifactExports` with the same lock/transaction structure as -`AcknowledgeArtifactExports`. Make `AcknowledgeArtifactExports` call it with -successful outcomes. - -Move the current checkpoint body into `RecordArtifactCheckpointHeadOutcomes`, -call `finalizeArtifactExportOutcomesTx` after the head insert, and retain -`RecordArtifactCheckpointHead` as a successful-outcome wrapper. - -Implement diagnostic lookup: - -```go -func (db *DB) GetArtifactExportRejection( - ctx context.Context, sessionID string, -) (ArtifactExportRejection, bool, error) { - var rejection ArtifactExportRejection - err := db.getReader().QueryRowContext(ctx, ` - SELECT session_id, rejected_generation, last_error, rejected_at - FROM artifact_export_queue - WHERE session_id = ? - AND rejected_generation IS NOT NULL - AND last_error <> '' - AND rejected_at IS NOT NULL`, sessionID, - ).Scan( - &rejection.SessionID, &rejection.Generation, - &rejection.Error, &rejection.RejectedAt, - ) - if errors.Is(err, sql.ErrNoRows) { - return ArtifactExportRejection{}, false, nil - } - if err != nil { - return ArtifactExportRejection{}, false, - fmt.Errorf("reading artifact export rejection: %w", err) - } - return rejection, true, nil -} -``` - -- [ ] **Step 5: Verify schema, lifecycle, and existing acknowledgement - behavior** - -Run: - -```bash -CGO_ENABLED=1 go test -tags fts5 ./internal/db \ - -run 'ArtifactExport|ArtifactPublication|ArtifactCheckpoint|ArtifactOrigin' \ - -count=1 -v -``` - -Expected: PASS. - -- [ ] **Step 6: Commit Task 2** - -```bash -git add internal/db/schema.sql internal/db/db.go \ - internal/db/orphaned.go internal/db/artifact_publication.go \ - internal/db/artifact_publication_test.go -git commit -m "feat(artifact): persist rejected export generations" \ - -m "Deterministic failures need a durable terminal outcome that remains generation-guarded and checkpoint-atomic. Record diagnostics on clean queue rows while making every later enqueue a fresh retry." -``` - -______________________________________________________________________ - -### Task 3: Add a Transactional Bounded Export Loader - -**Files:** - -- Create: `internal/db/artifact_export_load.go` -- Create: `internal/db/artifact_export_load_test.go` -- Modify: `internal/db/usage_events.go:285-335` - -**Interfaces:** - -- Produces: - -```go -var ErrArtifactExportLimit = errors.New("artifact export load limit exceeded") - -type ArtifactExportLoadLimits struct { - Messages int - UsageEvents int - MessageToolCalls int - ToolResultEvents int - SessionToolCalls int - SessionResultEvents int - MessageBytes int64 - UsageBytes int64 -} - -type ArtifactExportData struct { - Messages []Message - UsageEvents []UsageEvent -} - -func (db *DB) LoadArtifactExportData( - context.Context, string, ArtifactExportLoadLimits, -) (ArtifactExportData, error) -``` - -- Consumes: `selectMessageCols`, `scanMessages`, `attachToolCallsWithQuerier`, - and a factored `scanUsageEvents`. - -- [ ] **Step 1: Write table-driven failing boundary tests** - -Create `internal/db/artifact_export_load_test.go` with: - -```go -func smallArtifactExportLoadLimits() ArtifactExportLoadLimits { - return ArtifactExportLoadLimits{ - Messages: 2, UsageEvents: 2, - MessageToolCalls: 2, ToolResultEvents: 2, - SessionToolCalls: 3, SessionResultEvents: 3, - MessageBytes: 1 << 20, UsageBytes: 1 << 20, - } -} -``` - -Add exact-boundary and `limit + 1` subtests for: - -- three messages with `Messages: 2`; -- three usage events with `UsageEvents: 2`; -- three calls on one message with `MessageToolCalls: 2`; -- four calls across messages with `SessionToolCalls: 3`; -- three result events on one call with `ToolResultEvents: 2`; -- four result events across calls with `SessionResultEvents: 3`. - -Every failure must use: - -```go -_, err := database.LoadArtifactExportData(ctx, "session", limits) -require.ErrorIs(t, err, ErrArtifactExportLimit) -``` - -For every collection, remove the last row and assert the exact boundary loads -successfully with literal expected lengths. - -Add raw-byte tests with one message whose only non-empty exported strings are -role `"user"` and content `"abcd"`: - -```go -limits := smallArtifactExportLoadLimits() -limits.MessageBytes = 8 -data, err := database.LoadArtifactExportData(ctx, "session", limits) -require.NoError(t, err) -require.Len(t, data.Messages, 1) - -limits.MessageBytes = 7 -_, err = database.LoadArtifactExportData(ctx, "session", limits) -require.ErrorIs(t, err, ErrArtifactExportLimit) -``` - -Add an analogous usage event with source `"src"` and model `"model"`: -`UsageBytes: 8` succeeds and `UsageBytes: 7` fails. - -- [ ] **Step 2: Add the cardinality-scaling regression** - -Use two separate databases containing 3 and 10,000 messages for the claimed -session, with `Messages: 2`. Measure allocations around only -`LoadArtifactExportData` after setup: - -```go -smallAllocs := testing.AllocsPerRun(5, func() { - _, err := small.LoadArtifactExportData(ctx, "session", limits) - require.ErrorIs(t, err, ErrArtifactExportLimit) -}) -largeAllocs := testing.AllocsPerRun(5, func() { - _, err := large.LoadArtifactExportData(ctx, "session", limits) - require.ErrorIs(t, err, ErrArtifactExportLimit) -}) -assert.LessOrEqual(t, largeAllocs, smallAllocs+50, - "preflight allocation must remain bounded by limit, not session cardinality") -``` - -This catches removal of the SQL `LIMIT limits.Messages + 1` guard without -inspecting source text. - -- [ ] **Step 3: Run tests and observe the missing bounded-loader API** - -Run: - -```bash -CGO_ENABLED=1 go test -tags fts5 ./internal/db \ - -run '^TestLoadArtifactExportData' -count=1 -v -``` - -Expected: build FAIL because the API does not exist. - -- [ ] **Step 4: Add the limits, data type, and validation** - -Create `internal/db/artifact_export_load.go` with the declarations from -**Interfaces** and: - -```go -func validateArtifactExportLoadLimits(limits ArtifactExportLoadLimits) error { - if limits.Messages < 1 || limits.UsageEvents < 1 || - limits.MessageToolCalls < 1 || limits.ToolResultEvents < 1 || - limits.SessionToolCalls < 1 || limits.SessionResultEvents < 1 || - limits.MessageBytes < 1 || limits.UsageBytes < 1 { - return errors.New("artifact export load limits must all be positive") - } - if limits.Messages == math.MaxInt || limits.UsageEvents == math.MaxInt || - limits.SessionToolCalls == math.MaxInt || - limits.SessionResultEvents == math.MaxInt { - return errors.New("artifact export load limits must permit a limit-plus-one probe") - } - return nil -} - -func artifactExportLimitf(format string, args ...any) error { - return fmt.Errorf("%w: %s", ErrArtifactExportLimit, fmt.Sprintf(format, args...)) -} -``` - -- [ ] **Step 5: Implement bounded preflight queries** - -Define exact raw-byte expressions: - -```go -const artifactMessageRawBytesSQL = ` - length(CAST(role AS BLOB)) + - length(CAST(content AS BLOB)) + - length(CAST(thinking_text AS BLOB)) + - length(CAST(COALESCE(timestamp, '') AS BLOB)) + - length(CAST(model AS BLOB)) + - length(CAST(token_usage AS BLOB)) + - length(CAST(claude_message_id AS BLOB)) + - length(CAST(claude_request_id AS BLOB)) + - length(CAST(source_type AS BLOB)) + - length(CAST(source_subtype AS BLOB)) + - length(CAST(source_uuid AS BLOB)) + - length(CAST(source_parent_uuid AS BLOB))` - -const artifactToolCallRawBytesSQL = ` - length(CAST(tool_name AS BLOB)) + - length(CAST(category AS BLOB)) + - length(CAST(COALESCE(tool_use_id, '') AS BLOB)) + - length(CAST(COALESCE(input_json, '') AS BLOB)) + - length(CAST(COALESCE(skill_name, '') AS BLOB)) + - length(CAST(COALESCE(result_content, '') AS BLOB)) + - length(CAST(COALESCE(subagent_session_id, '') AS BLOB)) + - length(CAST(COALESCE(file_path, '') AS BLOB))` - -const artifactResultEventRawBytesSQL = ` - length(CAST(COALESCE(tool_use_id, '') AS BLOB)) + - length(CAST(COALESCE(agent_id, '') AS BLOB)) + - length(CAST(COALESCE(subagent_session_id, '') AS BLOB)) + - length(CAST(source AS BLOB)) + - length(CAST(status AS BLOB)) + - length(CAST(content AS BLOB)) + - length(CAST(COALESCE(timestamp, '') AS BLOB))` - -const artifactUsageRawBytesSQL = ` - length(CAST(source AS BLOB)) + - length(CAST(model AS BLOB)) + - length(CAST(cost_status AS BLOB)) + - length(CAST(cost_source AS BLOB)) + - length(CAST(COALESCE(occurred_at, '') AS BLOB)) + - length(CAST(dedup_key AS BLOB))` -``` - -Implement `preflightArtifactExportTx` as four streaming queries, each with -`LIMIT cap + 1` and no `ORDER BY`, so SQLite can stop from the session index -without sorting an oversized session: - -```go -func preflightArtifactExportTx( - ctx context.Context, tx *sql.Tx, sessionID string, limits ArtifactExportLoadLimits, -) error { - messageBytes, err := preflightArtifactMessagesTx(ctx, tx, sessionID, limits) - if err != nil { - return err - } - nestedBytes, err := preflightArtifactToolCallsTx(ctx, tx, sessionID, limits) - if err != nil { - return err - } - resultBytes, err := preflightArtifactResultEventsTx(ctx, tx, sessionID, limits) - if err != nil { - return err - } - if messageBytes+nestedBytes+resultBytes > limits.MessageBytes { - return artifactExportLimitf( - "session %s raw message bytes exceed %d", sessionID, limits.MessageBytes, - ) - } - return preflightArtifactUsageTx(ctx, tx, sessionID, limits) -} -``` - -The four helpers must use these query shapes: - -```sql -SELECT id, ordinal, -FROM messages WHERE session_id = ? LIMIT ? -``` - -```sql -SELECT message_id, -FROM tool_calls WHERE session_id = ? LIMIT ? -``` - -```sql -SELECT tool_call_message_ordinal, call_index, - -FROM tool_result_events WHERE session_id = ? LIMIT ? -``` - -```sql -SELECT -FROM usage_events WHERE session_id = ? LIMIT ? -``` - -For each helper: - -- iterate rows without appending objects; - -- reject when the row count reaches the applicable global `limit + 1`; - -- maintain bounded `map[int64]int` or `map[[2]int]int` parent counts for the - per-message/per-call limits; - -- add raw bytes using overflow-safe comparison - `current > limit || additional > limit-current`; - -- return errors wrapping `ErrArtifactExportLimit`; - -- propagate query, scan, close, and context errors without wrapping the limit - sentinel. - -- [ ] **Step 6: Materialize from the same read snapshot** - -Factor the body of `GetUsageEvents` into: - -```go -func usageEventsWithQuerier( - ctx context.Context, q messageRowsQuerier, sessionID string, limit int, -) ([]UsageEvent, error) -``` - -When `limit > 0`, append `LIMIT ?` to the existing stable-order query and bind -that value. When `limit == 0`, run the current query without a limit. -`GetUsageEvents` passes `0`, preserving its public behavior, while the artifact -loader passes `limits.UsageEvents + 1`. The helper only scans; the caller that -requested a cap compares the returned length with its policy limit. - -Implement the loader: - -```go -func (db *DB) LoadArtifactExportData( - ctx context.Context, sessionID string, limits ArtifactExportLoadLimits, -) (_ ArtifactExportData, retErr error) { - if sessionID == "" { - return ArtifactExportData{}, errors.New("artifact export session id is required") - } - if err := validateArtifactExportLoadLimits(limits); err != nil { - return ArtifactExportData{}, err - } - db.connMu.RLock() - reader := db.reader.Load() - if reader == nil { - db.connMu.RUnlock() - return ArtifactExportData{}, errors.New("database is closed") - } - tx, err := reader.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - db.connMu.RUnlock() - if err != nil { - return ArtifactExportData{}, fmt.Errorf("beginning artifact export snapshot: %w", err) - } - committed := false - defer func() { - if !committed { - retErr = errors.Join(retErr, tx.Rollback()) - } - }() - - if err := preflightArtifactExportTx(ctx, tx, sessionID, limits); err != nil { - return ArtifactExportData{}, err - } - rows, err := tx.QueryContext(ctx, fmt.Sprintf(` - SELECT %s FROM messages - WHERE session_id = ? - ORDER BY ordinal ASC - LIMIT ?`, selectMessageCols), sessionID, limits.Messages+1) - if err != nil { - return ArtifactExportData{}, fmt.Errorf("loading artifact export messages: %w", err) - } - messages, scanErr := scanMessages(rows) - closeErr := rows.Close() - if scanErr != nil || closeErr != nil { - return ArtifactExportData{}, errors.Join(scanErr, closeErr) - } - if len(messages) > limits.Messages { - return ArtifactExportData{}, artifactExportLimitf( - "session %s message count exceeds %d", sessionID, limits.Messages, - ) - } - if err := attachToolCallsWithQuerier(ctx, tx, messages); err != nil { - return ArtifactExportData{}, err - } - usage, err := usageEventsWithQuerier( - ctx, tx, sessionID, limits.UsageEvents+1, - ) - if err != nil { - return ArtifactExportData{}, err - } - if len(usage) > limits.UsageEvents { - return ArtifactExportData{}, artifactExportLimitf( - "session %s usage count exceeds %d", sessionID, limits.UsageEvents, - ) - } - if err := tx.Commit(); err != nil { - return ArtifactExportData{}, fmt.Errorf("committing artifact export snapshot: %w", err) - } - committed = true - return ArtifactExportData{Messages: messages, UsageEvents: usage}, nil -} -``` - -- [ ] **Step 7: Verify boundaries and existing readers** - -Run: - -```bash -CGO_ENABLED=1 go test -tags fts5 ./internal/db \ - -run 'TestLoadArtifactExportData|TestGetUsageEvents|TestGetAllMessages' \ - -count=1 -v -``` - -Expected: PASS. - -- [ ] **Step 8: Commit Task 3** - -```bash -git add internal/db/artifact_export_load.go \ - internal/db/artifact_export_load_test.go internal/db/usage_events.go -git commit -m "feat(artifact): bound export snapshot loading" \ - -m "Wire limits must also cap construction memory. Preflight each claimed session through bounded SQLite queries and only materialize data proven to fit within the existing artifact policy." -``` - -______________________________________________________________________ - -### Task 4: Route Export Through the Bounded Loader and Type Deterministic Limits - -**Files:** - -- Modify: `internal/artifact/limits.go` -- Modify: `internal/artifact/export.go:14-32,104-148,333-370,458-635` -- Modify: `internal/artifact/export_limits_test.go` - -**Interfaces:** - -- Consumes: `db.LoadArtifactExportData` and `db.ErrArtifactExportLimit`. -- Produces: - -```go -var ErrArtifactExportRejected = errors.New("artifact export rejected") - -func artifactExportLoadLimits(artifactLimits) db.ArtifactExportLoadLimits - -func isDeterministicArtifactExportError(error) bool - -func exportToStoreWithLimits( - context.Context, artifactExportStore, ArtifactStore, ExportOptions, - artifactLimits, -) (ExportResult, error) - -func exportClaimedSessionToStore( - context.Context, artifactExportStore, ArtifactStore, string, *db.Session, - artifactLimits, -) (string, bool, error) -``` - -- [ ] **Step 1: Write failing bounded-loader integration tests** - -Add a wrapper that counts legacy reads while embedding the real DB: - -```go -type boundedLoadExportStore struct { - *db.DB - allMessageLoads int - usageLoads int -} - -func (s *boundedLoadExportStore) GetAllMessages( - ctx context.Context, sessionID string, -) ([]db.Message, error) { - s.allMessageLoads++ - return s.DB.GetAllMessages(ctx, sessionID) -} - -func (s *boundedLoadExportStore) GetUsageEvents( - ctx context.Context, sessionID string, -) ([]db.UsageEvent, error) { - s.usageLoads++ - return s.DB.GetUsageEvents(ctx, sessionID) -} -``` - -Export a normal session and assert: - -```go -assert.Zero(t, wrapped.allMessageLoads) -assert.Zero(t, wrapped.usageLoads) -``` - -Add a small-limit direct test through `exportClaimedSessionToStore` using the -signature in **Interfaces**. Seed `limits.sessionMessages + 1` messages and -assert the error satisfies both `ErrArtifactExportRejected` and -`db.ErrArtifactExportLimit`, with no manifest or segment created. - -- [ ] **Step 2: Run tests and observe legacy loading and untyped errors** - -Run: - -```bash -CGO_ENABLED=1 go test -tags fts5 ./internal/artifact \ - -run 'TestExportUsesBoundedSnapshotLoader|TestExportClassifiesBoundedLoadLimit' \ - -count=1 -v -``` - -Expected: FAIL because `ExportToStore` calls `GetAllMessages` and -`GetUsageEvents`, and limit errors are not typed as deterministic rejection. - -- [ ] **Step 3: Map artifact policy to database limits** - -Add: - -```go -var ErrArtifactExportRejected = errors.New("artifact export rejected") - -func artifactExportLoadLimits(limits artifactLimits) db.ArtifactExportLoadLimits { - return db.ArtifactExportLoadLimits{ - Messages: limits.sessionMessages, - UsageEvents: limits.manifestUsageEvents, - MessageToolCalls: limits.messageToolCalls, - ToolResultEvents: limits.toolResultEvents, - SessionToolCalls: limits.sessionToolCalls, - SessionResultEvents: limits.sessionResultEvents, - MessageBytes: limits.sessionDecodedBytes, - UsageBytes: manifestDecodedLimit, - } -} - -func rejectArtifactExportf(format string, args ...any) error { - return fmt.Errorf( - "%w: %s", ErrArtifactExportRejected, fmt.Sprintf(format, args...), - ) -} - -func classifyArtifactExportLoadError(err error) error { - if errors.Is(err, db.ErrArtifactExportLimit) { - return fmt.Errorf("%w: %w", ErrArtifactExportRejected, err) - } - return err -} - -func isDeterministicArtifactExportError(err error) bool { - return errors.Is(err, ErrArtifactExportRejected) || - errors.Is(err, db.ErrArtifactExportLimit) -} -``` - -Wrap existing deterministic validation failures in `exportLoadedSessionToStore`, -`validateExportNestedCollections`, `exportMessageSegmentsToStore`, and generated -manifest/session/segment size checks with `ErrArtifactExportRejected`. Do not -wrap `store.Create`, spool I/O, context, database, or corruption errors. - -- [ ] **Step 4: Replace unbounded reads in claimed and full-recovery paths** - -Change `artifactExportStore` to consume: - -```go -LoadArtifactExportData( - context.Context, string, db.ArtifactExportLoadLimits, -) (db.ArtifactExportData, error) -``` - -Remove `GetAllMessages` and `GetUsageEvents` from the interface after all call -sites switch. - -For a live local session: - -```go -data, err := database.LoadArtifactExportData( - ctx, sessionID, artifactExportLoadLimits(limits), -) -if err != nil { - return "", false, fmt.Errorf( - "loading bounded artifact export data %s: %w", - sessionID, classifyArtifactExportLoadError(err), - ) -} -return exportLoadedSessionToStore( - ctx, store, origin, sess, data.Messages, data.UsageEvents, limits, -) -``` - -Make `ExportToStore` a production-policy wrapper: - -```go -return exportToStoreWithLimits( - ctx, database, store, opts, productionArtifactLimits(), -) -``` - -Move its current body to `exportToStoreWithLimits`. Add -`exportFullToStoreWithDrainRoundsAndLimits(ctx, database, store, origin, drainRounds, limits)` -and have both existing full-export wrappers pass production limits. Every -recursive queue page inside full export must call `exportToStoreWithLimits` with -the same `limits`, so small-limit tests exercise the complete path. - -Use `exportClaimedSessionToStore` in the normal claimed loop and in full -dependency recovery. Keep full recovery fail-fast because it has no claim to -finalize. - -- [ ] **Step 5: Verify bounded loading and deterministic classification** - -Run: - -```bash -CGO_ENABLED=1 go test -tags fts5 ./internal/artifact ./internal/db \ - -run 'ArtifactExport|ExportRejects|ExportUsesBounded|LoadArtifactExport' \ - -count=1 -v -``` - -Expected: PASS. - -- [ ] **Step 6: Commit Task 4** - -```bash -git add internal/artifact/limits.go internal/artifact/export.go \ - internal/artifact/export_limits_test.go -git commit -m "fix(artifact): enforce limits before export materialization" \ - -m "Artifact wire guards were too late to protect construction memory. Route every session export through the bounded SQLite snapshot and classify only deterministic policy failures as rejectable." -``` - -______________________________________________________________________ - -### Task 5: Isolate Deterministic Failures Within a Publication Batch - -**Files:** - -- Modify: `internal/artifact/export.go:33-44,95-280,405-425` -- Modify: `internal/artifact/export_test.go` -- Modify: `internal/artifact/export_limits_test.go` -- Modify: test wrappers overriding `AcknowledgeArtifactExports` or - `RecordArtifactCheckpointHead` - -**Interfaces:** - -- Extends: - -```go -type ExportResult struct { - ExportedSessions int - RejectedSessions int - CheckpointCreated bool - CheckpointSequence int -} -``` - -- `artifactExportStore` consumes: - -```go -FinalizeArtifactExports(context.Context, []db.ArtifactExportOutcome) error -RecordArtifactCheckpointHeadOutcomes( - context.Context, db.ArtifactCheckpointHead, []db.ArtifactExportOutcome, -) error -``` - -- [ ] **Step 1: Write the mixed FIFO regression** - -Seed an already-published session `rejected` and a later valid session `valid`. -Mutate `rejected` to exceed a small test limit, ensure its `enqueued_at` sorts -first, then export both through `exportToStoreWithLimits`. - -Assert observable authority: - -```go -require.NoError(t, err) -assert.Equal(t, 1, result.ExportedSessions) -assert.Equal(t, 1, result.RejectedSessions) -checkpoint := latestStoreCheckpointForTest(t, store, origin) -assert.NotContains(t, checkpoint.Sessions, origin+"~rejected") -assert.Contains(t, checkpoint.Sessions, origin+"~valid") -pending, err := database.PendingArtifactExports(t.Context(), 10) -require.NoError(t, err) -assert.Empty(t, pending) -rejection, ok, err := database.GetArtifactExportRejection( - t.Context(), "rejected", -) -require.NoError(t, err) -require.True(t, ok) -assert.Contains(t, rejection.Error, "message limit") -``` - -This test must fail against the current early-return loop with the valid session -absent and both claims pending. - -- [ ] **Step 2: Add transient and crash-boundary regressions** - -Add separate tests: - -- a store that fails `Create` for `rejected` returns the injected error, records - no rejection, and leaves both claims pending; -- a store that fails checkpoint creation after one deterministic rejection - leaves both claims pending and retains prior checkpoint authority; -- a mutation between collection and checkpoint finalization returns - `db.ErrArtifactExportClaimStale`, leaves the newer generation pending, and - records no stale rejection. - -Use different store doubles for each branch and assert exact call arguments and -counts. - -- [ ] **Step 3: Run the new tests and observe FIFO starvation** - -Run: - -```bash -CGO_ENABLED=1 go test -tags fts5 ./internal/artifact \ - -run 'TestExportContinuesPastDeterministicRejection|TestExportTransientFailureDoesNotReject|TestExportRejectionWaitsForCheckpoint|TestExportStaleRejectionCannotConsumeMutation' \ - -count=1 -v -``` - -Expected: FAIL because the first deterministic error aborts the batch. - -- [ ] **Step 4: Collect per-claim outcomes** - -Refactor the session loop to use: - -```go -changes := make([]db.ArtifactPublicationChange, 0, len(claims)) -outcomes := make([]db.ArtifactExportOutcome, 0, len(claims)) -``` - -For missing/deleted/foreign claimed sessions, append a delete change and a -successful outcome. - -For live sessions: - -```go -manifestHash, created, err := exportClaimedSessionToStore( - ctx, database, store, opts.Origin, sess, limits, -) -if err != nil { - if !claimed || !isDeterministicArtifactExportError(err) { - return result, err - } - changes = append(changes, db.ArtifactPublicationChange{ - SessionID: sessionID, Generation: claim.Generation, Delete: true, - }) - outcomes = append(outcomes, db.ArtifactExportOutcome{ - Item: claim, Rejection: err.Error(), - }) - result.RejectedSessions++ - continue -} -if created && claimed { - result.ExportedSessions++ -} -if claimed { - changes = append(changes, db.ArtifactPublicationChange{ - SessionID: sessionID, Generation: claim.Generation, - ManifestHash: manifestHash, SourceFingerprint: manifestHash, - }) - outcomes = append(outcomes, db.ArtifactExportOutcome{Item: claim}) -} -``` - -Do not classify a deterministic failure from the unclaimed full-recovery pass as -a rejection. - -- [ ] **Step 5: Finalize outcomes only behind checkpoint authority** - -In the verified-unchanged-head fast path, replace acknowledgement with: - -```go -if err := database.FinalizeArtifactExports(ctx, outcomes); err != nil { - return result, err -} -``` - -In both checkpoint record paths, call: - -```go -database.RecordArtifactCheckpointHeadOutcomes(ctx, head, outcomes) -``` - -Update every test wrapper that requeues after finalization to override the new -methods and delegate to the embedded `*db.DB`, preserving the existing -concurrent-write tests. - -Update `mergeArtifactExportResult`: - -```go -total.ExportedSessions += page.ExportedSessions -total.RejectedSessions += page.RejectedSessions -``` - -- [ ] **Step 6: Verify mixed success, transient failure, and full drains** - -Run: - -```bash -CGO_ENABLED=1 go test -tags fts5 ./internal/artifact \ - -run 'ExportContinuesPast|ExportTransient|ExportRejection|ExportStale|ExportFullDrain' \ - -count=1 -v -``` - -Expected: PASS. - -- [ ] **Step 7: Commit Task 5** - -```bash -git add internal/artifact/export.go internal/artifact/export_test.go \ - internal/artifact/export_limits_test.go -git commit -m "fix(artifact): isolate deterministic claim failures" \ - -m "One permanently invalid FIFO entry must not block unrelated publication work. Delete stale authority, checkpoint successful claims, and finalize generation-scoped rejection diagnostics together." -``` - -______________________________________________________________________ - -### Task 6: Complete Lifecycle Coverage and Repository Verification - -**Files:** - -- Modify: `internal/db/artifact_publication_test.go` -- Modify: `internal/artifact/origin_test.go` - -**Interfaces:** - -- Consumes all interfaces from Tasks 1–5. - -- Produces no new production API. - -- [ ] **Step 1: Add the end-to-end lifecycle regression** - -Add one real-store test in `internal/artifact/origin_test.go` that covers: - -1. publish a session under origin A; -1. force a deterministic rejection and verify the next A checkpoint removes it; -1. mutate the session and verify rejection state clears and a later A checkpoint - republishes it; -1. adopt origin B and verify all requeued rows have no stale rejection fields. - -Expected checkpoint maps must use literal session GIDs and explicit -`Contains`/`NotContains` assertions. - -- [ ] **Step 2: Run the lifecycle regression** - -Run: - -```bash -CGO_ENABLED=1 go test -tags fts5 ./internal/artifact ./internal/db \ - -run 'Artifact.*Rejection|Origin.*Rejection|LoadArtifactExport|ExportContinuesPast' \ - -count=1 -v -``` - -Expected: PASS. - -- [ ] **Step 3: Format and run complete affected-package tests** - -```bash -go fmt ./... -git diff --check -CGO_ENABLED=1 go test -tags fts5 ./internal/db ./internal/artifact -count=1 -``` - -Expected: PASS with no formatting errors. - -- [ ] **Step 4: Run repository verification** - -Run: - -```bash -make test-short -go vet ./... -``` - -Expected: both commands exit 0. - -- [ ] **Step 5: Inspect the final diff and private-data hygiene** - -```bash -git status --short -git diff --stat -git diff HEAD -git diff --check -git log --format='%s%n%b%n---' -10 -``` - -Confirm the diff contains no private hostnames, identities, absolute paths, -credentials, generated attribution blocks, or unrelated changes. - -- [ ] **Step 6: Commit lifecycle tests** - -```bash -git add internal/db/artifact_publication_test.go \ - internal/artifact/origin_test.go -git commit -m "test(artifact): cover rejected claim lifecycle" \ - -m "Rejection correctness spans checkpoint removal, generation-driven retry, resync, and origin adoption. Protect the complete lifecycle with real database and artifact-store authority checks." -``` - -- [ ] **Step 7: Request focused code review** - -Dispatch a read-only reviewer against the implementation range. Require review -of origin locking, snapshot lifetime, bounded query shapes, error -classification, checkpoint ordering, resync migration, and tests. Resolve every -Critical or Important issue before publishing. - -- [ ] **Step 8: Push the completed branch** - -After confirming a clean working tree and matching local tests: - -```bash -git push origin artifact/2-export-ledger -``` - -Do not poll CI unless explicitly requested. diff --git a/docs/superpowers/plans/2026-07-27-configurable-chart-palette.md b/docs/superpowers/plans/2026-07-27-configurable-chart-palette.md deleted file mode 100644 index 26845123c..000000000 --- a/docs/superpowers/plans/2026-07-27-configurable-chart-palette.md +++ /dev/null @@ -1,1143 +0,0 @@ -# Configurable Chart Palette 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:** Add a server-wide Settings preference that switches all categorical -charts between the agentsview palette family and a gray-free Matplotlib palette -with up to 36 distinct colors. - -**Architecture:** A typed top-level Go configuration value is persisted through -the existing Settings API and loaded by the existing global frontend settings -store. A focused frontend palette resolver selects either each chart's legacy -palette rules or the adaptive Matplotlib family. Usage centralizes allocation -over its full category universe, while Skill Trend and Trends each consume one -resolved map for every chart/legend representation. - -**Tech Stack:** Go, BurntSushi TOML, Huma/OpenAPI, Svelte 5 runes, TypeScript, -Paraglide JS, kit-ui `SegmentedControl`, Vitest, Testing Library, and testify. - -## Global Constraints - -- The only persisted values are `agentsview` and `matplotlib`; omission defaults - to `agentsview`, while an explicitly present empty string is invalid. -- `agentsview` must preserve existing chart palettes and Usage's preferred hash - plus linear-probing rules. Resolved Usage colors may move when allocation is - shared across both panels because probing depends on the full identifier - set. -- `matplotlib` uses gray-free families of 9, 18, and 36 exact Matplotlib v3.10.5 - colors, selected at active-series counts 1–9, 10–18, and 19 or more. -- Empty identifiers stay muted. In Matplotlib mode, `__other__` uses the general - muted token; in agentsview mode, each surface preserves its current `Other` - token. More than 36 active identifiers cycle only after all 36 colors are - used. -- The setting applies to categorical series only. Do not change heatmaps, - semantic status colors, agent badges, tool categories, or syntax colors. -- The current browser updates from the PUT response. Other browsers adopt the - server-wide value on their next reload; do not add an SSE configuration - event. -- Keep all five Paraglide catalogs (`en`, `zh-CN`, `zh-TW`, `ko`, `fr`) on - identical key sets. -- Do not edit generated Paraglide or OpenAPI files by hand; run their - generators. -- Add no database migration, dependency, compatibility adapter, or - custom-palette editor. -- Matplotlib uses exact, non-theme-adaptive hex values. Treat reduced contrast - in some themes as an accepted fidelity tradeoff, retain text/tooltip - associations, and visually verify discernibility in light, dark, and - high-contrast modes. -- Follow TDD for each task: observe the specified test fail for the missing - behavior before writing production code. -- Before each Go commit, run `go fmt ./...` and `go vet ./...` as required by - the repository instructions. -- Before every commit, invoke the mandatory commit skill and never bypass hooks. - -______________________________________________________________________ - -### Task 1: Typed configuration and TOML persistence - -**Files:** - -- Modify: `internal/config/config.go:451-530` -- Modify: `internal/config/config.go:684-755` -- Modify: `internal/config/config.go:990-1125` -- Modify: `internal/config/config.go:2467-2535` -- Test: `internal/config/config_test.go` -- Test: `internal/config/persistence_test.go` - -**Interfaces:** - -- Produces: `type ChartPalette string` - -- Produces: `ChartPaletteAgentsview`, `ChartPaletteMatplotlib`, and - `DefaultChartPalette` constants - -- Produces: `ParseChartPalette(value string) (ChartPalette, error)` - -- Produces: `func (c Config) ResolvedChartPalette() ChartPalette` - -- Produces: `Config.ChartPalette ChartPalette` serialized as `chart_palette` - -- Consumes: existing `Config.applyConfigTOML` and `Config.SaveSettings` - -- [ ] **Step 1: Write failing configuration tests** - -Add table-driven tests using testify. The production changes that must make -these tests fail are a missing default, a missing TOML assignment, permissive -invalid input, or persistence that does not update both file and memory. - -```go -func TestChartPaletteDefaultsAndLoads(t *testing.T) { - tests := []struct { - name string - toml string - want ChartPalette - }{ - {name: "omitted", want: ChartPaletteAgentsview}, - {name: "agentsview", toml: `chart_palette = "agentsview"`, want: ChartPaletteAgentsview}, - {name: "matplotlib", toml: `chart_palette = "matplotlib"`, want: ChartPaletteMatplotlib}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg, err := Default() - require.NoError(t, err) - require.NoError(t, cfg.applyConfigTOML(tt.toml)) - assert.Equal(t, tt.want, cfg.ResolvedChartPalette()) - }) - } -} - -func TestChartPaletteRejectsInvalidValue(t *testing.T) { - tests := []struct { - name string - toml string - want string - }{ - { - name: "unknown", - toml: `chart_palette = "neon"`, - want: `chart_palette must be "agentsview" or "matplotlib" (got "neon")`, - }, - { - name: "explicit empty", - toml: `chart_palette = ""`, - want: `chart_palette must be "agentsview" or "matplotlib" (got "")`, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg, err := Default() - require.NoError(t, err) - err = cfg.applyConfigTOML(tt.toml) - require.EqualError(t, err, tt.want) - }) - } -} - -func TestSaveSettingsPersistsChartPalette(t *testing.T) { - dir := setupTestEnv(t) - cfg, err := Default() - require.NoError(t, err) - cfg.DataDir = dir - require.NoError(t, cfg.SaveSettings(map[string]any{ - "chart_palette": ChartPaletteMatplotlib, - })) - assert.Equal(t, ChartPaletteMatplotlib, cfg.ChartPalette) - fileCfg := readConfigFile(t, dir) - assert.Equal(t, ChartPaletteMatplotlib, fileCfg.ChartPalette) -} -``` - -- [ ] **Step 2: Run the focused tests and verify RED** - -Run: - -```bash -CGO_ENABLED=1 go test -tags fts5 ./internal/config -run 'TestChartPalette|TestSaveSettingsPersistsChartPalette' -count=1 -``` - -Expected: compilation fails because `ChartPalette`, its constants, and -`ResolvedChartPalette` do not exist. - -- [ ] **Step 3: Implement the typed configuration boundary** - -Add the type and exact validation near the other top-level configuration types: - -```go -type ChartPalette string - -const ( - ChartPaletteAgentsview ChartPalette = "agentsview" - ChartPaletteMatplotlib ChartPalette = "matplotlib" - DefaultChartPalette = ChartPaletteAgentsview -) - -func ParseChartPalette(value string) (ChartPalette, error) { - p := ChartPalette(value) - switch p { - case ChartPaletteAgentsview, ChartPaletteMatplotlib: - return p, nil - default: - return "", fmt.Errorf( - `chart_palette must be "agentsview" or "matplotlib" (got %q)`, - value, - ) - } -} - -func (c Config) ResolvedChartPalette() ChartPalette { - if c.ChartPalette == "" { - return DefaultChartPalette - } - return c.ChartPalette -} -``` - -Add this field to `Config`: - -```go -ChartPalette ChartPalette `json:"chart_palette" toml:"chart_palette"` -``` - -Initialize it to `DefaultChartPalette` in `Default` and decode it in the -anonymous TOML file struct. In `applyConfigTOML`, distinguish omission from an -explicit empty string with the existing TOML metadata: - -```go -if meta.IsDefined("chart_palette") { - p, err := ParseChartPalette(string(file.ChartPalette)) - if err != nil { - return err - } - c.ChartPalette = p -} -``` - -Extend `SaveSettings`' known-key in-memory update: - -```go -if v, ok := patch["chart_palette"]; ok { - if p, ok := v.(ChartPalette); ok { - c.ChartPalette = p - } -} -``` - -Only callers that have already parsed the value may place this typed value in -the patch. - -- [ ] **Step 4: Format and verify GREEN** - -Run: - -```bash -go fmt ./... -go vet ./... -CGO_ENABLED=1 go test -tags fts5 ./internal/config -run 'TestChartPalette|TestSaveSettingsPersistsChartPalette' -count=1 -``` - -Expected: PASS. - -- [ ] **Step 5: Commit the configuration contract** - -```bash -git add internal/config/config.go internal/config/config_test.go internal/config/persistence_test.go -git commit -m "feat(config): persist chart palette selection" -``` - -______________________________________________________________________ - -### Task 2: Settings API and generated client contract - -**Files:** - -- Modify: `internal/server/settings.go` -- Modify: `internal/server/huma_routes_settings.go:44-118` -- Modify: `internal/server/server_test.go:3420-3490` -- Regenerate: `frontend/src/lib/api/generated/models/SettingsResponse.ts` -- Regenerate: `frontend/src/lib/api/generated/models/SettingsUpdateRequest.ts` -- Regenerate: any other files changed by `npm run generate:api` - -**Interfaces:** - -- Consumes: `config.ParseChartPalette` and `Config.ResolvedChartPalette` - -- Produces: required response field `chart_palette` - -- Produces: optional PUT field `chart_palette` - -- Produces: generated TypeScript fields on `SettingsResponse` and - `SettingsUpdateRequest` - -- [ ] **Step 1: Write failing Settings API tests** - -Add one valid round-trip test and one invalid-update test. These exercise the -HTTP and persisted-file contracts rather than private handler state. - -```go -func TestSettingsChartPaletteRoundTrip(t *testing.T) { - te := setup(t) - putSettings := func(body string) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", - strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "http://127.0.0.1:0") - w := httptest.NewRecorder() - te.handler.ServeHTTP(w, req) - return w - } - - w := te.get(t, "/api/v1/settings") - assertStatus(t, w, http.StatusOK) - var initial struct { - ChartPalette config.ChartPalette `json:"chart_palette"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &initial)) - assert.Equal(t, config.ChartPaletteAgentsview, initial.ChartPalette) - - w = putSettings(`{"chart_palette":"matplotlib"}`) - assertStatus(t, w, http.StatusOK) - var updated struct { - ChartPalette config.ChartPalette `json:"chart_palette"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &updated)) - assert.Equal(t, config.ChartPaletteMatplotlib, updated.ChartPalette) - - var persisted struct { - ChartPalette config.ChartPalette `toml:"chart_palette"` - } - _, err := toml.DecodeFile(filepath.Join(te.dataDir, "config.toml"), &persisted) - require.NoError(t, err) - assert.Equal(t, config.ChartPaletteMatplotlib, persisted.ChartPalette) -} - -func TestSettingsRejectInvalidChartPaletteWithoutChangingSelection(t *testing.T) { - te := setup(t) - putSettings := func(body string) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", - strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "http://127.0.0.1:0") - w := httptest.NewRecorder() - te.handler.ServeHTTP(w, req) - return w - } - - w := putSettings(`{"chart_palette":"matplotlib"}`) - assertStatus(t, w, http.StatusOK) - - w = putSettings(`{"chart_palette":"neon"}`) - assertStatus(t, w, http.StatusBadRequest) - assertBodyContains(t, w, `chart_palette must be`) - w = putSettings(`{"chart_palette":""}`) - assertStatus(t, w, http.StatusBadRequest) - assertBodyContains(t, w, `chart_palette must be`) - - w = te.get(t, "/api/v1/settings") - assertStatus(t, w, http.StatusOK) - var got struct { - ChartPalette config.ChartPalette `json:"chart_palette"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) - assert.Equal(t, config.ChartPaletteMatplotlib, got.ChartPalette) -} -``` - -Also change the existing `TestSettingsRemainLockedInPGMode` PUT body to -`{"chart_palette":"matplotlib"}`. Its existing HTTP 501 assertion protects the -new field from bypassing the read-only guard. - -- [ ] **Step 2: Run the server tests and verify RED** - -Run: - -```bash -CGO_ENABLED=1 go test -tags fts5 ./internal/server -run 'TestSettingsChartPalette|TestSettingsRejectInvalidChartPalette' -count=1 -``` - -Expected: the GET response has no `chart_palette`, and PUT does not persist it. - -- [ ] **Step 3: Add the API fields and validation** - -In `settings.go`, import `internal/config` and add: - -```go -ChartPalette config.ChartPalette `json:"chart_palette"` -``` - -to `settingsResponse`, plus: - -```go -ChartPalette *string `json:"chart_palette,omitempty"` -``` - -to `settingsUpdateRequest`. - -In `humaGetSettings`, return `s.cfg.ResolvedChartPalette()` while holding the -existing read lock. In `humaUpdateSettings`, validate before acquiring the write -lock or constructing a persisted patch: - -```go -if in.Body.ChartPalette != nil { - p, err := config.ParseChartPalette(*in.Body.ChartPalette) - if err != nil { - return nil, apiError(http.StatusBadRequest, err.Error()) - } - patch["chart_palette"] = p -} -``` - -Keep the existing read-only guard before all updates. - -- [ ] **Step 4: Regenerate the frontend API client** - -From `frontend/`, run: - -```bash -npm run generate:api -``` - -Verify the generated response has a required `chart_palette: string` and the -request has `chart_palette?: string`. Do not hand-edit generated files. - -- [ ] **Step 5: Format and verify GREEN** - -Run: - -```bash -go fmt ./... -go vet ./... -CGO_ENABLED=1 go test -tags fts5 ./internal/server -run 'TestSettingsChartPalette|TestSettingsRejectInvalidChartPalette|TestSettingsRemainLocked' -count=1 -``` - -Expected: PASS. - -- [ ] **Step 6: Commit the API contract** - -```bash -git add internal/server/settings.go internal/server/huma_routes_settings.go internal/server/server_test.go frontend/src/lib/api/generated -git commit -m "feat(api): expose chart palette setting" -``` - -______________________________________________________________________ - -### Task 3: Shared frontend palette resolver - -**Files:** - -- Create: `frontend/src/lib/utils/chartPalette.ts` -- Create: `frontend/src/lib/utils/chartPalette.test.ts` -- Consume: `frontend/src/lib/utils/projectColor.ts` - -**Interfaces:** - -- Produces: `type ChartPalette = "agentsview" | "matplotlib"` - -- Produces: `DEFAULT_CHART_PALETTE` - -- Produces: `isChartPalette(value: unknown): value is ChartPalette` - -- Produces: - `chartSeriesColorMap(ids, palette, agentsviewColor?, agentsviewOtherColor?)` - -- Consumes: `seriesColorMap(ids)` for Usage's existing allocator - -- [ ] **Step 1: Write failing resolver tests** - -Use literal expected colors so the test does not reproduce the allocator's -implementation. The production changes that must fail these tests are wrong -family boundaries, a gray entry, unstable input ordering, or early cycling. - -```ts -import { describe, expect, it } from "vite-plus/test"; -import { - chartSeriesColorMap, - DEFAULT_CHART_PALETTE, - isChartPalette, -} from "./chartPalette.js"; - -const ids = (count: number) => - Array.from({ length: count }, (_, i) => `series-${String(i).padStart(2, "0")}`); - -const EXPECTED_TAB20 = [ - "#1f77b4", "#aec7e8", "#ff7f0e", "#ffbb78", "#2ca02c", - "#98df8a", "#d62728", "#ff9896", "#9467bd", "#c5b0d5", - "#8c564b", "#c49c94", "#e377c2", "#f7b6d2", "#bcbd22", - "#dbdb8d", "#17becf", "#9edae5", -] as const; - -const EXPECTED_TAB20B_AND_TAB20C = [ - "#393b79", "#5254a3", "#6b6ecf", "#9c9ede", "#637939", - "#8ca252", "#b5cf6b", "#cedb9c", "#8c6d31", "#bd9e39", - "#e7ba52", "#e7cb94", "#843c39", "#ad494a", "#d6616b", - "#e7969c", "#7b4173", "#a55194", "#ce6dbd", "#de9ed6", - "#3182bd", "#6baed6", "#9ecae1", "#c6dbef", "#e6550d", - "#fd8d3c", "#fdae6b", "#fdd0a2", "#31a354", "#74c476", - "#a1d99b", "#c7e9c0", "#756bb1", "#9e9ac8", "#bcbddc", - "#dadaeb", -] as const; - -it("uses gray-free tab10 in Matplotlib order", () => { - const colors = chartSeriesColorMap(ids(9), "matplotlib"); - expect([...colors.values()]).toEqual([ - "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", - "#8c564b", "#e377c2", "#bcbd22", "#17becf", - ]); -}); - -it("uses exact families through their advertised capacities", () => { - expect([...chartSeriesColorMap(ids(10), "matplotlib").values()]) - .toEqual(EXPECTED_TAB20.slice(0, 10)); - expect([...chartSeriesColorMap(ids(18), "matplotlib").values()]) - .toEqual(EXPECTED_TAB20); - expect([...chartSeriesColorMap(ids(19), "matplotlib").values()]) - .toEqual(EXPECTED_TAB20B_AND_TAB20C.slice(0, 19)); - expect([...chartSeriesColorMap(ids(36), "matplotlib").values()]) - .toEqual(EXPECTED_TAB20B_AND_TAB20C); -}); - -it("keeps every advertised Matplotlib family gray-free", () => { - const isAchromatic = (hex: string) => { - const red = hex.slice(1, 3); - const green = hex.slice(3, 5); - const blue = hex.slice(5, 7); - return red === green && green === blue; - }; - for (const count of [9, 18, 36]) { - expect([...chartSeriesColorMap(ids(count), "matplotlib").values()] - .some(isAchromatic)).toBe(false); - } -}); - -it("uses all 36 colors before cycling", () => { - const atCapacity = chartSeriesColorMap(ids(36), "matplotlib"); - expect(new Set(atCapacity.values()).size).toBe(36); - const overflow = chartSeriesColorMap(ids(37), "matplotlib"); - expect(overflow.get("series-36")).toBe(overflow.get("series-00")); -}); - -it("is stable across permutations and keeps Other muted", () => { - const input = ["zeta", "__other__", "alpha", "zeta"]; - expect([...chartSeriesColorMap(input, "matplotlib")]).toEqual([ - ...chartSeriesColorMap([...input].reverse(), "matplotlib"), - ]); - expect(chartSeriesColorMap(input, "matplotlib").get("__other__")) - .toBe("var(--text-muted)"); -}); - -it("preserves the supplied agentsview colors", () => { - const colors = chartSeriesColorMap( - ["beta", "alpha"], - DEFAULT_CHART_PALETTE, - (_id, index) => [`legacy-a`, `legacy-b`][index]!, - ); - expect([...colors.values()]).toEqual(["legacy-a", "legacy-b"]); - expect(isChartPalette("agentsview")).toBe(true); - expect(isChartPalette("matplotlib")).toBe(true); - expect(isChartPalette("neon")).toBe(false); -}); - -it("preserves a surface-specific agentsview Other token", () => { - const colors = chartSeriesColorMap( - ["commit", "__other__"], - "agentsview", - () => "legacy", - "var(--chart-series-other)", - ); - expect(colors.get("__other__")).toBe("var(--chart-series-other)"); -}); -``` - -- [ ] **Step 2: Run the resolver test and verify RED** - -From `frontend/`, run: - -```bash -npm test -- src/lib/utils/chartPalette.test.ts -``` - -Expected: FAIL because `chartPalette.ts` does not exist. - -- [ ] **Step 3: Implement the resolver with exact palettes** - -Create the type guard and three constant arrays. Port the exact values: - -```ts -export type ChartPalette = "agentsview" | "matplotlib"; -export const DEFAULT_CHART_PALETTE: ChartPalette = "agentsview"; -const MUTED = "var(--text-muted)"; - -const TAB10 = [ - "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", - "#8c564b", "#e377c2", "#bcbd22", "#17becf", -] as const; - -const TAB20 = [ - "#1f77b4", "#aec7e8", "#ff7f0e", "#ffbb78", "#2ca02c", - "#98df8a", "#d62728", "#ff9896", "#9467bd", "#c5b0d5", - "#8c564b", "#c49c94", "#e377c2", "#f7b6d2", "#bcbd22", - "#dbdb8d", "#17becf", "#9edae5", -] as const; - -const TAB20B_AND_TAB20C = [ - "#393b79", "#5254a3", "#6b6ecf", "#9c9ede", "#637939", - "#8ca252", "#b5cf6b", "#cedb9c", "#8c6d31", "#bd9e39", - "#e7ba52", "#e7cb94", "#843c39", "#ad494a", "#d6616b", - "#e7969c", "#7b4173", "#a55194", "#ce6dbd", "#de9ed6", - "#3182bd", "#6baed6", "#9ecae1", "#c6dbef", "#e6550d", - "#fd8d3c", "#fdae6b", "#fdd0a2", "#31a354", "#74c476", - "#a1d99b", "#c7e9c0", "#756bb1", "#9e9ac8", "#bcbddc", - "#dadaeb", -] as const; -``` - -Implement `chartSeriesColorMap` with this exact signature: - -```ts -export function chartSeriesColorMap( - ids: readonly string[], - palette: ChartPalette, - agentsviewColor?: (id: string, index: number) => string, - agentsviewOtherColor?: string, -): ReadonlyMap -``` - -For `agentsview`, deduplicate non-empty identifiers in first-seen order and use -the supplied callback. When there is no callback, use the existing -`seriesColorMap(ids)` so Usage retains its stable hash and collision resolution. -Exclude `__other__` from the active count and assign it -`agentsviewOtherColor ?? MUTED`. - -For `matplotlib`, deduplicate and lexically sort non-empty, non-`__other__` -identifiers, choose the array by active count, then assign -`colors[index % colors.length]`. Add empty and `__other__` entries as `MUTED` -after allocation. - -- [ ] **Step 4: Verify GREEN** - -From `frontend/`, run: - -```bash -npm test -- src/lib/utils/chartPalette.test.ts src/lib/utils/projectColor.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Commit the palette resolver** - -```bash -git add frontend/src/lib/utils/chartPalette.ts frontend/src/lib/utils/chartPalette.test.ts -git commit -m "feat(frontend): add adaptive Matplotlib chart palette" -``` - -______________________________________________________________________ - -### Task 4: Server-backed Appearance preference and localization - -**Files:** - -- Modify: `frontend/src/lib/stores/settings.svelte.ts` -- Modify: `frontend/src/lib/stores/settings.test.ts` -- Modify: `frontend/src/lib/components/settings/AppearanceSettings.svelte` -- Modify: `frontend/src/lib/components/settings/AppearanceSettings.test.ts` -- Modify: `frontend/src/lib/components/settings/SettingsPage.test.ts` -- Modify: `frontend/messages/en.json` -- Modify: `frontend/messages/zh-CN.json` -- Modify: `frontend/messages/zh-TW.json` -- Modify: `frontend/messages/ko.json` -- Modify: `frontend/messages/fr.json` - -**Interfaces:** - -- Consumes: `ChartPalette`, `DEFAULT_CHART_PALETTE`, and `isChartPalette` - -- Consumes: generated `SettingsResponse.chart_palette` and - `SettingsUpdateRequest.chart_palette` - -- Produces: reactive `settings.chartPalette: ChartPalette` - -- Produces: server-backed Appearance `SegmentedControl` - -- [ ] **Step 1: Write failing store tests** - -Reset `settings.chartPalette` to `DEFAULT_CHART_PALETTE` in `beforeEach`. Add a -load test and a save/failure test using the existing generated-service mock: - -```ts -it("loads the server chart palette", async () => { - settingsService.getApiV1Settings.mockResolvedValue({ - agent_dirs: {}, chart_palette: "matplotlib", github_configured: false, - host: "127.0.0.1", port: 8080, read_only: false, - require_auth: false, terminal: { mode: "auto" }, - }); - await settings.load(); - expect(settings.chartPalette).toBe("matplotlib"); -}); - -it("keeps the confirmed palette when saving fails", async () => { - settings.chartPalette = "agentsview"; - settingsService.putApiV1Settings.mockRejectedValue(new Error("save failed")); - await settings.save({ chart_palette: "matplotlib" }); - expect(settings.chartPalette).toBe("agentsview"); - expect(settings.error).toBe("save failed"); -}); -``` - -- [ ] **Step 2: Write the failing Appearance interaction test** - -Mock `putApiV1Settings`, return a full Settings response with -`chart_palette: "matplotlib"`, click the Matplotlib radio, and assert both the -exact request and the confirmed selection: - -```ts -expect(settingsService.putApiV1Settings).toHaveBeenCalledWith({ - requestBody: { chart_palette: "matplotlib" }, -}); -expect(getByRole("radio", { name: "Matplotlib" }).getAttribute("aria-checked")) - .toBe("true"); -``` - -Add a read-only assertion that both palette radio buttons are disabled. - -Update every successful `getApiV1Settings` response fixture in both -`settings.test.ts` and `SettingsPage.test.ts` to include -`chart_palette: "agentsview"`, including the existing read-only-mode store test. -Those fixtures exercise the real settings store and must satisfy the new -required API response contract. - -- [ ] **Step 3: Run the focused tests and verify RED** - -From `frontend/`, run: - -```bash -npm test -- src/lib/stores/settings.test.ts src/lib/components/settings/AppearanceSettings.test.ts src/lib/components/settings/SettingsPage.test.ts -``` - -Expected: FAIL because the store property and chart-color control do not exist. - -- [ ] **Step 4: Implement the settings store field** - -Import the palette type and helpers. Add: - -```ts -chartPalette: ChartPalette = $state(DEFAULT_CHART_PALETTE); -``` - -to `SettingsStore`. In both successful `load` and `save`, require the server -value to pass `isChartPalette`; if it does not, throw an `Error` naming the -invalid response value so the existing catch path retains the last confirmed -palette. Assign `this.chartPalette = data.chart_palette` only after validation. - -- [ ] **Step 5: Add localized Appearance copy** - -Add identical keys to all five catalogs: - -```json -"appearance_chart_palette": "Chart colors", -"appearance_chart_palette_agentsview": "Agentsview", -"appearance_chart_palette_matplotlib": "Matplotlib" -``` - -Use these translations for the label key: - -- `zh-CN`: `图表配色` -- `zh-TW`: `圖表配色` -- `ko`: `차트 색상` -- `fr`: `Couleurs des graphiques` - -Keep the two technical palette names untranslated. Update -`appearance_description` to mention chart colors in each language: - -- `en`: `Theme, layout, chart colors, and block visibility preferences.` - -- `zh-CN`: `主题、布局、图表配色和内容块可见性偏好。` - -- `zh-TW`: `主題、版面配置、圖表配色與內容區塊顯示偏好。` - -- `ko`: `테마, 레이아웃, 차트 색상, 블록 표시 여부 설정입니다.` - -- `fr`: - `Préférences de thème, de disposition, de couleurs des graphiques et de visibilité des blocs.` - -- [ ] **Step 6: Add the shared control to Appearance** - -Create a localized `$derived` options array and render a new `.option-row`: - -```svelte - { - if (!isChartPalette(value)) return; - void settings.save({ chart_palette: value }); - }} -/> -``` - -Use the existing responsive `.option-row` rules. Do not add one-off control -chrome. The root `App.svelte` already calls `settings.load()` at startup, so do -not add a second bootstrap request. - -- [ ] **Step 7: Generate localization and verify GREEN** - -From `frontend/`, run: - -```bash -npm run i18n:compile -npm test -- src/lib/stores/settings.test.ts src/lib/components/settings/AppearanceSettings.test.ts src/lib/components/settings/SettingsPage.test.ts -npm run check -``` - -Expected: PASS with matching locale keys and no Svelte type errors. - -- [ ] **Step 8: Commit the server-backed preference UI** - -```bash -git add frontend/src/lib/stores/settings.svelte.ts frontend/src/lib/stores/settings.test.ts frontend/src/lib/components/settings/AppearanceSettings.svelte frontend/src/lib/components/settings/AppearanceSettings.test.ts frontend/src/lib/components/settings/SettingsPage.test.ts frontend/messages -git commit -m "feat(settings): select the server chart palette" -``` - -______________________________________________________________________ - -### Task 5: Usage chart integration - -**Files:** - -- Create: `frontend/src/lib/utils/usageChartColors.ts` -- Create: `frontend/src/lib/utils/usageChartColors.test.ts` -- Modify: `frontend/src/lib/components/usage/UsagePage.svelte` -- Modify: `frontend/src/lib/components/usage/UsagePage.test.ts` -- Modify: `frontend/src/lib/components/usage/CostTimeSeriesChart.svelte` -- Modify: `frontend/src/lib/components/usage/CostTimeSeriesChart.test.ts` -- Modify: `frontend/src/lib/components/usage/AttributionPanel.svelte` -- Modify: `frontend/src/lib/components/usage/AttributionPanel.test.ts` - -**Interfaces:** - -- Consumes: `settings.chartPalette` - -- Consumes: `chartSeriesColorMap(ids, palette)` with no legacy callback - -- Produces: `usageChartColorMaps(summary, palette)`, containing one - full-universe map for each `GroupBy` value - -- Produces: matching colors across time-series paths/legend and attribution - list/treemap/rail - -- [ ] **Step 1: Write failing Matplotlib rendering tests** - -In the new utility suite, construct a summary with these ten model totals: -`model-alpha`, `model-bravo`, `model-charlie`, `model-delta`, `model-echo`, -`model-foxtrot`, `model-golf`, `model-hotel`, `model-india`, and `model-zulu`. -Give the last five enough cost to become the Cost Over Time leaders. Assert that -`usageChartColorMaps(summary, "matplotlib").model` contains all ten identifiers -and uses the 18-color Matplotlib family. This test fails if the family is chosen -from the capped time-series list. - -In the Usage page suite, return that summary from the existing API mock and -select Model for both panels. Assert `model-zulu` has the expected shared color -in both Cost Over Time and Cost Attribution: - -- agentsview: `var(--accent-sky)` from the full-universe collision allocation, - rather than the capped subset's `var(--accent-indigo)`; -- matplotlib: `#c5b0d5` from full-universe `tab20`, rather than the capped - subset's `#9467bd` from `tab10`. - -This is the cross-component regression: both assertions fail if either child -computes a local active-set map. - -In the isolated Attribution child suite, pass a deliberately distinguishable -supplied color map and assert that its list rows and treemap tiles use those -exact colors. This independently fails if Attribution ignores the shared map and -recomputes a local allocation whose full totals happen to match the shared -universe. - -In both child component suites, reset `settings.chartPalette = "agentsview"` -during cleanup. Add Matplotlib cases using the reported collision pair: - -```ts -settings.chartPalette = "matplotlib"; -// Active IDs sort as claude-opus-5, gpt-5.6-sol. -// Assert the rendered colors are #1f77b4 and #ff7f0e and that every legend or -// treemap representation equals its corresponding series color. -``` - -For Cost Over Time, assert SVG `fill` attributes and legend-dot styles. For -Attribution list and treemap, assert the two row/tile colors are distinct and -correspond to the exact first two Matplotlib colors after browser style -normalization. Keep the existing agentsview collision tests unchanged so the -default behavior remains protected. - -Add one reactive Usage page assertion: mount in `agentsview` mode, capture the -Cost Over Time path fills and Cost Attribution dots, assign -`settings.chartPalette = "matplotlib"`, await `tick()`, and assert both panels -now use their shared Matplotlib map. This is the observable guarantee that a -successful Settings response updates open charts without a reload. - -- [ ] **Step 2: Run the Usage component tests and verify RED** - -From `frontend/`, run: - -```bash -npm test -- src/lib/utils/usageChartColors.test.ts src/lib/components/usage/UsagePage.test.ts src/lib/components/usage/CostTimeSeriesChart.test.ts src/lib/components/usage/AttributionPanel.test.ts -``` - -Expected: the utility is missing, child components do not accept a shared map, -and the two Usage panels cannot maintain cross-panel color parity. - -- [ ] **Step 3: Route Usage through the shared resolver** - -Create `usageChartColorMaps(summary, palette)` in `usageChartColors.ts`. For -each of `project`, `model`, and `agent`, union identifiers from the -corresponding summary totals and every daily breakdown, then pass the sorted -full universe to `chartSeriesColorMap`. Return: - -```typescript -export interface UsageChartColorMaps { - project: ReadonlyMap; - model: ReadonlyMap; - agent: ReadonlyMap; -} - -export function usageChartColorMaps( - summary: UsageSummaryResponse | null, - palette: ChartPalette, -): UsageChartColorMaps -``` - -In `UsagePage.svelte`, derive these maps once from `usage.summary` and -`settings.chartPalette`. Pass the map matching -`usage.toggles.timeSeries.groupBy` to Cost Over Time and the map matching -`usage.toggles.attribution.groupBy` to Cost Attribution. - -Add a required `colorMap: ReadonlyMap` prop to both child -components. Remove their local allocator calls and use the supplied map for -paths, legend dots, treemap items, rails, and list fills. Keep `__other__` out -of the shared active universe and muted in rendering. Update isolated child -tests to pass a map created by `usageChartColorMaps`. - -- [ ] **Step 4: Verify GREEN** - -From `frontend/`, run: - -```bash -npm test -- src/lib/utils/usageChartColors.test.ts src/lib/components/usage/UsagePage.test.ts src/lib/components/usage/CostTimeSeriesChart.test.ts src/lib/components/usage/AttributionPanel.test.ts src/lib/utils/projectColor.test.ts src/lib/utils/chartPalette.test.ts -``` - -Expected: PASS in both palette modes. - -- [ ] **Step 5: Commit Usage integration** - -```bash -git add frontend/src/lib/utils/usageChartColors.ts frontend/src/lib/utils/usageChartColors.test.ts frontend/src/lib/components/usage/UsagePage.svelte frontend/src/lib/components/usage/UsagePage.test.ts frontend/src/lib/components/usage/CostTimeSeriesChart.svelte frontend/src/lib/components/usage/CostTimeSeriesChart.test.ts frontend/src/lib/components/usage/AttributionPanel.svelte frontend/src/lib/components/usage/AttributionPanel.test.ts -git commit -m "feat(usage): honor the selected chart palette" -``` - -______________________________________________________________________ - -### Task 6: Skill Trend and Trends integration - -**Files:** - -- Modify: `frontend/src/lib/components/analytics/SkillTrend.svelte` -- Modify: `frontend/src/lib/components/analytics/SkillTrend.test.ts` -- Modify: `frontend/src/lib/components/trends/TrendsPage.svelte` -- Modify: `frontend/src/lib/components/trends/TrendsPage.test.ts` - -**Interfaces:** - -- Consumes: `settings.chartPalette` - -- Consumes: - `chartSeriesColorMap(ids, palette, agentsviewColor, agentsviewOtherColor)` - -- Preserves: Skill Trend's `--chart-series-N` and Trends' `--trend-*` colors in - agentsview mode - -- Produces: one color map per active series set, shared by chart, tooltip, - legend, and table consumers - -- [ ] **Step 1: Write failing Skill Trend tests** - -Reset `settings.chartPalette` around every test. Add a Matplotlib test that -mounts the existing three-skill fixture and asserts colors by series identifier, -not by palette order: - -```ts -expect(legendColorBySkill).toEqual({ - commit: "#1f77b4", - deploy: "#ff7f0e", - review: "#2ca02c", -}); -// DOM line order remains volume-ranked: commit, review, deploy. -expect(lineColors).toEqual(["#1f77b4", "#2ca02c", "#ff7f0e"]); -``` - -The allocation is lexical while rendering remains volume-ranked. Retain the -existing survivor-stability test and run it in Matplotlib mode too: hiding -`commit` must not repaint `review`. Extend the folded-tail test to assert that -Skill Trend's line and legend entry for `Other` still use -`var(--chart-series-other)` in agentsview mode. - -- [ ] **Step 2: Write a failing Trends test** - -Use a response with terms intentionally returned out of lexical order, set -`settings.chartPalette = "matplotlib"`, and assert that chart strokes and term -table swatches use the same color per term. Assert lexical identifier assignment -(`alpha` gets `#1f77b4`, `zeta` gets `#ff7f0e`) independently of response order. -Keep the current `--trend-*` agentsview test unchanged. - -- [ ] **Step 3: Run both suites and verify RED** - -From `frontend/`, run: - -```bash -npm test -- src/lib/components/analytics/SkillTrend.test.ts src/lib/components/trends/TrendsPage.test.ts -``` - -Expected: new tests see the existing six- and twelve-color component palettes. - -- [ ] **Step 4: Integrate Skill Trend** - -Import `settings` and `chartSeriesColorMap`. Derive one map from -`allSeries.map((series) => series.key)` and supply the existing color tokens as -the legacy callback: - -```ts -const colorMap = $derived(chartSeriesColorMap( - allSeries.map((series) => series.key), - settings.chartPalette, - (_key, index) => `var(--chart-series-${index + 1})`, - "var(--chart-series-other)", -)); -``` - -Look colors up by `series.key` everywhere instead of recalculating from visible -series. The resolver keeps `__other__` on `--chart-series-other` in agentsview -mode and uses the general muted fallback in Matplotlib mode. This preserves -colors when a legend chip hides a series. - -- [ ] **Step 5: Integrate Trends** - -Derive active term IDs from `trends.response?.series`. Replace the direct index -lookup in `colorFor` with a shared resolved map: - -```ts -const termColorMap = $derived(chartSeriesColorMap( - (trends.response?.series ?? []).map((item) => item.term), - settings.chartPalette, - (_term, index) => TREND_PALETTE[index % TREND_PALETTE.length]!, -)); - -function colorFor(term: string, index: number): string { - return termColorMap.get(term) ?? TREND_PALETTE[index % TREND_PALETTE.length]!; -} -``` - -Continue passing the same `colorFor` function to `TrendsLineChart` and -`TermTable`. - -- [ ] **Step 6: Verify GREEN** - -From `frontend/`, run: - -```bash -npm test -- src/lib/components/analytics/SkillTrend.test.ts src/lib/components/trends/TrendsPage.test.ts -``` - -Expected: PASS, including survivor stability and chart/table parity. - -- [ ] **Step 7: Commit remaining chart integrations** - -```bash -git add frontend/src/lib/components/analytics/SkillTrend.svelte frontend/src/lib/components/analytics/SkillTrend.test.ts frontend/src/lib/components/trends/TrendsPage.svelte frontend/src/lib/components/trends/TrendsPage.test.ts -git commit -m "feat(charts): apply the configured categorical palette" -``` - -______________________________________________________________________ - -### Task 7: Full verification and clean generated state - -**Files:** - -- Verify only; modify a production or test file only if a preceding requirement - is not met, then repeat that task's RED/GREEN cycle and create a focused - commit. - -**Interfaces:** - -- Consumes: all prior task outputs - -- Produces: a clean worktree with generated artifacts synchronized and all - relevant checks passing - -- [ ] **Step 1: Re-run generators and confirm no drift** - -From `frontend/`, run: - -```bash -npm run generate:api -npm run i18n:compile -git diff --exit-code -- src/lib/api/generated src/lib/paraglide -``` - -Expected: no diff. - -- [ ] **Step 2: Run frontend verification** - -From `frontend/`, run: - -```bash -npm test -npm run check -npm run check:kit-ui -``` - -Expected: all tests and checks pass without warnings attributable to this -change. - -- [ ] **Step 3: Inspect Matplotlib colors in every supported appearance mode** - -Start the repository's isolated fixture server from the repository root: - -```bash -bash scripts/e2e-server.sh -``` - -Use the T3 preview at `http://127.0.0.1:8090`. Select Matplotlib under Settings, -then inspect Usage, Skill Trend, and Trends in light, dark, and high-contrast -modes. Confirm that series marks remain discernible, and that every mark can be -identified through its text legend, table row, or tooltip. The raw hex fills and -strokes must remain the exact Matplotlib values; reduced contrast in a specific -theme is an accepted fidelity tradeoff, not a reason to substitute colors. - -Stop only the fixture-server process created for this step after inspection. - -- [ ] **Step 4: Run Go verification** - -From the repository root, run: - -```bash -go fmt ./... -go vet ./... -CGO_ENABLED=1 go test -tags fts5 ./internal/config ./internal/server -``` - -Expected: PASS. - -- [ ] **Step 5: Inspect the final branch state** - -Run: - -```bash -git diff --check -git status --short -git log --oneline --decorate -8 -``` - -Expected: no uncommitted tracked changes and the focused implementation commits -from Tasks 1–6 following the design-spec and plan commits. diff --git a/docs/superpowers/specs/2026-07-21-microdollar-money-design.md b/docs/superpowers/specs/2026-07-21-microdollar-money-design.md deleted file mode 100644 index 0c25b5564..000000000 --- a/docs/superpowers/specs/2026-07-21-microdollar-money-design.md +++ /dev/null @@ -1,344 +0,0 @@ -# Authoritative microdollar money - -## Problem - -AgentsView currently represents monetary amounts and model-pricing rates as -`float64` values in Go, `REAL` values in SQLite, `DOUBLE PRECISION` values in -PostgreSQL and DuckDB, and JSON numbers in public responses. Floating-point -arithmetic makes equality, aggregation, fingerprints, and synchronization depend -on binary approximations of decimal dollar values. - -Money needs one exact representation throughout the application. Existing -archives must migrate without losing sessions or requiring source transcripts to -still exist, and SQLite and PostgreSQL must continue to expose identical -behavior. DuckDB remains a disposable mirror and must rebuild rather than gain -an in-place compatibility path. - -## Goals - -- Represent every monetary value as signed 64-bit microdollars, where one US - dollar equals 1,000,000 microdollars. - -- Make the integer value authoritative in parsing, persistence, calculation, - aggregation, comparison, sorting, fingerprints, synchronization, and public - machine-readable output. - -- Encode a public monetary value only as a money object containing an integer: - - ```json - {"microdollars": 420000} - ``` - -- Render ordinary dollar strings such as `$0.42` in human-facing CLI tables and - UI labels. - -- Migrate existing SQLite and PostgreSQL data forward transactionally without - deleting or rebuilding the persistent session archive. - -- Preserve observable behavior and query-shape parity between SQLite and - PostgreSQL. - -- Rebuild DuckDB mirrors through the existing schema-version mechanism. - -- Reject invalid or overflowing monetary input without partially writing it. - -## Non-goals - -- Do not retain floating-point monetary fields in public or internal types. -- Do not emit a second dollar-valued representation beside microdollars. -- Do not retain old floating-point database columns, dual reads, dual writes, - aliases, version-bridging reads, or permanent repair gates. -- Do not introduce a general currency framework. All tracked money remains USD. -- Do not use nanodollars, arbitrary-precision decimals, or arbitrary-precision - integers. -- Do not convert unrelated floating-point measurements such as ratios, - percentages, durations, scores, or percentiles. -- Do not reparse or recreate the SQLite session archive to perform the schema - migration. - -## Representation - -### Money type - -A focused internal money package will define the shared value type: - -```go -type Money struct { - Microdollars int64 `json:"microdollars"` -} -``` - -All fields whose value is denominated in dollars will use `Money` or `*Money`. -Pointers retain the existing distinction between an absent reported cost and an -authoritative zero cost. Existing `HasCost`-style fields continue to distinguish -an unavailable complete estimate from a real zero-valued estimate. - -The package will own: - -- checked addition and subtraction; -- comparison without conversion; -- exact parsing of decimal text at a declared source scale; -- conversion of integer cents and other declared source units; -- checked token-rate multiplication with wide integer intermediates; -- nearest-microdollar rounding; and -- ordinary dollar formatting for human presentation. - -No method will expose a floating-point dollar value. UI and CLI formatters will -split the signed integer into whole dollars and fractional microdollars, then -apply the existing display precision rules without converting the amount to a -float. - -### Range and precision - -Signed `int64` microdollars cover approximately plus or minus $9.22 trillion. -Microdollars remain exactly representable as JavaScript numbers through -approximately $9.0 billion, comfortably above the operational range of a local -agent-cost archive. API schemas will describe the member as an `int64` integer, -and backend storage retains the full signed 64-bit range. - -One microdollar is $0.000001, or one ten-thousandth of a cent. An imported -amount requiring finer precision is rounded to the nearest microdollar, with an -exact half rounded away from zero. Nonnegative source charges are validated as -nonnegative. Signed `Money` remains necessary for derived differences such as a -comparison delta. - -### Pricing calculation - -Model rates are `Money` values whose surrounding field names declare that the -amount applies per million tokens. For example: - -```json -{ - "inputCostPerMTok": {"microdollars": 3000000} -} -``` - -For one independently priced usage row, the calculation will: - -1. select the billable token counts using the existing reasoning-token and cache - rules; -1. multiply every token category by its integer microdollars-per-million-token - rate using an overflow-safe wide intermediate; -1. sum those unrounded products; -1. divide by 1,000,000 tokens; and -1. round the combined row result once to the nearest microdollar. - -Rounding once per usage row avoids separately rounding input, output, cache -write, and cache-read components. Aggregate totals sum authoritative row costs -exactly. Reported costs remain authoritative over catalog pricing exactly as -they are today. - -## Input boundaries - -### Agent-reported values - -JSON sources will preserve the original numeric token with `json.Number`, raw -JSON text, or an equivalent non-floating representation and convert it directly -to `Money`. Integer source units, including Grok cost ticks, will convert with -integer quotient and remainder arithmetic. - -Some upstream formats already store their values as floating-point database -columns. AgentsView cannot recover decimal precision already discarded by an -upstream producer. Those boundary values will be checked for finiteness and -range, rounded immediately to `Money`, and never retained or aggregated as -floats inside AgentsView. - -### Cursor admin usage - -Cursor's `chargedCents` and `cursorTokenFee` values will be decoded from their -original decimal JSON tokens and converted directly from cents to microdollars. -Stored fields will be renamed to communicate their actual unit: - -- `charged_microdollars` -- `cursor_token_fee_microdollars` - -Cursor cost attribution will use `charged_microdollars` directly instead of -dividing cents by a floating-point constant in a query. - -### Pricing catalogs - -LiteLLM per-token decimal prices will be parsed from their JSON number text and -scaled directly into microdollars per million tokens. Embedded pricing snapshots -will contain integer microdollar rates. Pricing equality and canonical digests -will use those integers. - -Custom model-pricing configuration will replace dollar-number settings with -integer settings whose names carry the unit: - -```toml -[custom_model_pricing."example-model"] -input_microdollars_per_mtok = 3_000_000 -output_microdollars_per_mtok = 15_000_000 -cache_creation_microdollars_per_mtok = 3_750_000 -cache_read_microdollars_per_mtok = 300_000 -``` - -Old floating-point configuration keys will not be accepted through an alias or -fallback path. - -## Persistence - -### Fresh schemas - -Fresh SQLite, PostgreSQL, and DuckDB schemas will use integer columns: - -- `usage_events.cost_microdollars`, nullable; -- `cursor_usage_events.charged_microdollars`, not null; -- `cursor_usage_events.cursor_token_fee_microdollars`, not null; and -- `model_pricing.input_microdollars_per_mtok`, `output_microdollars_per_mtok`, - `cache_creation_microdollars_per_mtok`, and - `cache_read_microdollars_per_mtok`, all not null. - -SQLite uses `INTEGER`; PostgreSQL and DuckDB use `BIGINT`. - -### SQLite migration - -One forward migration will detect the released floating-point schema and -transactionally rebuild the three affected tables. It will: - -1. validate every legacy value for finiteness, nonnegative source-domain - constraints, and scaled `int64` range; -1. create replacement tables with their final integer schemas; -1. copy every row while converting legacy values with nearest-microdollar - rounding; -1. preserve primary keys, nullable cost semantics, foreign keys, and stable - deduplication keys; -1. replace the old tables; and -1. recreate all indexes. - -The migration commits only after all three tables and indexes are valid. A -failure rolls back the entire transaction. Fresh schemas already have the final -columns, and a completed migration has no legacy columns, making the migration -idempotent without a continuing compatibility read. - -The migration will fail closed if it encounters an unexpected mixed schema -instead of guessing which representation is authoritative. - -### PostgreSQL migration - -PostgreSQL will run the equivalent forward migration inside a transaction. It -will preflight legacy values for finiteness, nonnegative source-domain -constraints, and scaled `BIGINT` range, then convert and rename the affected -columns. The final schema and every query will use the same units and semantics -as SQLite. - -The migration runs before code that expects the new column names. It neither -keeps the old columns nor supports simultaneous old and new application -versions. - -### DuckDB mirror - -The DuckDB mirror schema version will advance from 3 to 4. Version 4 creates -only the integer money columns. A version mismatch triggers the established full -rebuild into a fresh validated file followed by atomic replacement. No DuckDB -`ALTER` migration or version-bridging query will be added. - -### Synchronization and fingerprints - -SQLite-to-PostgreSQL push, SQLite-to-DuckDB push, orphan preservation, session -export, and push fingerprints will transport and hash integer microdollars. -Fingerprints will never format money through a floating-point decimal string. -Backend comparisons and update predicates will compare integers directly. - -## Public contracts - -### Machine-readable output - -Every machine-readable monetary field becomes a single `Money` object. Examples -include: - -```json -{ - "cost": {"microdollars": 420000}, - "rollup_cost": {"microdollars": 1260000}, - "totalCost": {"microdollars": 1680000}, - "totalCostDelta": {"microdollars": -250000} -} -``` - -This is the only monetary JSON representation. Fields such as `cost_usd` and -floating-point forms of `cost`, `totalCost`, pricing rates, savings, or deltas -will be removed rather than emitted beside the authoritative object. - -Naming follows each surface's established casing. The parent field supplies the -semantic meaning, while the object supplies the unit. Versioned usage, activity, -and session-export schemas will receive breaking-version increments. OpenAPI and -generated TypeScript types will define one reusable `Money` schema. - -Dimensionless ratios and percentages remain JSON numbers. Token counts, session -counts, and other nonmonetary integers remain unchanged. - -### Human-facing output - -CLI tables, status lines, terminal summaries, and UI labels will render ordinary -dollars from `Money`, for example `$0.42`, `<$0.01`, or `$1,234.56` according to -the surface's existing display rules. They will not display the word -`microdollars` or raw scaled integers unless the user explicitly requests -machine-readable JSON. - -Frontend calculations that sort, group, compare, or add costs will use the -integer `microdollars` member. Dollar conversion is confined to display -formatters. Ratios divide integer amounts only at the dimensionless result -boundary. - -## Error handling - -- Decimal parsing rejects malformed, empty, non-finite, and out-of-range input. -- Source charges and rates reject negative values. -- Checked arithmetic reports overflow instead of wrapping or saturating. -- Migration validation reports the affected table and row identity without - including private session content. -- A migration error leaves the original schema and rows intact. -- API aggregation returns an error if an exact sum exceeds `int64`; it does not - fall back to floating-point arithmetic. -- Presentation formatting handles the full signed `int64` range without taking - an absolute value that overflows at `math.MinInt64`. - -## Testing - -Tests will protect behavior owned by AgentsView rather than the mechanics of Go, -SQLite, PostgreSQL, DuckDB, or JSON libraries. - -Focused money tests will cover: - -- exact decimal conversion at zero, positive, negative-derived, and exponent - forms used by source data; -- values immediately below, at, and above a half-microdollar boundary; -- `int64` range boundaries and checked addition/subtraction; -- token-rate calculation that would overflow a naive 64-bit intermediate but - produces a valid final result; -- combined-component rounding once per usage row; and -- dollar display formatting, including negative values and `math.MinInt64`. - -SQLite migration tests will open a legacy fixture containing fractional reported -costs, Cursor cents, pricing rates, an explicit zero, and a null cost. They will -assert the exact migrated integers, preserved identifiers and row counts, final -column types, and unchanged deduplication behavior. Invalid or overflowing -legacy input will prove that the transaction leaves the original tables intact. - -PostgreSQL integration tests will apply the migration to the equivalent released -schema and assert the same values and behavior. Fresh-schema tests will verify -that neither backend creates legacy money columns. - -SQLite and PostgreSQL usage tests will use identical token and pricing fixtures -to protect cost, savings, filtering, ordering, rollups, and breakdown-sum -parity. DuckDB tests will verify the version-4 rebuild boundary, integer push -values, integer aggregation, and query parity with SQLite. - -API, export, CLI JSON, and frontend tests will assert the sole -`{"microdollars": ...}` representation and exact integer values. Human CLI and -frontend tests will assert ordinary dollar rendering from those integers. -Realistic mutations such as an incorrect scale, component-wise rounding, -floating-point sorting, omitted cache cost, or stale legacy column will cause at -least one focused test to fail. - -## Delivery constraints - -The change is one atomic domain migration even though it touches parsers, -storage, services, APIs, CLI output, and the frontend. Shipping only one backend -or retaining floats in an intermediate layer would leave two competing monetary -authorities and is therefore outside the design. - -Implementation will use test-first cycles. The database change will be the only -new forward schema migration in its pull request, and shipped migration history -will remain untouched. diff --git a/docs/superpowers/specs/2026-07-27-artifact-export-reliability-design.md b/docs/superpowers/specs/2026-07-27-artifact-export-reliability-design.md deleted file mode 100644 index aed2d13bd..000000000 --- a/docs/superpowers/specs/2026-07-27-artifact-export-reliability-design.md +++ /dev/null @@ -1,292 +0,0 @@ -# Artifact Export Reliability Design - -## Status - -Approved design for follow-up reliability work on the artifact publication -ledger. - -## Problem - -Artifact export currently has three related consistency gaps: - -1. Publication changes accept any syntactically valid origin. A stale exporter - can consume the global queue under a namespace that no longer matches the - database's persisted `artifact_origin_id`. -1. Export reads all messages, tool calls, result events, and usage events before - enforcing cardinality and decoded-size limits. The limits protect the wire - format but do not bound memory used while constructing the source object - graph. -1. One deterministically unexportable FIFO claim aborts its whole batch. The - claim remains oldest and is retried forever, so unrelated later claims do - not make progress. - -These are one design problem: a claimed generation needs a bounded read, -origin-consistent publication decision, and a durable terminal outcome. - -## Goals - -- Prevent a claim from changing publication authority under any origin other - than the database's persisted artifact origin. -- Bound export-side object construction before loading a complete session. -- Let successful claims publish even when another claim in the same batch is - deterministically unexportable. -- Remove a rejected session's stale publication from the next checkpoint. -- Record the rejected generation and reason durably. -- Retry a rejected session automatically when a later session mutation advances - its queue generation. -- Preserve generation guards across origin adoption, resync, concurrent writes, - checkpoint creation, and acknowledgement. - -## Non-goals - -- Retrying transient database, context, or artifact-store failures within one - export call. -- Adding an operator-facing rejection UI or CLI command in this change. -- Replacing the current immutable segment and manifest formats. -- Rewriting the exporter as a fully streaming wire encoder. - -## Chosen Approach - -Use a transactional bounded loader followed by capped materialization, and add -durable rejection state to the existing export queue. - -This is deliberately smaller than a fully streaming exporter. The database first -examines a session through bounded pages inside one read snapshot, stopping as -soon as a cardinality or raw-byte budget is exceeded. Only a session proven to -fit those budgets is materialized through the existing message and usage models. -Existing canonical wire-size checks remain the final, exact format guard. - -Deterministic failures become per-claim rejected outcomes. Other claims in the -same bounded batch continue. Successful and rejected publication changes are -checkpointed together, after which their exact generations are finalized -atomically. - -## Consistency Invariants - -1. A publication mutation is valid only when its origin equals the non-empty - persisted `artifact_origin_id`. -1. Origin validation, claim-generation validation, and publication mutation - occur under the same SQLite writer reservation. -1. No claim becomes clean until its resulting publication map has a durable - checkpoint, or an unchanged recorded checkpoint has been verified. -1. A deterministic rejection deletes any publication for that session under the - active origin. -1. Rejection state belongs to one queue generation. A later generation is new - work and must be eligible for export. -1. Transient failures do not acknowledge, reject, or otherwise consume claims. -1. A stale claim cannot finalize either success or rejection after a concurrent - mutation or origin adoption advances its generation. - -## Origin Validation - -`ApplyArtifactPublicationChanges` will read `artifact_origin_id` after acquiring -the existing artifact-publication writer reservation and before validating or -mutating claims. - -- Missing or empty persisted origin: return an origin-mismatch error. -- Persisted origin differs from the requested origin: return an origin-mismatch - error. -- Matching origin: continue with claim validation and publication changes. - -The check remains inside the same transaction as the authoritative mutation. An -optional earlier read may avoid creating immutable dependencies for an obviously -stale exporter, but it is only an optimization; the locked check is the -correctness boundary. Immutable objects created before a failed locked check are -harmless unreferenced content and never become checkpoint authority. - -## Bounded Session Loading - -### API boundary - -The database will expose an artifact-specific snapshot loader accepting a -database-owned limits struct. Keeping the struct in `internal/db` avoids an -import cycle while making every bound explicit: - -- 32,768 messages per session; -- 32,768 usage events per manifest; -- 256 tool calls per message and 65,536 per session; -- 1,024 result events per tool call and 262,144 per session; -- 256 MiB of raw export-visible message and nested-collection bytes before - materialization; -- 16 MiB of raw export-visible usage-event bytes before materialization. - -These values match the existing artifact cardinality, session decoded-size, and -manifest decoded-size limits. The artifact package maps them to the -database-owned structure rather than defining a second policy. Test-only small -limits exercise every boundary. - -### Read algorithm - -The loader owns one read-only SQLite transaction so preflight and -materialization observe the same snapshot. - -1. Read messages in bounded ordinal pages. Count rows and the byte lengths of - export-visible scalar and JSON columns without retaining complete message - objects. -1. Read tool calls and result events in bounded pages, enforcing per-parent and - per-session cardinality while accumulating export-visible raw bytes. -1. Read usage events in bounded pages, enforcing count and raw-byte budgets. -1. Stop immediately when any count or byte budget is exceeded and return a typed - deterministic limit error. -1. If preflight succeeds, materialize messages with nested collections and usage - events from the same snapshot. Every materializing query retains a - `limit + 1` defense so an implementation regression cannot silently bypass - preflight. -1. Commit or roll back the read transaction before artifact-store I/O. - -Paging keeps preflight memory bounded by a small page, not by session size. The -raw-byte budgets bound the object graph that may be materialized. Existing -canonical segment, session decoded-byte, manifest decoded-byte, and nested -collection checks remain authoritative for exact wire encoding, including JSON -escaping overhead. - -The loader scans only rows belonging to the claimed session and stops at the -first exceeded limit. Work therefore scales with the configured cap, not with -the total archive or an arbitrarily oversized session. - -## Failure Classification - -Only failures that are deterministic for the claimed generation are rejectable: - -- bounded-loader cardinality or raw-byte limit violations; -- existing artifact nested-collection limits; -- exact generated session, segment-reference, or manifest decoded-size limits; -- other explicitly typed validation failures that cannot succeed without a - session mutation or a future format-limit change. - -Context cancellation, SQLite errors, stale claims, origin mismatch, temporary -filesystem failures, and artifact-store errors remain fatal for the export call. -They leave every unfinalized claim pending. - -Deterministic errors use a typed sentinel so the batch loop never classifies an -error by matching text. - -## Durable Rejection State - -Add non-destructive columns to `artifact_export_queue`: - -- `rejected_generation INTEGER`; -- `last_error TEXT NOT NULL DEFAULT ''`; -- `rejected_at TEXT`. - -Schema initialization and column migrations add the fields without rebuilding or -deleting the persistent archive. Resync preserves the columns as part of the -ledger schema, but clears their values when it advances copied generations for -mandatory re-export. - -Every enqueue path advances `generation`, sets `pending = 1`, and clears stale -rejection fields. Thus a content mutation automatically retries a previously -rejected session. Existing FIFO timestamp behavior remains unchanged. - -Checkpoint finalization accepts per-claim outcomes rather than only a list of -successful acknowledgements: - -- success: mark the exact generation clean and clear rejection fields; -- rejection: mark the exact generation clean and store its generation, stable - error text, and timestamp. - -Finalization validates every generation before updating any row. Recording a new -checkpoint head and finalizing all outcomes remain one transaction. The -verified-unchanged-head path uses the same outcome finalizer without rewriting -the head. - -## Batch Export Flow - -For each selected claim: - -1. Load the session metadata. -1. Treat missing, deleted, or foreign sessions as publication deletions. -1. Bounded-load export data for a live local session. -1. On success, create immutable dependencies and collect an upsert change. -1. On a deterministic failure, collect a deletion change and rejected outcome, - then continue to the next claim. -1. On a transient failure, abort and leave all unfinalized claims pending. - -After the loop, apply all successful upserts and deletion changes together. -Build the checkpoint from the resulting publication ledger, then finalize -successful, deleted, and rejected outcomes atomically with the checkpoint head. - -`ExportResult` gains a rejected-session count. Rejections are not returned as a -fatal error because that would stop full-export pagination after a batch that -was durably handled. Durable queue state retains the detailed reasons. - -If a rejected session had a prior publication, its deletion advances the -publication revision and the new checkpoint omits it. If it had no publication, -the verified-unchanged-head path can finalize the rejection without creating a -redundant checkpoint. - -The full-export dependency-recovery pass remains fail-fast for sessions that -have no claim because it cannot safely consume unclaimed authority. Dirty -claimed sessions, including deterministic rejections, are handled by the bounded -queue-drain phases before and after recovery. - -## Resync and Origin Adoption - -Resync copies queue generation authority with the rest of the artifact ledger, -then advances every copied generation and forces it pending as it does today. -That new generation clears the prior rejection state. Rebuilt-only sessions are -still enqueued after origin restore; their fresh generation likewise has no -rejection state. - -Origin adoption continues to requeue live sessions and target-origin publication -IDs. Requeueing clears rejection state because every adopted claim must be -evaluated under the newly active namespace. - -## Testing - -Tests use real SQLite databases and artifact stores where observable behavior -matters. - -### Origin tests - -- Applying changes under an origin different from persisted `artifact_origin_id` - returns the typed mismatch error. -- No publication, revision, queue acknowledgement, or checkpoint-head state - changes. -- Matching-origin behavior remains unchanged. - -### Bounded loading tests - -- Message, usage-event, tool-call, result-event, and raw-byte limits fail at - `limit + 1`. -- Exact boundaries succeed. -- A large fixture proves preflight stops after bounded rows rather than loading - the entire session graph. -- Existing segment and manifest exact-size tests remain green. - -### Rejection and progress tests - -- An oversized oldest claim is removed from publication authority, recorded as - rejected, and marked clean only after checkpoint finalization. -- A valid later claim in the same batch publishes and is acknowledged. -- A transient store failure does not reject or acknowledge either claim. -- Mutating a rejected session advances its generation, clears stale rejection - state, and makes it pending again. -- A stale rejection outcome cannot consume a newer generation. -- A crash/error before checkpoint finalization leaves the rejected claim - pending. - -### Lifecycle tests - -- Resync advances copied and rebuilt generations, clears prior rejection state, - and leaves every affected row pending. -- Divergent origin adoption clears rejection state and requeues the relevant - claims. - -## Trade-offs - -- Preflight adds a second read pass for sessions that fit. Both passes are - bounded by the same per-session caps, and avoiding unbounded allocation is - worth the extra local SQLite work. -- Raw-byte preflight is a memory-safety bound, not a replacement for canonical - encoded-size validation. Exact wire checks remain after materialization. -- Rejected sessions disappear from checkpoint authority until their content - changes and exports successfully. This is preferable to serving stale data - as if it represented the current archive. -- Rejection diagnostics are durable but intentionally have no UI in this change. - A later status surface can read the recorded queue fields without a schema - redesign. -- A future release that tightens export limits must include a migration that - requeues existing publications. Otherwise the unclaimed full-export - dependency-recovery pass remains intentionally fail-fast and cannot convert - an old clean publication into a rejected claim. diff --git a/docs/superpowers/specs/2026-07-27-configurable-chart-palette-design.md b/docs/superpowers/specs/2026-07-27-configurable-chart-palette-design.md deleted file mode 100644 index cf92f47cb..000000000 --- a/docs/superpowers/specs/2026-07-27-configurable-chart-palette-design.md +++ /dev/null @@ -1,208 +0,0 @@ -# Configurable Chart Palette Design - -## Summary - -agentsview will offer a server-wide choice between its existing categorical -palette family and a larger gray-free Matplotlib palette. The selection will be -persisted in `config.toml`, exposed through the existing Settings API, and -editable from the Appearance section without restarting the server. - -The new setting applies to categorical data series. It does not change semantic -status colors, heatmaps, agent identity badges, tool-category colors, or syntax -highlighting. - -## Goals - -- Let an administrator select the chart palette once for every client of an - agentsview server. -- Preserve existing theme-aware palettes plus Usage's preferred hash and probing - rules; resolved Usage colors may move under full-universe allocation to - enforce cross-panel parity. -- Provide as many as 36 distinct, non-gray categorical colors before cycling. -- Keep a series color consistent across its chart, legend, treemap, and - attribution list. -- Apply a saved preference immediately in the browser that changes it and on the - next application load for every other client. -- Keep the setting available in `config.toml` for headless administration. - -## Non-goals - -- User-specific or browser-specific palette preferences. -- Custom user-authored color lists. -- Changing the five-series limit or the `Other` aggregation in Cost Over Time. -- Recoloring semantic states, heatmaps, agent badges, or tool categories. -- Broadcasting palette changes to other already-open browsers over SSE. Those - clients adopt the server-wide value when they next reload. - -## Configuration and API Contract - -Add a typed top-level configuration value: - -```toml -chart_palette = "agentsview" -``` - -The accepted values are `agentsview` and `matplotlib`. An omitted value resolves -to `agentsview`. An explicitly present empty string is invalid in both TOML and -the Settings API; omission, not an empty sentinel, selects the default. This -preserves existing installations without writing a new key merely because the -server was upgraded. - -Configuration loading rejects any non-empty value outside the accepted set with -an error that names `chart_palette` and the valid values. The Settings API uses -the same validation and returns HTTP 400 for an invalid update. - -`GET /api/v1/settings` adds `chart_palette` to its response. -`PUT /api/v1/settings` accepts an optional `chart_palette` field. A valid update -is written through the existing locked partial-settings path, updates the -server's in-memory configuration, and is returned in the response. The -read-only-server behavior remains unchanged: updates return the existing -not-implemented error. - -The generated frontend API types are regenerated from the updated Huma schema. - -## Palette Behavior - -### Agentsview - -`agentsview` preserves each chart's existing palette and theme-aware tokens. In -particular, Usage keeps its 12-color stable name hash with deterministic -collision resolution, Skill Trend keeps its existing six categorical series -tokens, and Trends keeps its existing 12-color term palette. `Other` remains the -muted gray token. - -This mode deliberately does not consolidate the chart-specific palettes. Usage -keeps the same preferred hash and linear-probing rules, but any resolved slot -may change when allocation moves from a panel-local subset to the full shared -Usage universe: an earlier collision can occupy a later identifier's preferred -slot. That tradeoff is required for cross-panel consistency. - -### Matplotlib - -`matplotlib` uses the qualitative palette policy and exact color values from -Matplotlib v3.10.5: - -- Up to 9 active series use gray-free `tab10`. -- 10 through 18 active series use gray-free `tab20`. -- 19 through 36 active series use `tab20b` followed by gray-free `tab20c`. -- More than 36 active series cycle through the 36-color resolved palette. - -The gray entries are omitted because agentsview reserves muted gray for `Other` -and de-emphasized data. - -Before allocation, active identifiers are deduplicated and sorted by their -stable technical identifier. The selected family is based on that active count, -and colors are assigned in palette order. Sorting makes the result independent -of API response order. Crossing the 9/10 or 18/19 thresholds intentionally -selects a different Matplotlib family and may recolor the active set. - -Usage owns one color map per grouping dimension (project, model, and agent). -Each map is computed from the full category universe in the Usage response, -before Cost Over Time applies its five-series cap. Cost Over Time and Cost -Attribution receive the same map for their selected dimension, so the same -identifier cannot change color merely because attribution renders more rows or -crosses a Matplotlib family threshold. The agentsview mode keeps its existing -12-color hash preference and collision rules, but resolves collisions against -that same shared universe for cross-panel consistency. - -Every visual representation consumes its owning surface's computed color map. -Paths, bars, legend dots, treemap tiles, rails, and list fills therefore cannot -drift. A missing identifier uses the general muted fallback. In Matplotlib mode, -the synthetic `__other__` identifier also uses that fallback. In agentsview -mode, each existing surface retains its current `Other` token, including Skill -Trend's `--chart-series-other`. - -### Theme Tradeoff - -The Matplotlib option deliberately uses the exact source hex values instead of -theme-specific replacements. Some light entries have lower contrast on light -surfaces, and some dark entries have lower contrast on dark surfaces. This is an -accepted tradeoff for an optional fidelity-oriented palette. Color remains -supplemental: every affected chart has a matching text legend, table row, or -tooltip, and existing keyboard/hover associations remain intact. Visual -verification must confirm that marks remain discernible in light, dark, and -high-contrast modes; it must not silently alter the source hex values. - -## Frontend Data Flow - -The existing settings store gains a typed `chartPalette` value whose initial -value is `agentsview`. Application startup loads the Settings API so charts on -any route receive the effective server value, not only after the Settings page -has been visited. Until that request completes, charts render with the -behavior-preserving default; they reactively update if the server returns -`matplotlib`. - -The Appearance section adds a shared `SegmentedControl` with two localized -choices, Agentsview and Matplotlib. Choosing an option sends a partial Settings -API update. The control reflects the returned server value, is disabled while -the update is in flight or when the server is read-only, and leaves the prior -selection in place with the existing settings error treatment if saving fails. - -A shared frontend palette module owns the mode type, Matplotlib constants, and -active-series allocation. Usage components and Skill Trend request colors from -that boundary, as do the Trends line chart and term table. Existing -agentsview-mode logic remains reachable through the same boundary so consumers -do not implement mode checks independently. - -All new labels and descriptions use Paraglide messages. The `en`, `zh-CN`, -`zh-TW`, `ko`, and `fr` catalogs receive identical keys. - -## Error Handling - -- Invalid values in `config.toml` fail configuration loading with an actionable - validation error. -- Invalid API values return HTTP 400 and do not modify either the file or the - in-memory configuration. -- Persistence failures use the existing internal settings error response and - leave the prior in-memory value active. -- Frontend load failures retain the `agentsview` default. -- Frontend save failures retain the last server-confirmed selection and surface - the existing Settings error state. - -## Testing - -Backend tests will cover the observable configuration and HTTP contracts: - -- omitted configuration resolves to `agentsview`; -- both accepted values load from TOML; -- an explicitly empty value is rejected while omission defaults correctly; -- an invalid value is rejected; -- Settings GET returns the effective value; -- Settings PUT persists and immediately returns a valid change; -- invalid and read-only updates do not persist a change. - -Frontend tests will protect behavior users can see: - -- Matplotlib allocation uses the complete exact ordered gray-free 9-, 18-, and - 36-color sequences; -- the allocator selects the 18-color family at 10 and the 36-color family at 19; -- each family remains unique through its advertised capacity and cycles only - beyond 36; -- no advertised family contains an achromatic gray entry; -- allocation is deterministic for reordered and duplicate identifiers; -- the reported colliding model names remain distinct in both modes; -- Usage paths, legends, attribution lists, and treemaps use the same identifier - colors even when the two panels render different series counts; -- Skill Trend consumes the selected mode's colors; -- Skill Trend retains `--chart-series-other` in agentsview mode; -- Trends chart lines, points, and table indicators share the selected mode's - colors; -- changing the Appearance control sends the server update and updates rendered - charts; -- a failed update retains the previously confirmed selection. - -Localization compilation and the normal frontend type/component checks must pass -after the catalogs and generated API types are updated. Relevant Go config and -server tests must pass before the implementation is committed. - -## Rollout and Compatibility - -No database migration is required. Existing configuration files omit the new key -and remain in agentsview palette mode. Selecting Matplotlib writes one top-level -TOML value. Downgrading to a version that does not know the field leaves the -value inert in the config file; upgrading again restores the selected mode. - -The shared Usage universe can reassign resolved colors in the default agentsview -mode because linear-probing outcomes depend on the complete sorted identifier -set. The preferred hash function, palette, and every non-Usage chart remain -unchanged. From 5eea9e14ea9ab150509147506e59b5c6483a21e5 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 2 Aug 2026 09:17:42 -0500 Subject: [PATCH 7/7] fix(branches): preserve API compatibility at scale The branch picker needs bounded name search, but changing the stable v1 metadata endpoint would break clients that depend on project-qualified tokens. Keep that contract intact and expose picker-oriented search through an additive route.\n\nLarge archives also need a bounded Activity branch panel. Preserve the report total by aggregating overflow into an Other row instead of rendering an unbounded list or silently dropping data. --- docs/session-api.md | 42 ++++++++++++---- frontend/src/lib/api/client.ts | 2 +- frontend/src/lib/api/generated/index.ts | 2 + .../api/generated/models/BranchesResponse.ts | 7 +++ .../lib/api/generated/models/DbBranchInfo.ts | 9 ++++ .../generated/models/ServiceSessionDetail.ts | 1 - .../api/generated/services/MetadataService.ts | 47 ++++++++++++++++-- .../lib/components/activity/Breakdowns.svelte | 31 +++++++++++- .../components/activity/Breakdowns.test.ts | 47 ++++++++++++++++++ .../lib/components/usage/UsagePage.test.ts | 2 +- internal/db/branch_filter_test.go | 14 +++--- internal/db/sessions.go | 49 +++++++++++++++++-- internal/db/store.go | 3 +- internal/duckdb/store.go | 26 ++++++++++ internal/duckdb/store_test.go | 11 ++++- internal/postgres/sessions.go | 40 ++++++++++++++- internal/postgres/store_test.go | 13 +++-- internal/server/huma_routes_metadata.go | 26 ++++++++-- internal/server/server_test.go | 31 +++++++++++- 19 files changed, 361 insertions(+), 42 deletions(-) create mode 100644 frontend/src/lib/api/generated/models/BranchesResponse.ts create mode 100644 frontend/src/lib/api/generated/models/DbBranchInfo.ts diff --git a/docs/session-api.md b/docs/session-api.md index 1c3ee4f87..4f6891a0e 100644 --- a/docs/session-api.md +++ b/docs/session-api.md @@ -117,12 +117,33 @@ for filter options: GET /api/v1/projects GET /api/v1/machines GET /api/v1/branches +GET /api/v1/branch-names GET /api/v1/agents ``` -`GET /api/v1/branches` returns a bounded list of distinct branch names for -filter pickers. Results are sorted by session count and then branch name. The -search is case-insensitive and matches branch-name substrings. +`GET /api/v1/branches` preserves the stable project-qualified metadata +contract. It returns distinct `(project, branch)` pairs, ordered by project and +branch, plus an opaque token for exact filtering: + +```json +{ + "branches": [ + { + "project": "myapp", + "branch": "main", + "token": "..." + } + ] +} +``` + +Pass the token back as `git_branch` when project identity must remain exact; +treat it as opaque and URL-encode it in manual HTTP calls. + +`GET /api/v1/branch-names` is the bounded name-search contract used by filter +pickers. It deduplicates same-named branches after applying project scope and +orders names by the most recent matching session activity, then branch name. +Search is case-insensitive and matches branch-name substrings. | Query parameter | Meaning | |-----------------|---------| @@ -130,23 +151,24 @@ search is case-insensitive and matches branch-name substrings. | `projects` | Optional repeated project filter applied before branch-name deduplication | | `scope` | `roots` by default; `all` also includes subagent and fork sessions | | `limit` | Maximum branch names, from 1 through 100; default 100 | +| `include_one_shot` | Include one-shot sessions; default false | +| `include_automated` | Include automated sessions; default false | ```json { "branches": [ { - "branch": "main", - "session_count": 42 + "branch": "main" } ], "has_more": false } ``` -`has_more` is true when additional matching branch names exist beyond the -requested limit. Branch-aware endpoints accept branch names directly in -`git_branch`; clients do not obtain opaque filter tokens from this metadata -endpoint. +`has_more` is true when additional matching names exist beyond the requested +limit. Branch-aware endpoints accept these names directly in `git_branch` for +name-based filtering, or the token from `/api/v1/branches` for an exact +project/branch identity. ## Commands @@ -236,7 +258,7 @@ therefore appear on both dates. | `--project` | `project` | string | | `--exclude-project` | `exclude_project` | string | | `--machine` | `machine` | string | -| `--branch` | `git_branch` | Branch name; the CLI requires `--project`. Direct HTTP callers may supply project scope separately | +| `--branch` | `git_branch` | Branch name; the CLI requires `--project` and sends an exact opaque token. Direct HTTP callers may use a name or token | | `--agent` | `agent` | string | | `--date` | `date` | `YYYY-MM-DD` | | `--date-from` | `date_from` | `YYYY-MM-DD` | diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 4b898fcd7..12f3bded0 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -39,7 +39,7 @@ export async function searchBranches( params: BranchSearchParams, ): Promise { configureGeneratedClient(); - const response = await MetadataService.getApiV1Branches({ + const response = await MetadataService.getApiV1BranchNames({ projects: params.projects, search: params.search || undefined, limit: params.limit ?? 100, diff --git a/frontend/src/lib/api/generated/index.ts b/frontend/src/lib/api/generated/index.ts index a0dd08fa7..7d47f57fb 100644 --- a/frontend/src/lib/api/generated/index.ts +++ b/frontend/src/lib/api/generated/index.ts @@ -21,6 +21,7 @@ export type { ApiErrorResponse } from './models/ApiErrorResponse'; export type { ApplyWorktreeMappingsRequest } from './models/ApplyWorktreeMappingsRequest'; export type { ApplyWorktreeMappingsResponse } from './models/ApplyWorktreeMappingsResponse'; export type { BatchDeleteInputBody } from './models/BatchDeleteInputBody'; +export type { BranchesResponse } from './models/BranchesResponse'; export type { BranchTotal } from './models/BranchTotal'; export type { BulkStarInputBody } from './models/BulkStarInputBody'; export type { CacheStats } from './models/CacheStats'; @@ -38,6 +39,7 @@ export type { DbAgentInfo } from './models/DbAgentInfo'; export type { DbAgentSummary } from './models/DbAgentSummary'; export type { DbAnalyticsSummary } from './models/DbAnalyticsSummary'; export type { DbBranchBreakdown } from './models/DbBranchBreakdown'; +export type { DbBranchInfo } from './models/DbBranchInfo'; export type { DbBranchOption } from './models/DbBranchOption'; export type { DbBranchResult } from './models/DbBranchResult'; export type { DbCacheHitRatioDistribution } from './models/DbCacheHitRatioDistribution'; diff --git a/frontend/src/lib/api/generated/models/BranchesResponse.ts b/frontend/src/lib/api/generated/models/BranchesResponse.ts new file mode 100644 index 000000000..3bfd5cf3a --- /dev/null +++ b/frontend/src/lib/api/generated/models/BranchesResponse.ts @@ -0,0 +1,7 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type BranchesResponse = { + branches: any[] | null; +}; diff --git a/frontend/src/lib/api/generated/models/DbBranchInfo.ts b/frontend/src/lib/api/generated/models/DbBranchInfo.ts new file mode 100644 index 000000000..23ca178fc --- /dev/null +++ b/frontend/src/lib/api/generated/models/DbBranchInfo.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DbBranchInfo = { + branch: string; + project: string; + token: string; +}; diff --git a/frontend/src/lib/api/generated/models/ServiceSessionDetail.ts b/frontend/src/lib/api/generated/models/ServiceSessionDetail.ts index b6243db50..89c0268c4 100644 --- a/frontend/src/lib/api/generated/models/ServiceSessionDetail.ts +++ b/frontend/src/lib/api/generated/models/ServiceSessionDetail.ts @@ -61,4 +61,3 @@ export type ServiceSessionDetail = { transcript_revision?: string; user_message_count: number; }; - diff --git a/frontend/src/lib/api/generated/services/MetadataService.ts b/frontend/src/lib/api/generated/services/MetadataService.ts index bb5f16e60..7a5cd3aef 100644 --- a/frontend/src/lib/api/generated/services/MetadataService.ts +++ b/frontend/src/lib/api/generated/services/MetadataService.ts @@ -3,6 +3,7 @@ /* tslint:disable */ /* eslint-disable */ import type { AgentsResponse } from '../models/AgentsResponse'; +import type { BranchesResponse } from '../models/BranchesResponse'; import type { DbBranchResult } from '../models/DbBranchResult'; import type { DbSessionStats } from '../models/DbSessionStats'; import type { DbStats } from '../models/DbStats'; @@ -55,11 +56,11 @@ export class MetadataService { }); } /** - * List branches + * Search branch names * @returns DbBranchResult OK * @throws ApiError */ - public static getApiV1Branches({ + public static getApiV1BranchNames({ includeOneShot, includeAutomated, scope, @@ -94,7 +95,7 @@ export class MetadataService { }): CancelablePromise { return __request(OpenAPI, { method: 'GET', - url: '/api/v1/branches', + url: '/api/v1/branch-names', query: { 'include_one_shot': includeOneShot, 'include_automated': includeAutomated, @@ -118,6 +119,46 @@ export class MetadataService { }, }); } + /** + * List branches + * @returns BranchesResponse OK + * @throws ApiError + */ + public static getApiV1Branches({ + includeOneShot, + includeAutomated, + }: { + /** + * Include one-shot sessions + */ + includeOneShot?: boolean, + /** + * Include automated sessions + */ + includeAutomated?: boolean, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/branches', + query: { + 'include_one_shot': includeOneShot, + 'include_automated': includeAutomated, + }, + errors: { + 400: `Bad Request`, + 401: `Unauthorized`, + 403: `Forbidden`, + 404: `Not Found`, + 409: `Conflict`, + 422: `Unprocessable Entity`, + 500: `Internal Server Error`, + 501: `Not Implemented`, + 502: `Bad Gateway`, + 503: `Service Unavailable`, + 504: `Gateway Timeout`, + }, + }); + } /** * List machines * @returns MachinesResponse OK diff --git a/frontend/src/lib/components/activity/Breakdowns.svelte b/frontend/src/lib/components/activity/Breakdowns.svelte index e23564cc7..7a068b47f 100644 --- a/frontend/src/lib/components/activity/Breakdowns.svelte +++ b/frontend/src/lib/components/activity/Breakdowns.svelte @@ -45,6 +45,8 @@ const byProject = $derived(rankedRows(report.by_project)); const byModel = $derived(rankedRows(report.by_model)); const byAgent = $derived(rankedRows(report.by_agent)); + const BRANCH_ROW_LIMIT = 40; + const OTHER_BRANCHES_KEY = "__activity_other_branches__"; // Tokenizing branch rows depends only on the report, not the metric, so // keep it out of the rankedRows derived: flipping minutes/cost re-ranks // without re-allocating every row of an uncapped (project, branch) list. @@ -55,7 +57,34 @@ displayLabel: branchLabel(b.project, b.branch, m.shared_no_branch()), })), ); - const byBranch = $derived(rankedRows(branchRows)); + const byBranch = $derived.by(() => { + const ranked = rankedRows(branchRows); + if (ranked.length <= BRANCH_ROW_LIMIT) return ranked; + + const visible = ranked.slice(0, BRANCH_ROW_LIMIT - 1); + const omitted = ranked.slice(BRANCH_ROW_LIMIT - 1); + const sum = (value: (row: BreakdownRow) => number) => + omitted.reduce((total, row) => total + value(row), 0); + return [ + ...visible, + { + key: OTHER_BRANCHES_KEY, + displayLabel: m.shared_other(), + agent_minutes: sum((row) => row.agent_minutes), + cost: moneyFromMicrodollars(sum((row) => row.cost.microdollars)), + interactive_agent_minutes: sum( + (row) => row.interactive_agent_minutes, + ), + automated_agent_minutes: sum((row) => row.automated_agent_minutes), + interactive_cost: moneyFromMicrodollars( + sum((row) => row.interactive_cost.microdollars), + ), + automated_cost: moneyFromMicrodollars( + sum((row) => row.automated_cost.microdollars), + ), + }, + ]; + }); interface Panel { title: string; diff --git a/frontend/src/lib/components/activity/Breakdowns.test.ts b/frontend/src/lib/components/activity/Breakdowns.test.ts index da2b2a585..523435db8 100644 --- a/frontend/src/lib/components/activity/Breakdowns.test.ts +++ b/frontend/src/lib/components/activity/Breakdowns.test.ts @@ -394,4 +394,51 @@ describe("Breakdowns", () => { unmount(component); target.remove(); }); + + it("caps large branch panels with an aggregated Other row", async () => { + const report = makeReport(); + report.by_branch = Array.from({ length: 41 }, (_, index) => { + const value = index < 39 ? 2 : 1; + return { + project_key: `pl1:sha256:${index}`, + project: `project-${index}`, + branch: `branch-${index}`, + agent_minutes: value, + cost: testMoney(value), + interactive_agent_minutes: value, + automated_agent_minutes: 0, + interactive_cost: testMoney(value), + automated_cost: testMoney(0), + }; + }); + const target = document.createElement("div"); + document.body.appendChild(target); + const component = mount(Breakdowns, { target, props: { report } }); + await tick(); + + const branchPanel = Array.from( + target.querySelectorAll(".breakdown-panel"), + ).find( + (panel) => panel.querySelector(".panel-title")?.textContent === "Branch", + ); + expect(branchPanel?.querySelectorAll(".bar-row")).toHaveLength(40); + const otherRow = Array.from( + branchPanel?.querySelectorAll(".bar-row") ?? [], + ).find((row) => row.querySelector(".bar-label")?.textContent === "Other"); + expect(otherRow?.querySelector(".bar-value")?.textContent?.trim()).toBe("2"); + expect(branchPanel?.textContent).not.toContain("project-39/branch-39"); + expect(branchPanel?.textContent).not.toContain("project-40/branch-40"); + + const costButton = Array.from( + target.querySelectorAll(".metric-btn"), + ).find((button) => button.textContent?.trim() === "Cost"); + costButton?.click(); + await tick(); + expect(otherRow?.querySelector(".bar-value")?.textContent?.trim()).toBe( + "$2.00", + ); + + await unmount(component); + target.remove(); + }); }); diff --git a/frontend/src/lib/components/usage/UsagePage.test.ts b/frontend/src/lib/components/usage/UsagePage.test.ts index eee20d1e6..f61f59f2d 100644 --- a/frontend/src/lib/components/usage/UsagePage.test.ts +++ b/frontend/src/lib/components/usage/UsagePage.test.ts @@ -503,7 +503,7 @@ describe("UsagePage refresh behavior", () => { ); vi.spyOn(usage, "fetchAll").mockResolvedValue(); vi.spyOn(sessions, "loadAgents").mockResolvedValue(); - const searchBranches = vi.spyOn(MetadataService, "getApiV1Branches") + const searchBranches = vi.spyOn(MetadataService, "getApiV1BranchNames") .mockResolvedValue({ branches: [], has_more: false }); router.route = "usage"; router.params = { diff --git a/internal/db/branch_filter_test.go b/internal/db/branch_filter_test.go index 7c2bafefa..6c87b38f4 100644 --- a/internal/db/branch_filter_test.go +++ b/internal/db/branch_filter_test.go @@ -270,7 +270,7 @@ func TestBranchFilterToken(t *testing.T) { assert.Equal(t, EncodeBranchFilterToken("proj", "main"), tok) } -func TestGetBranches(t *testing.T) { +func TestSearchBranchNames(t *testing.T) { d := testDB(t) insertSession(t, d, "s1", "alpha", func(s *Session) { @@ -317,7 +317,7 @@ func TestGetBranches(t *testing.T) { s.EndedAt = new("2026-06-13T10:00:00Z") }) - all, err := d.GetBranches(context.Background(), BranchQuery{ + all, err := d.SearchBranchNames(context.Background(), BranchQuery{ Scope: BranchScopeRoots, Limit: 100, }) @@ -329,7 +329,7 @@ func TestGetBranches(t *testing.T) { {Branch: "solo"}, }}, all, "branch names deduplicated by latest activity, empty branch included") - withForks, err := d.GetBranches(context.Background(), BranchQuery{ + withForks, err := d.SearchBranchNames(context.Background(), BranchQuery{ Scope: BranchScopeAll, Limit: 100, }) @@ -337,7 +337,7 @@ func TestGetBranches(t *testing.T) { assert.Contains(t, withForks.Branches, BranchOption{Branch: "fork-only"}, "fork-only branch included when scope is all") - filtered, err := d.GetBranches(context.Background(), BranchQuery{ + filtered, err := d.SearchBranchNames(context.Background(), BranchQuery{ Scope: BranchScopeRoots, ExcludeOneShot: true, Limit: 100, @@ -347,7 +347,7 @@ func TestGetBranches(t *testing.T) { "one-shot branch excluded when excludeOneShot is set") } -func TestGetBranchesProjectsSearchAndLimit(t *testing.T) { +func TestSearchBranchNamesProjectsSearchAndLimit(t *testing.T) { d := testDB(t) seed := func(id, project, branch, endedAt string) { insertSession(t, d, id, project, func(s *Session) { @@ -366,7 +366,7 @@ func TestGetBranchesProjectsSearchAndLimit(t *testing.T) { seed("project-name-match", "feature-project", "main", "2026-07-01T10:00:00Z") seed("alpha-empty", "alpha", "", "2026-06-08T10:00:00Z") - got, err := d.GetBranches(context.Background(), BranchQuery{ + got, err := d.SearchBranchNames(context.Background(), BranchQuery{ Projects: []string{"alpha", "beta", "feature-project"}, Search: "FEATURE", Limit: 2, @@ -377,7 +377,7 @@ func TestGetBranchesProjectsSearchAndLimit(t *testing.T) { HasMore: true, }, got, "project filter applies before dedupe and recency ordering") - empty, err := d.GetBranches(context.Background(), BranchQuery{ + empty, err := d.SearchBranchNames(context.Background(), BranchQuery{ Projects: []string{"alpha"}, Limit: 100, }) diff --git a/internal/db/sessions.go b/internal/db/sessions.go index d57a6fa6e..6e696da18 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -3429,8 +3429,49 @@ type BranchInfo struct { Token string `json:"token"` } +// GetBranches returns distinct (project, git_branch) pairs, including the empty +// branch used for sessions with no recorded branch. This is the stable metadata +// contract consumed by clients that need opaque project-qualified tokens. +func (db *DB) GetBranches( + ctx context.Context, + excludeOneShot, excludeAutomated bool, +) ([]BranchInfo, error) { + q := `SELECT DISTINCT project, git_branch + FROM sessions + WHERE message_count > 0 + AND relationship_type NOT IN ('subagent', 'fork') + AND deleted_at IS NULL` + if excludeOneShot { + if !excludeAutomated { + q += " AND (user_message_count > 1 OR is_automated = 1)" + } else { + q += " AND user_message_count > 1" + } + } + if excludeAutomated { + q += " AND is_automated = 0" + } + q += " ORDER BY project, git_branch" + rows, err := db.getReader().QueryContext(ctx, q) + if err != nil { + return nil, fmt.Errorf("querying branches: %w", err) + } + defer rows.Close() + + branches := []BranchInfo{} + for rows.Next() { + var branch BranchInfo + if err := rows.Scan(&branch.Project, &branch.Branch); err != nil { + return nil, fmt.Errorf("scanning branch: %w", err) + } + branch.Token = EncodeBranchFilterToken(branch.Project, branch.Branch) + branches = append(branches, branch) + } + return branches, rows.Err() +} + // BranchScope selects which session relationships contribute (project, -// branch) pairs to GetBranches. +// branch) pairs to SearchBranchNames. type BranchScope int const ( @@ -3501,9 +3542,9 @@ func ScanBranchResult(rows *sql.Rows, q BranchQuery) (BranchResult, error) { return BranchResult{Branches: branches, HasMore: hasMore}, nil } -// GetBranches returns distinct git_branch values, including the empty branch, -// ordered by the latest matching session activity and then branch name. -func (db *DB) GetBranches( +// SearchBranchNames returns distinct git_branch values, including the empty +// branch, ordered by the latest matching session activity and then branch name. +func (db *DB) SearchBranchNames( ctx context.Context, query BranchQuery, ) (BranchResult, error) { query = NormalizeBranchQuery(query) diff --git a/internal/db/store.go b/internal/db/store.go index 2b5570fe0..f7fa2465d 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -64,7 +64,8 @@ type Store interface { GetActiveProjectLabels(ctx context.Context) ([]string, error) GetAgents(ctx context.Context, excludeOneShot, excludeAutomated bool) ([]AgentInfo, error) GetMachines(ctx context.Context, excludeOneShot, excludeAutomated bool) ([]string, error) - GetBranches(ctx context.Context, q BranchQuery) (BranchResult, error) + GetBranches(ctx context.Context, excludeOneShot, excludeAutomated bool) ([]BranchInfo, error) + SearchBranchNames(ctx context.Context, q BranchQuery) (BranchResult, error) ListProjectIdentityObservations(ctx context.Context, labels []string) ([]export.ProjectIdentityObservation, error) BuildProjectIdentityMap(ctx context.Context, labels []string) (map[string]export.ProjectMapEntry, error) diff --git a/internal/duckdb/store.go b/internal/duckdb/store.go index 71f9911c6..cec9ad8b6 100644 --- a/internal/duckdb/store.go +++ b/internal/duckdb/store.go @@ -833,6 +833,32 @@ func (s *Store) GetMachines(ctx context.Context, excludeOneShot, excludeAutomate } func (s *Store) GetBranches( + ctx context.Context, + excludeOneShot, excludeAutomated bool, +) ([]db.BranchInfo, error) { + rows, err := s.queryContext(ctx, + `SELECT DISTINCT project, git_branch FROM sessions WHERE `+ + rootSessionWhere(excludeOneShot, excludeAutomated)+ + ` ORDER BY project, git_branch`, + ) + if err != nil { + return nil, fmt.Errorf("querying duckdb branches: %w", err) + } + defer rows.Close() + + branches := []db.BranchInfo{} + for rows.Next() { + var branch db.BranchInfo + if err := rows.Scan(&branch.Project, &branch.Branch); err != nil { + return nil, fmt.Errorf("scanning duckdb branch: %w", err) + } + branch.Token = db.EncodeBranchFilterToken(branch.Project, branch.Branch) + branches = append(branches, branch) + } + return branches, rows.Err() +} + +func (s *Store) SearchBranchNames( ctx context.Context, query db.BranchQuery, ) (db.BranchResult, error) { query = db.NormalizeBranchQuery(query) diff --git a/internal/duckdb/store_test.go b/internal/duckdb/store_test.go index a5ced1b75..eba4a7b61 100644 --- a/internal/duckdb/store_test.go +++ b/internal/duckdb/store_test.go @@ -3989,8 +3989,15 @@ func TestDuckDBBranchDimension(t *testing.T) { _, err = syncer.pushEverything(ctx, nil) require.NoError(t, err) store := NewStoreFromDB(syncer.DB()) + qualified, err := store.GetBranches(ctx, false, false) + require.NoError(t, err, "GetBranches") + assert.Contains(t, qualified, db.BranchInfo{ + Project: "alpha", + Branch: "feature-x", + Token: "alpha\x1ffeature-x", + }) - branches, err := store.GetBranches(ctx, db.BranchQuery{ + branches, err := store.SearchBranchNames(ctx, db.BranchQuery{ Projects: []string{"alpha", "beta"}, Search: "MAIN", Limit: 1, @@ -4001,7 +4008,7 @@ func TestDuckDBBranchDimension(t *testing.T) { HasMore: false, }, branches, "same-named branches deduplicate after filtering selected projects") - withForks, err := store.GetBranches(ctx, db.BranchQuery{ + withForks, err := store.SearchBranchNames(ctx, db.BranchQuery{ Scope: db.BranchScopeAll, Projects: []string{"delta"}, Limit: 100, diff --git a/internal/postgres/sessions.go b/internal/postgres/sessions.go index 4035245df..59776950f 100644 --- a/internal/postgres/sessions.go +++ b/internal/postgres/sessions.go @@ -1175,8 +1175,46 @@ func (s *Store) GetMachines( return machines, rows.Err() } -// GetBranches mirrors db.DB.GetBranches for PostgreSQL. +// GetBranches mirrors the stable qualified branch metadata contract. func (s *Store) GetBranches( + ctx context.Context, + excludeOneShot, excludeAutomated bool, +) ([]db.BranchInfo, error) { + q := `SELECT DISTINCT project, git_branch FROM sessions + WHERE message_count > 0 + AND relationship_type NOT IN ('subagent', 'fork') + AND deleted_at IS NULL` + if excludeOneShot { + if !excludeAutomated { + q += " AND (user_message_count > 1 OR is_automated = TRUE)" + } else { + q += " AND user_message_count > 1" + } + } + if excludeAutomated { + q += " AND is_automated = FALSE" + } + q += " ORDER BY project, git_branch" + rows, err := s.pg.QueryContext(ctx, q) + if err != nil { + return nil, fmt.Errorf("querying branches: %w", err) + } + defer rows.Close() + + branches := []db.BranchInfo{} + for rows.Next() { + var branch db.BranchInfo + if err := rows.Scan(&branch.Project, &branch.Branch); err != nil { + return nil, fmt.Errorf("scanning branch: %w", err) + } + branch.Token = db.EncodeBranchFilterToken(branch.Project, branch.Branch) + branches = append(branches, branch) + } + return branches, rows.Err() +} + +// SearchBranchNames mirrors db.DB.SearchBranchNames for PostgreSQL. +func (s *Store) SearchBranchNames( ctx context.Context, query db.BranchQuery, ) (db.BranchResult, error) { query = db.NormalizeBranchQuery(query) diff --git a/internal/postgres/store_test.go b/internal/postgres/store_test.go index 2e97002b9..d0b0dae04 100644 --- a/internal/postgres/store_test.go +++ b/internal/postgres/store_test.go @@ -1600,7 +1600,7 @@ func TestStoreWriteSurfaceSplitByCapability(t *testing.T) { assert.ErrorIs(t, err, db.ErrReadOnly) } -func TestStoreGetBranchesPickerQuery(t *testing.T) { +func TestStoreSearchBranchNamesPickerQuery(t *testing.T) { pgURL := testPGURL(t) ensureStoreSchema(t, pgURL) @@ -1638,13 +1638,20 @@ func TestStoreGetBranchesPickerQuery(t *testing.T) { '2026-03-20T10:00:00Z'::timestamptz, 2, 2) `) require.NoError(t, err, "inserting branch sessions") + qualified, err := store.GetBranches(context.Background(), false, false) + require.NoError(t, err, "GetBranches") + assert.Contains(t, qualified, db.BranchInfo{ + Project: "alpha", + Branch: "feat/x", + Token: "alpha\x1ffeat/x", + }) - branches, err := store.GetBranches(context.Background(), db.BranchQuery{ + branches, err := store.SearchBranchNames(context.Background(), db.BranchQuery{ Projects: []string{"alpha", "beta"}, Search: "FEAT", Limit: 1, }) - require.NoError(t, err, "GetBranches") + require.NoError(t, err, "SearchBranchNames") assert.Equal(t, db.BranchResult{ Branches: []db.BranchOption{{Branch: "feat/x"}}, HasMore: true, diff --git a/internal/server/huma_routes_metadata.go b/internal/server/huma_routes_metadata.go index 0568da2f4..b88086ad2 100644 --- a/internal/server/huma_routes_metadata.go +++ b/internal/server/huma_routes_metadata.go @@ -16,6 +16,7 @@ func (s *Server) registerMetadataRoutes() { get(s, group, "/projects", "List projects", s.humaListProjects) get(s, group, "/machines", "List machines", s.humaListMachines) get(s, group, "/branches", "List branches", s.humaListBranches) + get(s, group, "/branch-names", "Search branch names", s.humaSearchBranchNames) get(s, group, "/agents", "List agents", s.humaListAgents) get(s, group, "/stats", "Get stats", s.humaGetStats) get(s, group, "/session-stats", "Get session stats", s.humaGetSessionStats) @@ -47,7 +48,11 @@ type machinesResponse struct { Machines []string `json:"machines"` } -type branchesResponse = db.BranchResult +type branchesResponse struct { + Branches []db.BranchInfo `json:"branches"` +} + +type branchNamesResponse = db.BranchResult type agentsResponse struct { Agents []db.AgentInfo `json:"agents"` @@ -126,7 +131,7 @@ func (s *Server) humaListMachines( type branchScopeParam string -type branchesInput struct { +type branchNamesInput struct { BoolIncludeInput Scope branchScopeParam `query:"scope" enum:"roots,all" doc:"Session scope: roots (default) counts only root sessions; all also counts subagent and fork sessions, matching the activity and usage rollups"` Projects []string `query:"projects,explode" doc:"Restrict to these projects before deduplicating branch names"` @@ -136,13 +141,24 @@ type branchesInput struct { func (s *Server) humaListBranches( ctx context.Context, - in *branchesInput, + in *statsInput, ) (*jsonOutput[branchesResponse], error) { + branches, err := s.db.GetBranches(ctx, !in.IncludeOneShot, !in.IncludeAutomated) + if err != nil { + return nil, serverError(err) + } + return &jsonOutput[branchesResponse]{Body: branchesResponse{Branches: branches}}, nil +} + +func (s *Server) humaSearchBranchNames( + ctx context.Context, + in *branchNamesInput, +) (*jsonOutput[branchNamesResponse], error) { scope := db.BranchScopeRoots if in.Scope == "all" { scope = db.BranchScopeAll } - branches, err := s.db.GetBranches(ctx, db.BranchQuery{ + branches, err := s.db.SearchBranchNames(ctx, db.BranchQuery{ Projects: in.Projects, Search: in.Search, Limit: in.Limit, @@ -153,7 +169,7 @@ func (s *Server) humaListBranches( if err != nil { return nil, serverError(err) } - return &jsonOutput[branchesResponse]{Body: branches}, nil + return &jsonOutput[branchNamesResponse]{Body: branches}, nil } func (s *Server) humaListAgents( diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8e18145ef..ec49b7454 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1420,6 +1420,10 @@ type branchPickerResponse struct { HasMore bool `json:"has_more"` } +type branchListResponse struct { + Branches []db.BranchInfo `json:"branches"` +} + type syncStatusResponse struct { LastSync string `json:"last_sync"` Progress *sync.Progress `json:"progress"` @@ -2451,7 +2455,30 @@ func TestSessionStats_DefaultVisibilityMatchesListDefaults(t *testing.T) { assert.Equal(t, 21, resp.Totals.MessagesTotal, "include_all messages_total") } -func TestListBranchesPickerFiltersSearchesDeduplicatesAndPaginates(t *testing.T) { +func TestListBranchesPreservesQualifiedTokenContract(t *testing.T) { + te := setup(t) + seed := func(id, project, branch string) { + te.seedSession(t, id, project, 5, func(s *db.Session) { + s.GitBranch = branch + s.UserMessageCount = 5 + }) + } + + seed("alpha-main", "alpha", "main") + seed("beta-main", "beta", "main") + seed("alpha-empty", "alpha", "") + + w := te.get(t, "/api/v1/branches") + assertStatus(t, w, http.StatusOK) + resp := decode[branchListResponse](t, w) + assert.Equal(t, []db.BranchInfo{ + {Project: "alpha", Branch: "", Token: "alpha\x1f"}, + {Project: "alpha", Branch: "main", Token: "alpha\x1fmain"}, + {Project: "beta", Branch: "main", Token: "beta\x1fmain"}, + }, resp.Branches) +} + +func TestSearchBranchNamesFiltersDeduplicatesAndPaginates(t *testing.T) { te := setup(t) seed := func(id, project, branch, endedAt string) { te.seedSession(t, id, project, 5, func(s *db.Session) { @@ -2468,7 +2495,7 @@ func TestListBranchesPickerFiltersSearchesDeduplicatesAndPaginates(t *testing.T) seed("unselected-shared", "gamma", "feature-shared", "2026-06-20T10:00:00Z") seed("search-miss", "beta", "bugfix", "2026-06-30T10:00:00Z") - w := te.get(t, "/api/v1/branches?projects=alpha&projects=beta&search=FEATURE&limit=2") + w := te.get(t, "/api/v1/branch-names?projects=alpha&projects=beta&search=FEATURE&limit=2") assertStatus(t, w, http.StatusOK) resp := decode[branchPickerResponse](t, w) require.Len(t, resp.Branches, 2)