diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8bc5bc2ed..09f8dc754 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -460,6 +460,7 @@ "shared_no_sessions_in_range": "No sessions in range", "shared_none": "None", "shared_other": "Other", + "shared_no_branch": "(no branch)", "shared_unknown": "unknown", "analytics_refresh": "Refresh analytics", "analytics_export_csv": "Export CSV", @@ -683,6 +684,7 @@ "analytics_top_skills_empty": "No skill usage data", "usage_model": "Model", "usage_models": "Models", + "usage_branch": "Branch", "usage_refresh": "Refresh usage data", "usage_summary_total_cost": "Total Cost", "usage_summary_copilot_ai_credits": "Copilot AI Credits", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index dff7e35b1..bfe171a72 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -449,6 +449,7 @@ "shared_no_sessions_in_range": "此范围内无会话", "shared_none": "无", "shared_other": "其他", + "shared_no_branch": "(无分支)", "shared_unknown": "未知", "analytics_refresh": "刷新分析", "analytics_export_csv": "导出 CSV", @@ -663,6 +664,7 @@ "analytics_top_skills_empty": "无 skill 使用数据", "usage_model": "模型", "usage_models": "模型", + "usage_branch": "分支", "usage_refresh": "刷新用量数据", "usage_summary_total_cost": "总成本", "usage_summary_copilot_ai_credits": "Copilot AI Credits", diff --git a/frontend/src/lib/api/generated/index.ts b/frontend/src/lib/api/generated/index.ts index d3bc43c74..d0073f491 100644 --- a/frontend/src/lib/api/generated/index.ts +++ b/frontend/src/lib/api/generated/index.ts @@ -18,6 +18,8 @@ export type { AgentsResponse } from './models/AgentsResponse'; export type { AgentTotal } from './models/AgentTotal'; export type { ApiErrorResponse } from './models/ApiErrorResponse'; export type { ApplyWorktreeMappingsResponse } from './models/ApplyWorktreeMappingsResponse'; +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'; @@ -31,6 +33,8 @@ 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 { DbBranchBreakdown } from './models/DbBranchBreakdown'; +export type { DbBranchInfo } from './models/DbBranchInfo'; export type { DbCallTiming } from './models/DbCallTiming'; export type { DbCategoryTotal } from './models/DbCategoryTotal'; export type { DbContentMatch } from './models/DbContentMatch'; 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..0028e5bb6 --- /dev/null +++ b/frontend/src/lib/api/generated/models/BranchTotal.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type BranchTotal = { + branch: string; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + inputTokens: number; + outputTokens: number; + project: string; +}; + 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..b3eda7a99 --- /dev/null +++ b/frontend/src/lib/api/generated/models/BranchesResponse.ts @@ -0,0 +1,8 @@ +/* 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/DbBranchBreakdown.ts b/frontend/src/lib/api/generated/models/DbBranchBreakdown.ts new file mode 100644 index 000000000..adc814a89 --- /dev/null +++ b/frontend/src/lib/api/generated/models/DbBranchBreakdown.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DbBranchBreakdown = { + branch: string; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + inputTokens: number; + outputTokens: number; + project: string; +}; + 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..0eb5262cb --- /dev/null +++ b/frontend/src/lib/api/generated/models/DbBranchInfo.ts @@ -0,0 +1,10 @@ +/* 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/DbDailyUsageEntry.ts b/frontend/src/lib/api/generated/models/DbDailyUsageEntry.ts index 72bbf937e..5fa61556d 100644 --- a/frontend/src/lib/api/generated/models/DbDailyUsageEntry.ts +++ b/frontend/src/lib/api/generated/models/DbDailyUsageEntry.ts @@ -4,6 +4,7 @@ /* eslint-disable */ 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 06467ecca..173df62f3 100644 --- a/frontend/src/lib/api/generated/models/UsageSummaryResponse.ts +++ b/frontend/src/lib/api/generated/models/UsageSummaryResponse.ts @@ -8,6 +8,7 @@ import type { DbUsageSessionCounts } from './DbUsageSessionCounts'; import type { DbUsageTotals } from './DbUsageTotals'; 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 9ffcbc038..bf4c2d2b9 100644 --- a/frontend/src/lib/api/generated/services/ActivityService.ts +++ b/frontend/src/lib/api/generated/services/ActivityService.ts @@ -20,6 +20,7 @@ export class ActivityService { timezone, bucket, project, + gitBranch, agent, machine, automation = 'all', @@ -52,6 +53,10 @@ export class ActivityService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -76,6 +81,7 @@ export class ActivityService { 'timezone': timezone, 'bucket': bucket, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'machine': machine, 'automation': automation, diff --git a/frontend/src/lib/api/generated/services/AnalyticsService.ts b/frontend/src/lib/api/generated/services/AnalyticsService.ts index 90076aeec..b0416c8d8 100644 --- a/frontend/src/lib/api/generated/services/AnalyticsService.ts +++ b/frontend/src/lib/api/generated/services/AnalyticsService.ts @@ -29,6 +29,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -61,6 +62,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -115,6 +120,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -153,6 +159,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -185,6 +192,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -239,6 +250,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -277,6 +289,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -308,6 +321,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -358,6 +375,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -395,6 +413,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -426,6 +445,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -476,6 +499,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -513,6 +537,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -544,6 +569,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -594,6 +623,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -632,6 +662,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -668,6 +699,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -722,6 +757,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -761,6 +797,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -792,6 +829,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -842,6 +883,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -879,6 +921,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -910,6 +953,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -960,6 +1007,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -997,6 +1045,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -1028,6 +1077,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -1078,6 +1131,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -1115,6 +1169,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -1146,6 +1201,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -1196,6 +1255,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -1233,6 +1293,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -1265,6 +1326,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -1319,6 +1384,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -1357,6 +1423,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -1388,6 +1455,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -1438,6 +1509,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, diff --git a/frontend/src/lib/api/generated/services/MetadataService.ts b/frontend/src/lib/api/generated/services/MetadataService.ts index 6f215735d..71c9e4976 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 { DbStats } from '../models/DbStats'; import type { MachinesResponse } from '../models/MachinesResponse'; import type { ProjectsResponse } from '../models/ProjectsResponse'; @@ -52,6 +53,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/api/generated/services/SearchService.ts b/frontend/src/lib/api/generated/services/SearchService.ts index 7d27e4de4..90b04932c 100644 --- a/frontend/src/lib/api/generated/services/SearchService.ts +++ b/frontend/src/lib/api/generated/services/SearchService.ts @@ -80,6 +80,7 @@ export class SearchService { project, excludeProject, machine, + gitBranch, agent, date, dateFrom, @@ -123,6 +124,10 @@ export class SearchService { * Filter by machine */ machine?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -176,6 +181,7 @@ export class SearchService { 'project': project, 'exclude_project': excludeProject, 'machine': machine, + 'git_branch': gitBranch, 'agent': agent, 'date': date, 'date_from': dateFrom, diff --git a/frontend/src/lib/api/generated/services/SessionsService.ts b/frontend/src/lib/api/generated/services/SessionsService.ts index e865eda86..e0031d095 100644 --- a/frontend/src/lib/api/generated/services/SessionsService.ts +++ b/frontend/src/lib/api/generated/services/SessionsService.ts @@ -59,6 +59,7 @@ export class SessionsService { project, excludeProject, machine, + gitBranch, agent, date, dateFrom, @@ -93,6 +94,10 @@ export class SessionsService { * Filter by machine */ machine?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -185,6 +190,7 @@ export class SessionsService { 'project': project, 'exclude_project': excludeProject, 'machine': machine, + 'git_branch': gitBranch, 'agent': agent, 'date': date, 'date_from': dateFrom, @@ -231,6 +237,7 @@ export class SessionsService { project, excludeProject, machine, + gitBranch, agent, date, dateFrom, @@ -265,6 +272,10 @@ export class SessionsService { * Filter by machine */ machine?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -357,6 +368,7 @@ export class SessionsService { 'project': project, 'exclude_project': excludeProject, 'machine': machine, + 'git_branch': gitBranch, 'agent': agent, 'date': date, 'date_from': dateFrom, diff --git a/frontend/src/lib/api/generated/services/TrendsService.ts b/frontend/src/lib/api/generated/services/TrendsService.ts index 47b0cea01..9fcbd062e 100644 --- a/frontend/src/lib/api/generated/services/TrendsService.ts +++ b/frontend/src/lib/api/generated/services/TrendsService.ts @@ -18,6 +18,7 @@ export class TrendsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -51,6 +52,10 @@ export class TrendsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -109,6 +114,7 @@ export class TrendsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, diff --git a/frontend/src/lib/api/generated/services/UsageService.ts b/frontend/src/lib/api/generated/services/UsageService.ts index 3ba2fec33..ef30894b1 100644 --- a/frontend/src/lib/api/generated/services/UsageService.ts +++ b/frontend/src/lib/api/generated/services/UsageService.ts @@ -21,6 +21,7 @@ export class UsageService { agent, project, machine, + gitBranch, excludeProject, excludeAgent, excludeModel, @@ -62,6 +63,10 @@ export class UsageService { * Filter by machine */ machine?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Exclude a project */ @@ -121,6 +126,7 @@ export class UsageService { 'agent': agent, 'project': project, 'machine': machine, + 'git_branch': gitBranch, 'exclude_project': excludeProject, 'exclude_agent': excludeAgent, 'exclude_model': excludeModel, @@ -162,6 +168,7 @@ export class UsageService { agent, project, machine, + gitBranch, excludeProject, excludeAgent, excludeModel, @@ -199,6 +206,10 @@ export class UsageService { * Filter by machine */ machine?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Exclude a project */ @@ -258,6 +269,7 @@ export class UsageService { 'agent': agent, 'project': project, 'machine': machine, + 'git_branch': gitBranch, 'exclude_project': excludeProject, 'exclude_agent': excludeAgent, 'exclude_model': excludeModel, @@ -298,6 +310,7 @@ export class UsageService { agent, project, machine, + gitBranch, excludeProject, excludeAgent, excludeModel, @@ -336,6 +349,10 @@ export class UsageService { * Filter by machine */ machine?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Exclude a project */ @@ -399,6 +416,7 @@ export class UsageService { 'agent': agent, 'project': project, 'machine': machine, + 'git_branch': gitBranch, 'exclude_project': excludeProject, 'exclude_agent': excludeAgent, 'exclude_model': excludeModel, diff --git a/frontend/src/lib/api/types/core.ts b/frontend/src/lib/api/types/core.ts index d0b0d3bfe..5014f1a1b 100644 --- a/frontend/src/lib/api/types/core.ts +++ b/frontend/src/lib/api/types/core.ts @@ -197,6 +197,17 @@ 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 0e7416cc2..a925b0299 100644 --- a/frontend/src/lib/api/types/usage.ts +++ b/frontend/src/lib/api/types/usage.ts @@ -37,6 +37,16 @@ export interface AgentBreakdown { cost: number; } +export interface BranchBreakdown { + project: string; + branch: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; +} + export interface DailyUsageEntry { date: string; inputTokens: number; @@ -48,6 +58,7 @@ export interface DailyUsageEntry { modelBreakdowns?: ModelBreakdown[]; projectBreakdowns?: ProjectBreakdown[]; agentBreakdowns?: AgentBreakdown[]; + branchBreakdowns?: BranchBreakdown[]; } export interface ProjectTotal { @@ -77,6 +88,16 @@ export interface AgentTotal { cost: number; } +export interface BranchTotal { + project: string; + branch: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; +} + export interface CacheStats { cacheReadTokens: number; cacheCreationTokens: number; @@ -107,6 +128,7 @@ export interface UsageSummaryResponse { projectTotals: ProjectTotal[]; modelTotals: ModelTotal[]; agentTotals: AgentTotal[]; + branchTotals: BranchTotal[]; sessionCounts: UsageSessionCounts; cacheStats: CacheStats; comparison?: UsageComparison; @@ -129,6 +151,7 @@ export interface UsageParams { to?: string; project?: string; machine?: string; + git_branch?: string; agent?: string; model?: string; exclude_project?: string; diff --git a/frontend/src/lib/branchFilters.test.ts b/frontend/src/lib/branchFilters.test.ts new file mode 100644 index 000000000..89443becb --- /dev/null +++ b/frontend/src/lib/branchFilters.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + branchFilterToken, + branchLabel, + branchTokenLabel, +} from "./branchFilters.js"; + +describe("branch filter labels", () => { + it("keeps empty branch labels distinct from a real unknown branch", () => { + const noBranch = "(no branch)"; + expect(branchLabel("proj", "", noBranch)).toBe("proj/(no branch)"); + expect(branchLabel("proj", "unknown", noBranch)).toBe("proj/unknown"); + expect(branchTokenLabel(branchFilterToken("proj", ""), noBranch)).toBe( + "proj/(no branch)", + ); + expect(branchTokenLabel( + branchFilterToken("proj", ""), + "No branch", + )).toBe("proj/No branch"); + }); +}); diff --git a/frontend/src/lib/branchFilters.ts b/frontend/src/lib/branchFilters.ts new file mode 100644 index 000000000..2e8c26cfc --- /dev/null +++ b/frontend/src/lib/branchFilters.ts @@ -0,0 +1,34 @@ +// Keep these in sync with internal/db branch token separators. +export const BRANCH_TOKEN_SEP = "\u001f"; +export const BRANCH_LIST_SEP = "\u001e"; + +export function branchFilterToken(project: string, branch: string): string { + return project + BRANCH_TOKEN_SEP + branch; +} + +export function splitBranchFilterToken(token: string): { + project: string; + branch: string; +} { + const i = token.indexOf(BRANCH_TOKEN_SEP); + return i < 0 + ? { project: "", branch: token } + : { project: token.slice(0, i), branch: token.slice(i + 1) }; +} + +export function branchLabel( + project: string, + branch: string, + noBranchLabel: string, +): string { + const label = branch || noBranchLabel; + return project ? `${project}/${label}` : label; +} + +export function branchTokenLabel( + token: string, + noBranchLabel: string, +): string { + const { project, branch } = splitBranchFilterToken(token); + return branchLabel(project, branch, noBranchLabel); +} diff --git a/frontend/src/lib/components/usage/AttributionPanel.svelte b/frontend/src/lib/components/usage/AttributionPanel.svelte index e9dede531..8a941170b 100644 --- a/frontend/src/lib/components/usage/AttributionPanel.svelte +++ b/frontend/src/lib/components/usage/AttributionPanel.svelte @@ -4,6 +4,10 @@ type GroupBy, type AttributionView, } from "../../stores/usage.svelte.js"; + import { + branchFilterToken, + branchLabel, + } from "../../branchFilters.js"; import { projectColor } from "../../utils/projectColor.js"; import Treemap from "./Treemap.svelte"; import { m } from "../../i18n/index.js"; @@ -20,6 +24,8 @@ const groupBy = $derived(usage.toggles.attribution.groupBy); const view = $derived(usage.toggles.attribution.view); + const canSelectRows = $derived(groupBy !== "branch"); + const noBranchLabel = $derived(m.shared_no_branch()); interface Row { id: string; @@ -51,6 +57,12 @@ label: m.model, cost: m.cost, })); + } else if (groupBy === "branch") { + items = s.branchTotals.map((b) => ({ + id: branchFilterToken(b.project, b.branch), + label: branchLabel(b.project, b.branch, noBranchLabel), + cost: b.cost, + })); } else { items = s.agentTotals.map((a) => ({ id: a.agent, @@ -88,7 +100,7 @@ usage.toggleProject(id); } else if (groupBy === "agent") { usage.toggleAgent(id); - } else { + } else if (groupBy === "model") { usage.toggleModel(id); } } @@ -128,6 +140,13 @@ > {m.analytics_col_agent()} +
{:else} -
{m.usage_click_to_hide_hint()}
+ {#if canSelectRows} +
{m.usage_click_to_hide_hint()}
+ {/if} {#if view === "treemap"}
@@ -167,8 +188,9 @@
handleSelect(row.id)} + class:interactive={canSelectRows} + title={canSelectRows ? m.usage_click_to_hide({ label: row.label }) : undefined} + onclick={canSelectRows ? () => handleSelect(row.id) : undefined} > {i + 1}
handleSelect(row.id)} + class:interactive={canSelectRows} + title={canSelectRows ? m.usage_click_to_hide({ label: row.label }) : undefined} + onclick={canSelectRows ? () => handleSelect(row.id) : undefined} > {i + 1} import { usage, type GroupBy } from "../../stores/usage.svelte.js"; + import { + branchFilterToken, + branchTokenLabel, + } from "../../branchFilters.js"; import { projectColor } from "../../utils/projectColor.js"; import { m } from "../../i18n/index.js"; @@ -36,6 +40,7 @@ } const groupBy = $derived(usage.toggles.timeSeries.groupBy); + const noBranchLabel = $derived(m.shared_no_branch()); const seriesData = $derived.by((): { points: Point[]; @@ -47,7 +52,6 @@ return { points: [], keys: [], maxY: 0 }; } - // Sum cost per key across the whole range to find top N. const totals = new Map(); for (const day of daily) { if (groupBy === "project" && day.projectBreakdowns) { @@ -65,10 +69,14 @@ totals.set(b.agent, (totals.get(b.agent) ?? 0) + b.cost); } + } else if (groupBy === "branch" && day.branchBreakdowns) { + for (const b of day.branchBreakdowns) { + const key = branchFilterToken(b.project, b.branch); + totals.set(key, (totals.get(key) ?? 0) + b.cost); + } } } - // If only one key or few keys, no need for "Other". if (totals.size === 0) { const points = daily.map((d) => ({ date: d.date, @@ -81,7 +89,6 @@ return { points, keys: ["total"], maxY: maxY || 1 }; } - // Pick top N by total cost, group the rest as "Other". const ranked = [...totals.entries()] .sort((a, b) => b[1] - a[1]); const topKeys = new Set( @@ -106,6 +113,10 @@ items = day.agentBreakdowns.map((b) => ({ key: b.agent, cost: b.cost, })); + } else if (groupBy === "branch" && day.branchBreakdowns) { + items = day.branchBreakdowns.map((b) => ({ + key: branchFilterToken(b.project, b.branch), cost: b.cost, + })); } for (const { key, cost } of items) { @@ -119,8 +130,6 @@ points.push({ date: day.date, values }); } - // Build ordered key list: top N by cost desc, then - // __other__ (displayed as "Other" in legend/labels). const keys = ranked .slice(0, MAX_SERIES) .map(([k]) => k); @@ -240,7 +249,6 @@ d += i === 0 ? `M${x},${top}` : `L${x},${top}`; } - // Close area back along baseline for (let i = points.length - 1; i >= 0; i--) { const x = Y_LABEL_W + i * xStep; const base = scaleY(baselines[i]!, maxY, h); @@ -332,6 +340,11 @@ function handleGroupByChange(g: GroupBy) { usage.setTimeSeriesGroupBy(g); } + + function legendLabel(key: string): string { + if (key === "__other__") return m.shared_other(); + return groupBy === "branch" ? branchTokenLabel(key, noBranchLabel) : key; + }
@@ -359,6 +372,13 @@ > {m.analytics_col_agent()} +
@@ -416,7 +436,7 @@ class="legend-dot" style="background: {key === '__other__' ? 'var(--text-muted)' : projectColor(key)}" >
- {key === "__other__" ? m.shared_other() : 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 5b38a5cef..c851cdc15 100644 --- a/frontend/src/lib/components/usage/CostTimeSeriesChart.test.ts +++ b/frontend/src/lib/components/usage/CostTimeSeriesChart.test.ts @@ -100,6 +100,7 @@ function usageSummary(): UsageSummaryResponse { ], modelTotals: [], agentTotals: [], + branchTotals: [], sessionCounts: { total: 15, byProject: { agentsview: 15 }, diff --git a/frontend/src/lib/components/usage/Treemap.svelte b/frontend/src/lib/components/usage/Treemap.svelte index b95fc9467..3adc282eb 100644 --- a/frontend/src/lib/components/usage/Treemap.svelte +++ b/frontend/src/lib/components/usage/Treemap.svelte @@ -20,6 +20,7 @@ let containerEl: HTMLDivElement | undefined = $state(); let width = $state(600); + const interactive = $derived(typeof onSelect === "function"); $effect(() => { if (!containerEl) return; @@ -104,17 +105,19 @@ height={tile.height} /> + onSelect?.(tile.id)} - onkeydown={(e) => handleKey(e, tile.id)} + class:interactive + tabindex={interactive ? 0 : undefined} + role={interactive ? "button" : undefined} + aria-label={interactive ? m.usage_hide_from_chart({ label: tile.label }) : undefined} + onclick={interactive ? () => onSelect?.(tile.id) : undefined} + onkeydown={interactive ? (e) => handleKey(e, tile.id) : undefined} clip-path="url(#{clipId})" > - {m.usage_click_to_hide({ label: tile.label })} + {interactive ? m.usage_click_to_hide({ label: tile.label }) : tile.label} ({ projectTotals: [], modelTotals: [], agentTotals: [], + branchTotals: [], sessionCounts: { total: 0, byProject: {}, @@ -110,6 +111,7 @@ function usageSummary(totalCost = 0): UsageSummaryResponse { projectTotals: [], modelTotals: [], agentTotals: [], + branchTotals: [], sessionCounts: { total: 0, byProject: {}, diff --git a/internal/db/analytics.go b/internal/db/analytics.go index f2e6a16e2..67471f515 100644 --- a/internal/db/analytics.go +++ b/internal/db/analytics.go @@ -87,10 +87,12 @@ func queryChunkedSize( // AnalyticsFilter is the shared filter for all analytics queries. type AnalyticsFilter struct { - From string // ISO date YYYY-MM-DD, inclusive - To string // ISO date YYYY-MM-DD, inclusive - Machine string // optional machine filter - Project string // optional project filter + From string // ISO date YYYY-MM-DD, inclusive + To string // ISO date YYYY-MM-DD, inclusive + Machine string // optional machine filter + Project string // optional project filter + // GitBranch is a branchListSep-joined list of opaque (project, branch) tokens (EncodeBranchFilterToken). + GitBranch string Agent string // optional agent filter Model string // optional model filter Timezone string // IANA timezone for day bucketing @@ -354,6 +356,12 @@ func (f AnalyticsFilter) buildWhereWithDate( args = append(args, f.Project) } + if f.GitBranch != "" { + var clause string + clause, args = BranchPairClauseArgs("project", "git_branch", f.GitBranch, args) + preds = append(preds, clause) + } + if f.Agent != "" { agents := csvFilterValues(f.Agent) if len(agents) == 1 { diff --git a/internal/db/branch_filter_test.go b/internal/db/branch_filter_test.go new file mode 100644 index 000000000..3f0c1f5ab --- /dev/null +++ b/internal/db/branch_filter_test.go @@ -0,0 +1,236 @@ +package db + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func branchInfoForTest(project, branch string) BranchInfo { + return BranchInfo{ + Project: project, + Branch: branch, + Token: EncodeBranchFilterToken(project, branch), + } +} + +func TestGetDailyUsageBranchBreakdowns(t *testing.T) { + d := testDB(t) + ctx := context.Background() + + seed := []struct { + id, project, branch string + input, output int + }{ + {"a", "proj-a", "main", 100, 10}, + {"b", "proj-a", "feature-x", 200, 20}, + {"c", "proj-b", "main", 300, 30}, + {"d", "proj-a", "", 400, 40}, + {"e", "proj-a", "unknown", 500, 50}, + } + for _, s := range seed { + input, output := s.input, s.output + insertSession(t, d, s.id, s.project, func(sess *Session) { + sess.GitBranch = s.branch + sess.StartedAt = new("2026-05-14T10:00:00Z") + sess.UserMessageCount = 2 + }) + require.NoError(t, d.ReplaceSessionUsageEvents(s.id, []UsageEvent{{ + SessionID: s.id, + Source: "session", + Model: "gpt-5.4", + InputTokens: input, + OutputTokens: output, + DedupKey: s.id + "-key", + }}), "replace usage event for %s", s.id) + } + + daily, err := d.GetDailyUsage(ctx, UsageFilter{ + From: "2026-05-14", + To: "2026-05-14", + Breakdowns: true, + }) + require.NoError(t, err, "GetDailyUsage") + require.Len(t, daily.Daily, 1, "one day") + + 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() + + seed := []struct { + id, project, branch string + input, output int + }{ + {"a", "proj-a", "main", 100, 10}, + {"b", "proj-a", "feature-x", 200, 20}, + {"c", "proj-b", "main", 300, 30}, + {"d", "proj-a", "", 400, 40}, + {"e", "proj-a", "unknown", 500, 50}, + } + for _, s := range seed { + input, output := s.input, s.output + insertSession(t, d, s.id, s.project, func(sess *Session) { + sess.GitBranch = s.branch + sess.StartedAt = new("2026-05-14T10:00:00Z") + sess.UserMessageCount = 2 + }) + require.NoError(t, d.ReplaceSessionUsageEvents(s.id, []UsageEvent{{ + SessionID: s.id, + Source: "session", + Model: "gpt-5.4", + InputTokens: input, + OutputTokens: output, + DedupKey: s.id + "-key", + }}), "replace usage event for %s", s.id) + } + + daily, err := d.GetDailyUsage(ctx, UsageFilter{ + From: "2026-05-14", + To: "2026-05-14", + GitBranch: EncodeBranchFilterToken("proj-a", "main"), + }) + require.NoError(t, err, "GetDailyUsage") + require.Len(t, daily.Daily, 1, "one day") + assert.Equal(t, 100, daily.Daily[0].InputTokens, + "usage filter uses scoped (project, branch), not branch name alone") +} + +func TestSplitBranchFilterTokens(t *testing.T) { + tests := []struct { + name string + in string + want []BranchInfo + }{ + {"empty", "", []BranchInfo{}}, + { + name: "round trip single", + in: EncodeBranchFilterToken("alpha", "main"), + want: []BranchInfo{branchInfoForTest("alpha", "main")}, + }, + { + name: "multiple", + in: encodeBranchFilterTokensForTest( + BranchInfo{Project: "alpha", Branch: "feat/x"}, + BranchInfo{Project: "beta", Branch: "main"}, + ), + want: []BranchInfo{ + branchInfoForTest("alpha", "feat/x"), + branchInfoForTest("beta", "main"), + }, + }, + { + name: "comma in branch name round-trips", + in: EncodeBranchFilterToken("proj", "wip,test"), + want: []BranchInfo{branchInfoForTest("proj", "wip,test")}, + }, + { + name: "drops blank and separator-less tokens", + in: branchListSep + EncodeBranchFilterToken("alpha", "main") + branchListSep + "noseparator", + want: []BranchInfo{branchInfoForTest("alpha", "main")}, + }, + { + name: "empty branch component survives", + in: EncodeBranchFilterToken("alpha", ""), + want: []BranchInfo{branchInfoForTest("alpha", "")}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, SplitBranchFilterTokens(tt.in)) + }) + } +} + +func TestGetBranches(t *testing.T) { + d := testDB(t) + + insertSession(t, d, "s1", "alpha", func(s *Session) { + s.GitBranch = "main" + s.UserMessageCount = 5 + }) + insertSession(t, d, "s2", "alpha", func(s *Session) { + s.GitBranch = "feat/x" + s.UserMessageCount = 5 + }) + insertSession(t, d, "s3", "beta", func(s *Session) { + s.GitBranch = "main" + s.UserMessageCount = 5 + }) + insertSession(t, d, "s4", "alpha", func(s *Session) { + s.GitBranch = "" + s.UserMessageCount = 5 + }) + insertSession(t, d, "s5", "gamma", func(s *Session) { + s.GitBranch = "solo" + s.UserMessageCount = 1 + }) + + all, err := d.GetBranches(context.Background(), false, false) + require.NoError(t, err, "GetBranches includeAll") + assert.Equal(t, []BranchInfo{ + branchInfoForTest("alpha", "feat/x"), + branchInfoForTest("alpha", "main"), + branchInfoForTest("beta", "main"), + branchInfoForTest("gamma", "solo"), + }, all, "distinct (project, branch) pairs, ordered, empty excluded") + + filtered, err := d.GetBranches(context.Background(), true, false) + require.NoError(t, err, "GetBranches excludeOneShot") + assert.NotContains(t, filtered, branchInfoForTest("gamma", "solo"), + "one-shot branch excluded when excludeOneShot is set") +} + +func TestSessionFilterGitBranchComposite(t *testing.T) { + d := testDB(t) + + insertSession(t, d, "alpha-main", "alpha", func(s *Session) { + s.GitBranch = "main" + }) + insertSession(t, d, "alpha-feat", "alpha", func(s *Session) { + s.GitBranch = "feat/x" + }) + insertSession(t, d, "beta-main", "beta", func(s *Session) { + s.GitBranch = "main" + }) + insertSession(t, d, "alpha-empty", "alpha", func(s *Session) { + s.GitBranch = "" + }) + insertSession(t, d, "alpha-unknown", "alpha", func(s *Session) { + s.GitBranch = "unknown" + }) + + // Filtering by (alpha, main) must not match (beta, main): the grain is + // (project, branch), so same-named branches across projects stay distinct. + requireSessions(t, d, SessionFilter{ + GitBranch: EncodeBranchFilterToken("alpha", "main"), + }, []string{"alpha-main"}) + + requireSessions(t, d, SessionFilter{ + GitBranch: encodeBranchFilterTokensForTest( + BranchInfo{Project: "alpha", Branch: "feat/x"}, + BranchInfo{Project: "beta", Branch: "main"}, + ), + }, []string{"alpha-feat", "beta-main"}) + + requireSessions(t, d, SessionFilter{ + GitBranch: EncodeBranchFilterToken("alpha", ""), + }, []string{"alpha-empty"}) + + requireSessions(t, d, SessionFilter{ + GitBranch: EncodeBranchFilterToken("alpha", "unknown"), + }, []string{"alpha-unknown"}) +} diff --git a/internal/db/db.go b/internal/db/db.go index f4fd1cf38..fc959cbc6 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1419,6 +1419,8 @@ func (db *DB) createPartialIndexesLocked(w *writerHandle) error { indexes := []string{ `CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd) WHERE cwd != ''`, + `CREATE INDEX IF NOT EXISTS idx_sessions_project_git_branch + ON sessions(project, git_branch) WHERE git_branch != ''`, `CREATE INDEX IF NOT EXISTS idx_messages_compact_boundary ON messages(session_id, ordinal) WHERE is_compact_boundary = 1`, `CREATE INDEX IF NOT EXISTS idx_messages_sidechain diff --git a/internal/db/query_dialect.go b/internal/db/query_dialect.go index 4a69545f6..594764855 100644 --- a/internal/db/query_dialect.go +++ b/internal/db/query_dialect.go @@ -483,6 +483,11 @@ func sessionFilterPredicates( preds = append(preds, inPredicate(q("machine"), splitCSV(f.Machine), b)) } + if f.GitBranch != "" { + preds = append(preds, BranchPairPredicate( + q("project"), q("git_branch"), f.GitBranch, + func(s string) string { return b.Add(s) })) + } if f.Agent != "" { preds = append(preds, inPredicate(q("agent"), splitCSV(f.Agent), b)) @@ -606,6 +611,75 @@ func splitCSV(s string) []string { return out } +// The separators are unit/record separators so comma-delimited filters can +// carry project or branch names containing commas. +const ( + branchFilterSep = "\x1f" + branchListSep = "\x1e" +) + +// EncodeBranchFilterToken builds the opaque (project, branch) filter token. +// Keying by (project, branch) keeps same-named branches across repos distinct; +// the frontend passes the token back verbatim. +func EncodeBranchFilterToken(project, branch string) string { + return project + branchFilterSep + branch +} + +// 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. +func SplitBranchFilterTokens(s string) []BranchInfo { + parts := strings.Split(s, branchListSep) + out := make([]BranchInfo, 0, len(parts)) + for _, p := range parts { + project, branch, ok := strings.Cut(p, branchFilterSep) + if !ok { + continue + } + out = append(out, BranchInfo{ + Project: project, + Branch: branch, + Token: EncodeBranchFilterToken(project, branch), + }) + } + 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. +func BranchPairPredicate( + projectCol, branchCol, tokens string, placeholder func(string) string, +) string { + pairs := SplitBranchFilterTokens(tokens) + if len(pairs) == 0 { + return "1 = 0" + } + parts := make([]string, len(pairs)) + for i, p := range pairs { + parts[i] = "(" + projectCol + " = " + placeholder(p.Project) + + " AND " + branchCol + " = " + placeholder(p.Branch) + ")" + } + if len(parts) == 1 { + return parts[0] + } + return "(" + strings.Join(parts, " OR ") + ")" +} + +// BranchPairClauseArgs is the raw-args ("?" placeholder) form of +// BranchPairPredicate. +func BranchPairClauseArgs( + projectCol, branchCol, tokens string, args []any, +) (string, []any) { + clause := BranchPairPredicate( + 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 60356914c..346782e80 100644 --- a/internal/db/query_dialect_test.go +++ b/internal/db/query_dialect_test.go @@ -9,6 +9,15 @@ import ( "github.com/stretchr/testify/require" ) +func encodeBranchFilterTokensForTest(branches ...BranchInfo) string { + tokens := make([]string, 0, len(branches)) + for _, branch := range branches { + tokens = append(tokens, + EncodeBranchFilterToken(branch.Project, branch.Branch)) + } + return strings.Join(tokens, branchListSep) +} + func TestBuildSessionFilterSQLRendersEquivalentDialectFilters(t *testing.T) { minToolFailures := 2 filter := SessionFilter{ @@ -189,6 +198,77 @@ func TestBuildSessionFilterSQLHandlesEmptyCSVFilters(t *testing.T) { assert.Empty(t, args) } +func TestBuildSessionFilterSQLRendersBranchPairs(t *testing.T) { + filter := SessionFilter{ + Machine: "laptop", + GitBranch: encodeBranchFilterTokensForTest( + BranchInfo{Project: "alpha", Branch: ""}, + BranchInfo{Project: "alpha", Branch: "unknown"}, + ), + Agent: "claude", + } + + tests := []struct { + name string + dialect QueryDialect + wantParts []string + }{ + { + name: "sqlite", + dialect: SQLiteQueryDialect(), + wantParts: []string{ + "machine = ?", + "((project = ? AND git_branch = ?) OR (project = ? AND git_branch = ?))", + "agent = ?", + }, + }, + { + name: "postgres", + dialect: PostgresQueryDialect(), + wantParts: []string{ + "machine = $1", + "((project = $2 AND git_branch = $3) OR (project = $4 AND git_branch = $5))", + "agent = $6", + }, + }, + { + name: "duckdb", + dialect: DuckDBQueryDialect(), + wantParts: []string{ + "machine = ?", + "((project = ? AND git_branch = ?) OR (project = ? AND git_branch = ?))", + "agent = ?", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, args := BuildSessionFilterSQL(filter, tt.dialect) + normalized := normalizeSQL(got) + + for _, part := range tt.wantParts { + assert.Contains(t, normalized, normalizeSQL(part)) + } + assert.Equal(t, []any{"laptop", "alpha", "", "alpha", "unknown", "claude"}, args) + }) + } +} + +func TestBranchPairClauseArgsKeepsEmptyBranchDistinct(t *testing.T) { + tokens := encodeBranchFilterTokensForTest( + BranchInfo{Project: "alpha", Branch: ""}, + BranchInfo{Project: "alpha", Branch: "unknown"}, + ) + + got, args := BranchPairClauseArgs("project", "git_branch", tokens, nil) + + assert.Equal(t, + "((project = ? AND git_branch = ?) OR (project = ? AND git_branch = ?))", + normalizeSQL(got)) + assert.Equal(t, []any{"alpha", "", "alpha", "unknown"}, args) +} + func TestSessionCursorFragmentsAreParameterized(t *testing.T) { cursor := SessionCursor{ EndedAt: "2026-06-08T12:00:00Z", diff --git a/internal/db/search_content.go b/internal/db/search_content.go index f3324a6da..dc9265f52 100644 --- a/internal/db/search_content.go +++ b/internal/db/search_content.go @@ -34,6 +34,8 @@ type ContentSearchFilter struct { Project, ExcludeProject, Machine, Agent string Date, DateFrom, DateTo, ActiveSince string IncludeChildren, IncludeAutomated, IncludeOneShot bool + // GitBranch is a branchListSep-joined list of opaque (project, branch) tokens (EncodeBranchFilterToken). + GitBranch string // RevealSecrets returns raw snippets. It defaults false so snippets are // secret-redacted unless a caller (the localhost-gated reveal path) @@ -88,7 +90,7 @@ func sessionScopeSubquery(f ContentSearchFilter) (string, []any) { // (scanned over every session at sync), not from search defaults. sf := SessionFilter{ Project: f.Project, ExcludeProject: f.ExcludeProject, - Machine: f.Machine, Agent: f.Agent, + Machine: f.Machine, GitBranch: f.GitBranch, Agent: f.Agent, Date: f.Date, DateFrom: f.DateFrom, DateTo: f.DateTo, ActiveSince: f.ActiveSince, ExcludeOneShot: !f.IncludeOneShot, diff --git a/internal/db/sessions.go b/internal/db/sessions.go index c988ee329..d721a6897 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -446,9 +446,11 @@ func (db *DB) DecodeCursor(s string) (SessionCursor, error) { // SessionFilter specifies how to query sessions. type SessionFilter struct { - Project string - ExcludeProject string // exclude sessions with this project name - Machine string + Project string + ExcludeProject string // exclude sessions with this project name + Machine string + // GitBranch is a branchListSep-joined list of opaque (project, branch) tokens (EncodeBranchFilterToken). + GitBranch string Agent string Date string // exact date YYYY-MM-DD DateFrom string // range start (inclusive) @@ -2417,6 +2419,56 @@ func (db *DB) GetMachines( return machines, rows.Err() } +// BranchInfo is a (project, branch) pair, keyed by project so same-named +// branches across repos stay distinct. +type BranchInfo struct { + Project string `json:"project"` + Branch string `json:"branch"` + Token string `json:"token"` +} + +// GetBranches returns distinct (project, git_branch) pairs for sessions with a +// recorded branch. Scoping matches GetProjects/GetAgents (root sessions with +// messages) so the dropdown reflects real work rather than subagents. +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 + AND git_branch != ''` + 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 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() +} + // scanSessionRows iterates rows and scans each using // scanSessionRow. func scanSessionRows(rows *sql.Rows) ([]Session, error) { diff --git a/internal/db/store.go b/internal/db/store.go index 9e90746a2..c1c431ebf 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -56,6 +56,7 @@ type Store interface { GetProjects(ctx context.Context, excludeOneShot, excludeAutomated bool) ([]ProjectInfo, 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) // Analytics. GetAnalyticsSummary(ctx context.Context, f AnalyticsFilter) (AnalyticsSummary, error) diff --git a/internal/db/usage.go b/internal/db/usage.go index 394359159..e5fb60e45 100644 --- a/internal/db/usage.go +++ b/internal/db/usage.go @@ -58,11 +58,13 @@ func (r *modelRateResolver) lookup(model string) (modelRates, bool) { // UsageFilter controls the date range, agent, and timezone // for daily usage aggregation queries. type UsageFilter struct { - From string // YYYY-MM-DD, inclusive - To string // YYYY-MM-DD, inclusive - Agent string // "" for all; supports comma-separated - Project string // "" for all; supports comma-separated - Machine string // "" for all; supports comma-separated + From string // YYYY-MM-DD, inclusive + To string // YYYY-MM-DD, inclusive + Agent string // "" for all; supports comma-separated + Project string // "" for all; supports comma-separated + Machine string // "" for all; supports comma-separated + // GitBranch is a branchListSep-joined list of opaque (project, branch) tokens (EncodeBranchFilterToken). + GitBranch string Model string // "" for all; supports comma-separated ExcludeProject string // comma-separated projects to exclude ExcludeAgent string // comma-separated agents to exclude @@ -160,6 +162,11 @@ func (f UsageFilter) appendUsageSessionFilterClauses( where, args = appendCSV(where, args, "s.agent", f.Agent, true) where, args = appendCSV(where, args, "s.project", f.Project, true) where, args = appendCSV(where, args, "s.machine", f.Machine, true) + if f.GitBranch != "" { + var clause string + clause, args = BranchPairClauseArgs("s.project", "s.git_branch", f.GitBranch, args) + where += "\n\tAND " + clause + } where, args = appendCSV(where, args, "s.project", f.ExcludeProject, false) where, args = appendCSV(where, args, "s.agent", f.ExcludeAgent, false) @@ -375,7 +382,8 @@ SELECT m.source_uuid, '' AS usage_dedup_key, s.project, - s.agent + s.agent, + s.git_branch FROM messages m JOIN sessions s ON m.session_id = s.id WHERE %s @@ -402,7 +410,8 @@ SELECT ELSE ue.session_id || ':' || ue.source || ':id:' || ue.id END AS usage_dedup_key, s.project, - s.agent + s.agent, + s.git_branch FROM usage_events ue JOIN sessions s ON s.id = ue.session_id WHERE %s` @@ -425,7 +434,8 @@ SELECT m.source_uuid, '' AS usage_dedup_key, s.project, - s.agent + s.agent, + s.git_branch FROM %s m JOIN sessions s ON m.session_id = s.id WHERE %s` @@ -451,7 +461,8 @@ SELECT ELSE ue.session_id || ':' || ue.source || ':id:' || ue.id END AS usage_dedup_key, s.project, - s.agent + s.agent, + s.git_branch FROM %s ue JOIN sessions s ON s.id = ue.session_id WHERE %s` @@ -582,6 +593,7 @@ type dailyUsageScanRow struct { usageDedupKey string project string agent string + gitBranch string } type topSessionMetadata struct { @@ -651,7 +663,8 @@ SELECT u.source_uuid, u.usage_dedup_key, u.project, - u.agent + u.agent, + u.git_branch FROM (` + rowsSQL + `) u WHERE 1=1` } @@ -803,7 +816,8 @@ SELECT '' AS source_uuid, cu.dedup_key AS usage_dedup_key, '' AS project, - 'cursor' AS agent + 'cursor' AS agent, + '' AS git_branch FROM cursor_usage_events cu WHERE %s` @@ -811,8 +825,11 @@ func cursorUsageRowsSQLForBounds( f UsageFilter, b usageBounds, ) (string, []any, bool) { 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 f.Project != "" || f.ExcludeProject != "" || - f.Machine != "" || f.MinUserMessages > 0 || + f.Machine != "" || f.GitBranch != "" || f.MinUserMessages > 0 || f.ExcludeOneShot || termPred != "" || f.ActiveSince != "" { return "", nil, false @@ -922,6 +939,7 @@ func scanDailyUsageRow(rows *sql.Rows) (dailyUsageScanRow, error) { &r.usageDedupKey, &r.project, &r.agent, + &r.gitBranch, ) return r, err } @@ -1356,6 +1374,7 @@ type DailyUsageEntry struct { ModelBreakdowns []ModelBreakdown `json:"modelBreakdowns,omitempty"` ProjectBreakdowns []ProjectBreakdown `json:"projectBreakdowns,omitempty"` AgentBreakdowns []AgentBreakdown `json:"agentBreakdowns,omitempty"` + BranchBreakdowns []BranchBreakdown `json:"branchBreakdowns,omitempty"` } // ModelBreakdown holds per-model token and cost breakdown. @@ -1388,6 +1407,17 @@ type AgentBreakdown struct { Cost float64 `json:"cost"` } +// BranchBreakdown is keyed by the raw (project, branch) pair. +type BranchBreakdown struct { + 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 float64 `json:"cost"` +} + // UsageTotals holds aggregate token and cost totals. type UsageTotals struct { InputTokens int `json:"inputTokens"` @@ -1513,12 +1543,12 @@ func (db *DB) GetDailyUsage( } defer rows.Close() - // 4-tuple key for per-(date, project, agent, model) accumulation. type accumKey struct { - date string - project string - agent string - model string + date string + project string + agent string + model string + gitBranch string } type bucket struct { inputTok int @@ -1584,9 +1614,14 @@ func (db *DB) GetDailyUsage( dailyUsageAmounts(r, rateResolver) totalSavings += savings + gitBranch := "" + if f.Breakdowns { + gitBranch = r.gitBranch + } key := accumKey{ date: date, project: r.project, agent: r.agent, model: r.model, + gitBranch: gitBranch, } b, ok := accum[key] if !ok { @@ -1744,11 +1779,15 @@ func (db *DB) GetDailyUsage( }, nil } - // Breakdown path: single walk builds model/project/agent maps. + type branchMapKey struct { + project string + branch string + } type dayMaps struct { models map[string]bucket projects map[string]bucket agents map[string]bucket + branches map[branchMapKey]bucket } days := make(map[string]*dayMaps, 64) for key, b := range accum { @@ -1758,6 +1797,7 @@ func (db *DB) GetDailyUsage( models: make(map[string]bucket, 4), projects: make(map[string]bucket, 8), agents: make(map[string]bucket, 4), + branches: make(map[branchMapKey]bucket, 8), } days[key.date] = dm } @@ -1784,6 +1824,18 @@ func (db *DB) GetDailyUsage( cur.cacheRd += b.cacheRd cur.cost += b.cost dm.agents[key.agent] = cur + + bk := branchMapKey{ + project: key.project, + branch: key.gitBranch, + } + cur = dm.branches[bk] + cur.inputTok += b.inputTok + cur.outputTok += b.outputTok + cur.cacheCr += b.cacheCr + cur.cacheRd += b.cacheRd + cur.cost += b.cost + dm.branches[bk] = cur } dateKeys := make([]string, 0, len(days)) @@ -1884,6 +1936,31 @@ func (db *DB) GetDailyUsage( }) entry.AgentBreakdowns = abd + 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, + }) + } + sort.Slice(bbd, func(i, j int) bool { + if bbd[i].Cost != bbd[j].Cost { + return bbd[i].Cost > bbd[j].Cost + } + if bbd[i].Project != bbd[j].Project { + return bbd[i].Project < bbd[j].Project + } + return bbd[i].Branch < bbd[j].Branch + }) + entry.BranchBreakdowns = bbd + daily = append(daily, entry) totals.InputTokens += entry.InputTokens diff --git a/internal/duckdb/analytics_usage.go b/internal/duckdb/analytics_usage.go index fcd743b08..d0abe06cb 100644 --- a/internal/duckdb/analytics_usage.go +++ b/internal/duckdb/analytics_usage.go @@ -210,6 +210,11 @@ func duckBuildAnalyticsWhere( preds = append(preds, q("project")+" = ?") args = append(args, f.Project) } + if f.GitBranch != "" { + var clause string + clause, args = db.BranchPairClauseArgs(q("project"), q("git_branch"), f.GitBranch, args) + preds = append(preds, clause) + } if f.Agent != "" { preds, args = appendDuckAnalyticsCSVFilter(preds, args, q("agent"), f.Agent) } @@ -3031,6 +3036,11 @@ func appendDuckUsageSessionFilterClauses( where, args = appendDuckUsageCSVFilter(where, args, "s.agent", f.Agent, true) where, args = appendDuckUsageCSVFilter(where, args, "s.project", f.Project, true) where, args = appendDuckUsageCSVFilter(where, args, "s.machine", f.Machine, true) + if f.GitBranch != "" { + var clause string + clause, args = db.BranchPairClauseArgs("s.project", "s.git_branch", f.GitBranch, args) + where += "\n\t\t\tAND " + clause + } where, args = appendDuckUsageCSVFilter(where, args, "s.project", f.ExcludeProject, false) where, args = appendDuckUsageCSVFilter(where, args, "s.agent", f.ExcludeAgent, false) if sessionID != "" { @@ -3093,6 +3103,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, @@ -3138,6 +3149,7 @@ func duckUsageRawSQL(f db.UsageFilter, sessionID string) (string, []any) { 0 AS input_tokens, 0 AS output_tokens, 0 AS cache_create, 0 AS cache_read, NULL AS cost_usd, 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, @@ -3160,6 +3172,7 @@ func duckUsageRawSQL(f db.UsageFilter, sessionID string) (string, []any) { ue.cache_read_input_tokens AS cache_read, ue.cost_usd AS cost_usd, 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, @@ -3179,7 +3192,7 @@ func duckCursorUsageRowsSQLForBounds( ) (string, []any, bool) { hasTermFilter := f.Termination != "" && f.Termination != "all" if f.Project != "" || f.ExcludeProject != "" || - f.Machine != "" || f.MinUserMessages > 0 || + f.Machine != "" || f.GitBranch != "" || f.MinUserMessages > 0 || f.ExcludeOneShot || hasTermFilter || f.ActiveSince != "" { return "", nil, false @@ -3351,6 +3364,7 @@ type duckUsageAggregateRow struct { project string agent string model string + gitBranch string displayName string startedAt string inputTok int @@ -3390,8 +3404,16 @@ func (s *Store) dailyUsageAggregateRows( ctx context.Context, f db.UsageFilter, ) ([]duckUsageAggregateRow, error) { cte, args := duckDailyUsageCTE(f) + branchSelect := "'' AS git_branch" + branchGroup := "" + branchOrder := "" + if f.Breakdowns { + branchSelect = "git_branch" + branchGroup = ", git_branch" + branchOrder = ", git_branch ASC" + } query := cte + ` - SELECT local_date, project, agent, model, + SELECT local_date, project, agent, model, ` + branchSelect + `, SUM(input_tokens_norm) AS input_tokens, SUM(output_tokens_norm) AS output_tokens, SUM(cache_create_norm) AS cache_creation_tokens, @@ -3402,8 +3424,8 @@ func (s *Store) dailyUsageAggregateRows( SUM(CASE WHEN cost_usd IS NULL THEN cache_read_norm ELSE 0 END) AS billable_cache_read_tokens, COALESCE(SUM(cost_usd), 0) AS explicit_cost FROM usage_localized - GROUP BY local_date, project, agent, model - ORDER BY local_date ASC, project ASC, agent ASC, model ASC` + GROUP BY local_date, project, agent, model` + branchGroup + ` + ORDER BY local_date ASC, project ASC, agent ASC, model ASC` + branchOrder rows, err := s.duck.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("querying duckdb daily usage aggregates: %w", err) @@ -3413,7 +3435,7 @@ func (s *Store) dailyUsageAggregateRows( for rows.Next() { var r duckUsageAggregateRow if err := rows.Scan( - &r.date, &r.project, &r.agent, &r.model, + &r.date, &r.project, &r.agent, &r.model, &r.gitBranch, &r.inputTok, &r.outputTok, &r.cacheCr, &r.cacheRd, &r.billableInput, &r.billableOutput, &r.billableCacheCr, &r.billableCacheRd, @@ -3438,15 +3460,16 @@ func (s *Store) GetDailyUsage( return db.DailyUsageResult{}, err } type usageAccumKey struct { - date string - project string - agent string - model string + date string + project string + agent string + model string + gitBranch string } accum := map[usageAccumKey]*duckUsageBucket{} totalSavings := 0.0 for _, r := range rows { - key := usageAccumKey{date: r.date, project: r.project, agent: r.agent, model: r.model} + key := usageAccumKey{date: r.date, project: r.project, agent: r.agent, model: r.model, gitBranch: r.gitBranch} b := accum[key] if b == nil { b = &duckUsageBucket{} @@ -3468,10 +3491,15 @@ func (s *Store) GetDailyUsage( b.cost += cost } + type branchMapKey struct { + project string + branch string + } type dayMaps struct { models map[string]duckUsageBucket projects map[string]duckUsageBucket agents map[string]duckUsageBucket + branches map[branchMapKey]duckUsageBucket } days := map[string]*dayMaps{} for key, b := range accum { @@ -3481,6 +3509,7 @@ func (s *Store) GetDailyUsage( models: map[string]duckUsageBucket{}, projects: map[string]duckUsageBucket{}, agents: map[string]duckUsageBucket{}, + branches: map[branchMapKey]duckUsageBucket{}, } days[key.date] = day } @@ -3488,6 +3517,17 @@ func (s *Store) GetDailyUsage( if f.Breakdowns { addUsageBucket(day.projects, key.project, *b) addUsageBucket(day.agents, key.agent, *b) + bk := branchMapKey{ + project: key.project, + branch: key.gitBranch, + } + cur := day.branches[bk] + cur.inputTok += b.inputTok + cur.outputTok += b.outputTok + cur.cacheCr += b.cacheCr + cur.cacheRd += b.cacheRd + cur.cost += b.cost + day.branches[bk] = cur } } @@ -3539,6 +3579,28 @@ func (s *Store) GetDailyUsage( Cost: roundCost(b.cost), }) } + 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: roundCost(b.cost), + }) + } + sort.Slice(branchBreakdowns, func(i, j int) bool { + if branchBreakdowns[i].Cost != branchBreakdowns[j].Cost { + return branchBreakdowns[i].Cost > branchBreakdowns[j].Cost + } + if branchBreakdowns[i].Project != branchBreakdowns[j].Project { + return branchBreakdowns[i].Project < branchBreakdowns[j].Project + } + return branchBreakdowns[i].Branch < branchBreakdowns[j].Branch + }) + entry.BranchBreakdowns = branchBreakdowns } entry.TotalCost = roundCost(entry.TotalCost) result.Daily = append(result.Daily, entry) diff --git a/internal/duckdb/store.go b/internal/duckdb/store.go index 83e7a2662..4ee91b4d2 100644 --- a/internal/duckdb/store.go +++ b/internal/duckdb/store.go @@ -514,6 +514,28 @@ 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.duck.QueryContext(ctx, + `SELECT DISTINCT project, git_branch FROM sessions WHERE `+ + rootSessionWhere(excludeOneShot, excludeAutomated)+ + ` AND git_branch != '' ORDER BY project, git_branch`, + ) + if err != nil { + return nil, 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() +} + func rootSessionWhere(excludeOneShot, excludeAutomated bool) string { filter := `message_count > 0 AND relationship_type NOT IN ('subagent', 'fork') @@ -1018,7 +1040,7 @@ func contentCandidateMatches(candidates []duckContentCandidate) []db.ContentMatc func contentSessionFilter(f db.ContentSearchFilter) db.SessionFilter { return db.SessionFilter{ Project: f.Project, ExcludeProject: f.ExcludeProject, - Machine: f.Machine, Agent: f.Agent, + Machine: f.Machine, GitBranch: f.GitBranch, Agent: f.Agent, Date: f.Date, DateFrom: f.DateFrom, DateTo: f.DateTo, ActiveSince: f.ActiveSince, ExcludeOneShot: !f.IncludeOneShot, diff --git a/internal/duckdb/store_test.go b/internal/duckdb/store_test.go index 960782093..bc8e06599 100644 --- a/internal/duckdb/store_test.go +++ b/internal/duckdb/store_test.go @@ -1080,6 +1080,54 @@ func TestSearchContentRegexOrdersBySessionRecency(t *testing.T) { assert.Equal(t, "a-old-regex", got.Matches[1].SessionID) } +func TestSearchContentGitBranchFilter(t *testing.T) { + ctx := context.Background() + local := newLocalDB(t) + alphaMain := syncSession("branch-alpha-main", "alpha", "main session", "2026-01-11T00:00:00Z", 1) + alphaMain.GitBranch = "main" + alphaFeature := syncSession("branch-alpha-feature", "alpha", "feature session", "2026-01-11T00:01:00Z", 1) + alphaFeature.GitBranch = "feature" + betaMain := syncSession("branch-beta-main", "beta", "beta session", "2026-01-11T00:02:00Z", 1) + betaMain.GitBranch = "main" + _, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{ + { + Session: alphaMain, + Messages: []db.Message{syncMessage(alphaMain.ID, 0, "user", "BRANCHNEEDLE alpha main", "2026-01-11T00:00:00Z")}, + DataVersion: 1, + ReplaceMessages: true, + }, + { + Session: alphaFeature, + Messages: []db.Message{syncMessage(alphaFeature.ID, 0, "user", "BRANCHNEEDLE alpha feature", "2026-01-11T00:01:00Z")}, + DataVersion: 1, + ReplaceMessages: true, + }, + { + Session: betaMain, + Messages: []db.Message{syncMessage(betaMain.ID, 0, "user", "BRANCHNEEDLE beta main", "2026-01-11T00:02:00Z")}, + DataVersion: 1, + ReplaceMessages: true, + }, + }) + require.NoError(t, err) + syncer := newInMemoryTestSync(t, local, SyncOptions{}) + _, err = syncer.Push(ctx, true, nil) + require.NoError(t, err) + store := NewStoreFromDB(syncer.DB()) + + got, err := store.SearchContent(ctx, db.ContentSearchFilter{ + Pattern: "BRANCHNEEDLE", + Mode: "substring", + Sources: []string{"messages"}, + GitBranch: db.EncodeBranchFilterToken("alpha", "main"), + IncludeOneShot: true, + Limit: 10, + }) + require.NoError(t, err) + require.Len(t, got.Matches, 1) + assert.Equal(t, alphaMain.ID, got.Matches[0].SessionID) +} + func TestSearchContentSubstringPaginatesAfterGlobalOrdering(t *testing.T) { ctx := context.Background() store, _ := newSyncedStore(t) @@ -2309,3 +2357,108 @@ func newSyncedStore(t *testing.T) (*Store, syncFixture) { require.NoError(t, err) return NewStoreFromDB(syncer.DB()), fixture } + +func TestDuckDBBranchDimension(t *testing.T) { + ctx := context.Background() + local := newLocalDB(t) + require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{{ + ModelPattern: "claude-test", InputPerMTok: 3, OutputPerMTok: 15, + }})) + + seed := []struct { + id, project, branch 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}, + } + 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.GitBranch = s.branch + writes = append(writes, db.SessionBatchWrite{ + Session: sess, + // A token-free user message so only the usage event below feeds the + // usage totals (syncMessage would inject a stray input token). + Messages: []db.Message{{ + SessionID: s.id, + Ordinal: 0, + Role: "user", + Content: s.id + " first", + Timestamp: "2026-02-01T12:00:00.000Z", + ContentLength: len(s.id + " first"), + }}, + UsageEvents: []db.UsageEvent{{ + Source: "session", Model: "claude-test", + InputTokens: s.input, OutputTokens: s.output, + OccurredAt: "2026-02-01T12:01:00.000Z", DedupKey: s.id + "-usage", + }}, + DataVersion: 1, + ReplaceMessages: true, + }) + } + _, err := local.WriteSessionBatchAtomic(writes) + require.NoError(t, err) + + syncer := newInMemoryTestSync(t, local, SyncOptions{}) + _, err = syncer.Push(ctx, true, nil) + require.NoError(t, err) + store := NewStoreFromDB(syncer.DB()) + + branches, err := store.GetBranches(ctx, false, false) + require.NoError(t, err) + assert.Equal(t, []db.BranchInfo{ + { + 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) + + wide := db.UsageFilter{From: "2026-01-01", To: "2026-12-31", Breakdowns: true} + daily, err := store.GetDailyUsage(ctx, wide) + require.NoError(t, err) + 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, 5, "one bucket per distinct (project, branch)") + 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", Breakdowns: true, + GitBranch: db.EncodeBranchFilterToken("alpha", "main"), + }) + require.NoError(t, err) + total := 0 + for _, day := range filtered.Daily { + total += day.InputTokens + } + assert.Equal(t, 100, total, "branch filter restricts usage to alpha/main") +} diff --git a/internal/postgres/analytics.go b/internal/postgres/analytics.go index 9b6821061..2509adb59 100644 --- a/internal/postgres/analytics.go +++ b/internal/postgres/analytics.go @@ -148,6 +148,11 @@ func buildAnalyticsWhereWithDate( preds = append(preds, "project = "+pb.add(f.Project)) } + if f.GitBranch != "" { + preds = append(preds, db.BranchPairPredicate( + "project", "git_branch", f.GitBranch, + func(s string) string { return pb.add(s) })) + } if f.Agent != "" { preds = appendPGAnalyticsCSVFilter( preds, "agent", f.Agent, pb) diff --git a/internal/postgres/schema.go b/internal/postgres/schema.go index 4cc5407ce..524cf82ef 100644 --- a/internal/postgres/schema.go +++ b/internal/postgres/schema.go @@ -828,6 +828,8 @@ func createPartialIndexesPG(ctx context.Context, db *sql.DB) error { indexes := []string{ `CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd) WHERE cwd != ''`, + `CREATE INDEX IF NOT EXISTS idx_sessions_project_git_branch + ON sessions(project, git_branch) WHERE git_branch != ''`, `CREATE INDEX IF NOT EXISTS idx_messages_compact_boundary ON messages(session_id, ordinal) WHERE is_compact_boundary = TRUE`, `CREATE INDEX IF NOT EXISTS idx_messages_sidechain diff --git a/internal/postgres/search_content.go b/internal/postgres/search_content.go index 8a7d49ad4..f47702505 100644 --- a/internal/postgres/search_content.go +++ b/internal/postgres/search_content.go @@ -61,7 +61,7 @@ func pgHasSource(f db.ContentSearchFilter, src string) bool { func pgSessionFilter(f db.ContentSearchFilter) db.SessionFilter { return db.SessionFilter{ Project: f.Project, ExcludeProject: f.ExcludeProject, - Machine: f.Machine, Agent: f.Agent, + Machine: f.Machine, GitBranch: f.GitBranch, Agent: f.Agent, Date: f.Date, DateFrom: f.DateFrom, DateTo: f.DateTo, ActiveSince: f.ActiveSince, ExcludeOneShot: !f.IncludeOneShot, diff --git a/internal/postgres/search_content_pgtest_test.go b/internal/postgres/search_content_pgtest_test.go index 3096b3843..183a437f5 100644 --- a/internal/postgres/search_content_pgtest_test.go +++ b/internal/postgres/search_content_pgtest_test.go @@ -59,6 +59,15 @@ func insertCSSession( require.NoError(t, err, "insert session %s", id) } +func setCSSessionBranch(t *testing.T, store *Store, id, branch string) { + t.Helper() + _, err := store.DB().Exec( + `UPDATE sessions SET git_branch = $1 WHERE id = $2`, + branch, id, + ) + require.NoError(t, err, "set session branch %s", id) +} + // insertCSMessage inserts a message; isSystem=true sets is_system. func insertCSMessage( t *testing.T, store *Store, @@ -351,6 +360,38 @@ func TestPGSearchContentProjectFilter(t *testing.T) { assert.NotEmpty(t, got.Matches, "expected matches in alpha project") } +func TestPGSearchContentGitBranchFilter(t *testing.T) { + store := setupContentSearch(t) + insertCSSession(t, store, "cs-branch-alpha-main", "alpha", "claude", + "2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z") + setCSSessionBranch(t, store, "cs-branch-alpha-main", "main") + insertCSMessage(t, store, "cs-branch-alpha-main", 0, "user", + "BRANCHNEEDLE alpha main", "2026-05-01T10:00:00Z", false) + insertCSSession(t, store, "cs-branch-alpha-feature", "alpha", "claude", + "2026-05-01T11:00:00Z", "2026-05-01T11:30:00Z") + setCSSessionBranch(t, store, "cs-branch-alpha-feature", "feature") + insertCSMessage(t, store, "cs-branch-alpha-feature", 0, "user", + "BRANCHNEEDLE alpha feature", "2026-05-01T11:00:00Z", false) + insertCSSession(t, store, "cs-branch-beta-main", "beta", "claude", + "2026-05-01T12:00:00Z", "2026-05-01T12:30:00Z") + setCSSessionBranch(t, store, "cs-branch-beta-main", "main") + insertCSMessage(t, store, "cs-branch-beta-main", 0, "user", + "BRANCHNEEDLE beta main", "2026-05-01T12:00:00Z", false) + + ctx := context.Background() + got, err := store.SearchContent(ctx, db.ContentSearchFilter{ + Pattern: "BRANCHNEEDLE", + Mode: "substring", + Sources: []string{"messages"}, + GitBranch: db.EncodeBranchFilterToken("alpha", "main"), + IncludeOneShot: true, + Limit: 50, + }) + require.NoError(t, err) + require.Len(t, got.Matches, 1) + assert.Equal(t, "cs-branch-alpha-main", got.Matches[0].SessionID) +} + // TestPGSearchContentPagination verifies Limit+1 sentinel and NextCursor. func TestPGSearchContentPagination(t *testing.T) { store := setupContentSearch(t) diff --git a/internal/postgres/sessions.go b/internal/postgres/sessions.go index b677e2de4..a7fa2e072 100644 --- a/internal/postgres/sessions.go +++ b/internal/postgres/sessions.go @@ -1066,3 +1066,43 @@ func (s *Store) GetMachines( } return machines, rows.Err() } + +// GetBranches mirrors db.DB.GetBranches: distinct (project, branch) pairs scoped +// to root sessions with messages, matching GetProjects/GetAgents. +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 + AND git_branch != ''` + 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 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() +} diff --git a/internal/postgres/usage.go b/internal/postgres/usage.go index 2436b873e..52f1c2eb1 100644 --- a/internal/postgres/usage.go +++ b/internal/postgres/usage.go @@ -120,6 +120,11 @@ func appendPGUsageSessionFilterClauses( where = appendCSV(where, "s.agent", f.Agent, true) where = appendCSV(where, "s.project", f.Project, true) where = appendCSV(where, "s.machine", f.Machine, true) + if f.GitBranch != "" { + where += "\n\tAND " + db.BranchPairPredicate( + "s.project", "s.git_branch", f.GitBranch, + func(s string) string { return pb.add(s) }) + } where = appendCSV(where, "s.project", f.ExcludeProject, false) where = appendCSV(where, "s.agent", f.ExcludeAgent, false) @@ -291,7 +296,8 @@ SELECT m.source_uuid, '' AS usage_dedup_key, s.project, - s.agent + s.agent, + s.git_branch FROM messages m JOIN sessions s ON m.session_id = s.id WHERE %s @@ -318,7 +324,8 @@ SELECT ELSE ue.session_id || ':' || ue.source || ':id:' || ue.id END AS usage_dedup_key, s.project, - s.agent + s.agent, + s.git_branch FROM usage_events ue JOIN sessions s ON s.id = ue.session_id WHERE %s` @@ -341,7 +348,8 @@ SELECT m.source_uuid, '' AS usage_dedup_key, s.project, - s.agent + s.agent, + s.git_branch FROM %s m JOIN sessions s ON m.session_id = s.id WHERE %s` @@ -367,7 +375,8 @@ SELECT ELSE ue.session_id || ':' || ue.source || ':id:' || ue.id END AS usage_dedup_key, s.project, - s.agent + s.agent, + s.git_branch FROM %s ue JOIN sessions s ON s.id = ue.session_id WHERE %s` @@ -497,6 +506,7 @@ type pgDailyUsageScanRow struct { usageDedupKey string project string agent string + gitBranch string } type pgTopSessionMetadata struct { @@ -565,7 +575,8 @@ SELECT u.source_uuid, u.usage_dedup_key, u.project, - u.agent + u.agent, + u.git_branch FROM (` + rowsSQL + `) u WHERE 1=1` } @@ -684,7 +695,8 @@ SELECT '' AS source_uuid, cu.dedup_key AS usage_dedup_key, '' AS project, - 'cursor' AS agent + 'cursor' AS agent, + '' AS git_branch FROM cursor_usage_events cu WHERE %s` @@ -692,8 +704,11 @@ func pgCursorUsageRowsSQLForBounds( pb *paramBuilder, f db.UsageFilter, b pgUsageBounds, ) (string, bool) { 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 f.Project != "" || f.ExcludeProject != "" || - f.Machine != "" || f.MinUserMessages > 0 || + f.Machine != "" || f.GitBranch != "" || f.MinUserMessages > 0 || f.ExcludeOneShot || hasTermFilter || f.ActiveSince != "" { return "", false } @@ -799,6 +814,7 @@ func scanPGDailyUsageRow(rows *sql.Rows) (pgDailyUsageScanRow, error) { &r.usageDedupKey, &r.project, &r.agent, + &r.gitBranch, ) return r, err } @@ -1131,10 +1147,11 @@ func (s *Store) GetDailyUsage( defer rows.Close() type accumKey struct { - date string - project string - agent string - model string + date string + project string + agent string + model string + gitBranch string } type bucket struct { inputTok int @@ -1189,9 +1206,14 @@ func (s *Store) GetDailyUsage( pgDailyUsageAmounts(r, rateResolver) totalSavings += savings + gitBranch := "" + if f.Breakdowns { + gitBranch = r.gitBranch + } key := accumKey{ date: date, project: r.project, agent: r.agent, model: r.model, + gitBranch: gitBranch, } b, ok := accum[key] if !ok { @@ -1334,10 +1356,15 @@ 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 + branches map[branchMapKey]bucket } days := make(map[string]*dayMaps, 64) for key, b := range accum { @@ -1347,6 +1374,7 @@ func (s *Store) GetDailyUsage( models: make(map[string]bucket, 4), projects: make(map[string]bucket, 8), agents: make(map[string]bucket, 4), + branches: make(map[branchMapKey]bucket, 8), } days[key.date] = dm } @@ -1373,6 +1401,18 @@ func (s *Store) GetDailyUsage( cur.cacheRd += b.cacheRd cur.cost += b.cost dm.agents[key.agent] = cur + + bk := branchMapKey{ + project: key.project, + branch: key.gitBranch, + } + cur = dm.branches[bk] + cur.inputTok += b.inputTok + cur.outputTok += b.outputTok + cur.cacheCr += b.cacheCr + cur.cacheRd += b.cacheRd + cur.cost += b.cost + dm.branches[bk] = cur } dateKeys := make([]string, 0, len(days)) @@ -1461,6 +1501,29 @@ func (s *Store) GetDailyUsage( }) entry.AgentBreakdowns = abd + 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, + }) + } + sort.Slice(bbd, func(i, j int) bool { + if bbd[i].Cost != bbd[j].Cost { + return bbd[i].Cost > bbd[j].Cost + } + if bbd[i].Project != bbd[j].Project { + return bbd[i].Project < bbd[j].Project + } + return bbd[i].Branch < bbd[j].Branch + }) + entry.BranchBreakdowns = bbd + daily = append(daily, entry) totals.InputTokens += entry.InputTokens totals.OutputTokens += entry.OutputTokens diff --git a/internal/postgres/usage_unit_test.go b/internal/postgres/usage_unit_test.go index ca295d452..20ac7e4fa 100644 --- a/internal/postgres/usage_unit_test.go +++ b/internal/postgres/usage_unit_test.go @@ -134,6 +134,7 @@ func (c *usageProbeConn) QueryContext( "usage_dedup_key", "project", "agent", + "git_branch", }, values: [][]driver.Value{ usageProbeUsageRow("s-parent", "proj-a", "claude", ts), @@ -165,6 +166,7 @@ func usageProbeUsageRow( "", project, agent, + "", } } @@ -287,6 +289,21 @@ func TestPGUsageRowQueryPushesDateBoundsIntoUnion(t *testing.T) { assert.Equal(t, "2024-07-01T13:59:59Z", pb.args[1]) } +func TestPGBranchPairPredicateKeepsEmptyBranchDistinct(t *testing.T) { + pb := ¶mBuilder{} + const branchListSepForTest = "\x1e" + tokens := db.EncodeBranchFilterToken("alpha", "") + branchListSepForTest + + db.EncodeBranchFilterToken("alpha", "unknown") + + got := db.BranchPairPredicate("project", "git_branch", tokens, + func(s string) string { return pb.add(s) }) + + assert.Equal(t, + "((project = $1 AND git_branch = $2) OR (project = $3 AND git_branch = $4))", + got) + assert.Equal(t, []any{"alpha", "", "alpha", "unknown"}, pb.args) +} + func TestPGTopSessionsUsageRowQueryUsesNarrowScan(t *testing.T) { pb := ¶mBuilder{} query := pgTopSessionsUsageRowQuery(pb, db.UsageFilter{ diff --git a/internal/server/activity_report_test.go b/internal/server/activity_report_test.go index 8e4c72af3..220a04897 100644 --- a/internal/server/activity_report_test.go +++ b/internal/server/activity_report_test.go @@ -328,3 +328,46 @@ func TestActivityReportEndpoint_BadAutomation(t *testing.T) { })) assertStatus(t, w, http.StatusBadRequest) } + +// TestActivityReportEndpoint_GitBranchFilter guards that /activity/report honors +// the git_branch filter (it previously ignored the param). +func TestActivityReportEndpoint_GitBranchFilter(t *testing.T) { + te := setup(t) + seed := []struct { + id, branch, started, ended string + times []string + }{ + {"b1", "main", activityDate + "T10:00:00Z", activityDate + "T10:08:00Z", + []string{activityDate + "T10:00:00Z", activityDate + "T10:02:00Z", + activityDate + "T10:05:00Z", activityDate + "T10:07:00Z"}}, + {"b2", "feature-x", activityDate + "T10:01:00Z", activityDate + "T10:09:00Z", + []string{activityDate + "T10:01:00Z", activityDate + "T10:03:00Z", + activityDate + "T10:06:00Z", activityDate + "T10:08:00Z"}}, + } + for _, e := range seed { + started, ended, branch := e.started, e.ended, e.branch + te.seedSession(t, e.id, "alpha", len(e.times), func(s *db.Session) { + s.GitBranch = branch + s.StartedAt = &started + s.EndedAt = &ended + }) + times := e.times + te.seedMessages(t, e.id, len(times), func(i int, m *db.Message) { + m.Timestamp = times[i] + }) + } + + all := te.get(t, buildPathURL("/api/v1/activity/report", map[string]string{ + "preset": "day", "date": activityDate, "timezone": "UTC", + })) + assertStatus(t, all, http.StatusOK) + assert.Equal(t, 2, decode[activity.Report](t, all).Totals.Sessions) + + filtered := te.get(t, buildPathURL("/api/v1/activity/report", map[string]string{ + "preset": "day", "date": activityDate, "timezone": "UTC", + "git_branch": db.EncodeBranchFilterToken("alpha", "main"), + })) + assertStatus(t, filtered, http.StatusOK) + assert.Equal(t, 1, decode[activity.Report](t, filtered).Totals.Sessions, + "git_branch filter restricts the activity report to alpha/main") +} diff --git a/internal/server/huma_routes_activity.go b/internal/server/huma_routes_activity.go index 4725af96b..c688985ad 100644 --- a/internal/server/huma_routes_activity.go +++ b/internal/server/huma_routes_activity.go @@ -16,15 +16,16 @@ func (s *Server) registerActivityRoutes() { } type activityReportInput struct { - Preset string `query:"preset" enum:"day,week,month,custom" doc:"Range preset"` - Date string `query:"date" format:"date" doc:"Calendar day (YYYY-MM-DD) for presets"` - From string `query:"from" doc:"Range start (RFC3339) for custom ranges"` - To string `query:"to" doc:"Range end (RFC3339) for custom ranges"` - 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"` - Agent string `query:"agent" doc:"Filter by agent"` - Machine string `query:"machine" doc:"Filter by machine"` + Preset string `query:"preset" enum:"day,week,month,custom" doc:"Range preset"` + Date string `query:"date" format:"date" doc:"Calendar day (YYYY-MM-DD) for presets"` + From string `query:"from" doc:"Range start (RFC3339) for custom ranges"` + To string `query:"to" doc:"Range end (RFC3339) for custom ranges"` + 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"` + 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" // drops automated sessions, "automated" drops interactive ones. Empty is // treated as "all"; any other value is rejected. @@ -63,7 +64,8 @@ func (s *Server) humaActivityReport( // analytics which excludes them by default. The automation class is the // caller's choice (default "all" keeps both automated and interactive). f := db.AnalyticsFilter{ - Timezone: tz, Project: in.Project, Agent: in.Agent, Machine: in.Machine, + Timezone: tz, Project: in.Project, GitBranch: in.GitBranch, + Agent: in.Agent, Machine: in.Machine, ExcludeOneShot: false, ExcludeAutomated: excludeAutomated, ExcludeInteractive: excludeInteractive, diff --git a/internal/server/huma_routes_analytics.go b/internal/server/huma_routes_analytics.go index 60f3d6493..bc3bb1e26 100644 --- a/internal/server/huma_routes_analytics.go +++ b/internal/server/huma_routes_analytics.go @@ -39,6 +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"` 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"` @@ -95,6 +96,7 @@ func analyticsFilterFromInput(in AnalyticsFilterInput) (db.AnalyticsFilter, erro To: to, Machine: in.Machine, Project: in.Project, + GitBranch: in.GitBranch, Agent: in.Agent, Model: in.Model, Timezone: tz, diff --git a/internal/server/huma_routes_metadata.go b/internal/server/huma_routes_metadata.go index 31163ff8a..d406285b4 100644 --- a/internal/server/huma_routes_metadata.go +++ b/internal/server/huma_routes_metadata.go @@ -12,6 +12,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, "/agents", "List agents", s.humaListAgents) get(s, group, "/stats", "Get stats", s.humaGetStats) get(s, group, "/version", "Get server version", s.humaGetVersion) @@ -30,6 +31,10 @@ type machinesResponse struct { Machines []string `json:"machines"` } +type branchesResponse struct { + Branches []db.BranchInfo `json:"branches"` +} + type agentsResponse struct { Agents []db.AgentInfo `json:"agents"` } @@ -67,6 +72,17 @@ func (s *Server) humaListMachines( return &jsonOutput[machinesResponse]{Body: machinesResponse{Machines: machines}}, nil } +func (s *Server) humaListBranches( + ctx context.Context, + 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) humaListAgents( ctx context.Context, in *statsInput, diff --git a/internal/server/huma_routes_search.go b/internal/server/huma_routes_search.go index 0d92ea6d2..7b87d9bf7 100644 --- a/internal/server/huma_routes_search.go +++ b/internal/server/huma_routes_search.go @@ -38,6 +38,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"` Agent string `query:"agent" doc:"Filter by agent"` Date string `query:"date" format:"date" doc:"Filter to a single YYYY-MM-DD date"` DateFrom string `query:"date_from" format:"date" doc:"Filter start date"` @@ -108,6 +109,7 @@ func (s *Server) humaSearchContent( Project: in.Project, ExcludeProject: in.ExcludeProject, Machine: in.Machine, + GitBranch: in.GitBranch, Agent: in.Agent, Date: in.Date, DateFrom: in.DateFrom, diff --git a/internal/server/huma_routes_sessions.go b/internal/server/huma_routes_sessions.go index b1cfcae92..1a2a8e49d 100644 --- a/internal/server/huma_routes_sessions.go +++ b/internal/server/huma_routes_sessions.go @@ -60,6 +60,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"` Agent string `query:"agent" doc:"Filter by agent"` Date string `query:"date" format:"date" doc:"Filter to a single YYYY-MM-DD date"` DateFrom string `query:"date_from" format:"date" doc:"Filter start date"` @@ -107,6 +108,7 @@ func (in *sessionFilterInput) listFilter() (service.ListFilter, error) { Project: in.Project, ExcludeProject: in.ExcludeProject, Machine: in.Machine, + GitBranch: in.GitBranch, Agent: in.Agent, Date: in.Date, DateFrom: in.DateFrom, @@ -152,6 +154,7 @@ func (in *sessionFilterInput) dbFilter(includeChildren bool) (db.SessionFilter, Project: in.Project, ExcludeProject: in.ExcludeProject, Machine: in.Machine, + GitBranch: in.GitBranch, Agent: in.Agent, Date: in.Date, DateFrom: in.DateFrom, diff --git a/internal/server/huma_routes_usage.go b/internal/server/huma_routes_usage.go index cee1d0d84..79c5a0135 100644 --- a/internal/server/huma_routes_usage.go +++ b/internal/server/huma_routes_usage.go @@ -25,6 +25,7 @@ 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"` ExcludeProject string `query:"exclude_project" doc:"Exclude a project"` ExcludeAgent string `query:"exclude_agent" doc:"Exclude an agent"` ExcludeModel string `query:"exclude_model" doc:"Exclude a model"` @@ -59,6 +60,7 @@ func usageRequestFromInput(in UsageFilterInput) service.UsageRequest { Agent: in.Agent, Project: in.Project, Machine: in.Machine, + GitBranch: in.GitBranch, ExcludeProject: in.ExcludeProject, ExcludeAgent: in.ExcludeAgent, ExcludeModel: in.ExcludeModel, @@ -161,6 +163,7 @@ func (s *Server) computeUsageComparison( Agent: f.Agent, Project: f.Project, Machine: f.Machine, + GitBranch: f.GitBranch, Model: f.Model, ExcludeProject: f.ExcludeProject, ExcludeAgent: f.ExcludeAgent, diff --git a/internal/server/usage.go b/internal/server/usage.go index d5fb36d4a..e058ff336 100644 --- a/internal/server/usage.go +++ b/internal/server/usage.go @@ -5,8 +5,6 @@ import ( "go.kenn.io/agentsview/internal/service" ) -// Comparison holds the prior-period cost comparison returned by -// GET /api/v1/usage/comparison. type Comparison struct { PriorFrom string `json:"priorFrom"` PriorTo string `json:"priorTo"` @@ -14,7 +12,6 @@ type Comparison struct { DeltaPct float64 `json:"deltaPct"` } -// ProjectTotal holds range-wide token and cost totals per project. type ProjectTotal struct { Project string `json:"project"` InputTokens int `json:"inputTokens"` @@ -24,7 +21,6 @@ type ProjectTotal struct { Cost float64 `json:"cost"` } -// ModelTotal holds range-wide token and cost totals per model. type ModelTotal struct { Model string `json:"model"` InputTokens int `json:"inputTokens"` @@ -34,7 +30,6 @@ type ModelTotal struct { Cost float64 `json:"cost"` } -// AgentTotal holds range-wide token and cost totals per agent. type AgentTotal struct { Agent string `json:"agent"` InputTokens int `json:"inputTokens"` @@ -44,7 +39,16 @@ type AgentTotal struct { Cost float64 `json:"cost"` } -// CacheStats summarizes cache hit/miss for the period. +type BranchTotal struct { + 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 float64 `json:"cost"` +} + type CacheStats struct { CacheReadTokens int `json:"cacheReadTokens"` CacheCreationTokens int `json:"cacheCreationTokens"` @@ -65,6 +69,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"` Comparison *Comparison `json:"comparison,omitempty"` @@ -81,52 +86,69 @@ 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), } } func projectTotalsFromService(in []service.ProjectTotal) []ProjectTotal { - out := make([]ProjectTotal, 0, len(in)) - for _, total := range in { - out = append(out, ProjectTotal{ + out := make([]ProjectTotal, len(in)) + for i, total := range in { + out[i] = ProjectTotal{ Project: total.Project, InputTokens: total.InputTokens, OutputTokens: total.OutputTokens, CacheCreationTokens: total.CacheCreationTokens, CacheReadTokens: total.CacheReadTokens, Cost: total.Cost, - }) + } } return out } func modelTotalsFromService(in []service.ModelTotal) []ModelTotal { - out := make([]ModelTotal, 0, len(in)) - for _, total := range in { - out = append(out, ModelTotal{ + out := make([]ModelTotal, len(in)) + for i, total := range in { + out[i] = ModelTotal{ Model: total.Model, InputTokens: total.InputTokens, OutputTokens: total.OutputTokens, CacheCreationTokens: total.CacheCreationTokens, CacheReadTokens: total.CacheReadTokens, Cost: total.Cost, - }) + } } return out } func agentTotalsFromService(in []service.AgentTotal) []AgentTotal { - out := make([]AgentTotal, 0, len(in)) - for _, total := range in { - out = append(out, AgentTotal{ + out := make([]AgentTotal, len(in)) + for i, total := range in { + out[i] = AgentTotal{ Agent: total.Agent, InputTokens: total.InputTokens, OutputTokens: total.OutputTokens, CacheCreationTokens: total.CacheCreationTokens, CacheReadTokens: total.CacheReadTokens, Cost: total.Cost, - }) + } + } + return out +} + +func branchTotalsFromService(in []service.BranchTotal) []BranchTotal { + out := make([]BranchTotal, len(in)) + for i, total := range in { + out[i] = BranchTotal{ + Project: total.Project, + Branch: total.Branch, + InputTokens: total.InputTokens, + OutputTokens: total.OutputTokens, + CacheCreationTokens: total.CacheCreationTokens, + CacheReadTokens: total.CacheReadTokens, + Cost: total.Cost, + } } return out } diff --git a/internal/server/usage_internal_test.go b/internal/server/usage_internal_test.go index 9c1d5e13c..ca66e46a1 100644 --- a/internal/server/usage_internal_test.go +++ b/internal/server/usage_internal_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "net/url" "testing" "github.com/stretchr/testify/assert" @@ -134,6 +135,19 @@ func TestUsageComparisonScansPriorPeriodOnly(t *testing.T) { assert.Equal(t, 2.0, out.DeltaPct) } +func TestUsageComparisonCopiesGitBranchFilterToPriorPeriod(t *testing.T) { + spy := &usageSummaryCountsSpy{} + s := newRoutedTestServerWithStore(t, spy) + branch := db.EncodeBranchFilterToken("alpha", "main") + + w := serveGet(t, s, + "/api/v1/usage/comparison?"+oneDayUsageRange+"¤t_cost=3&git_branch="+url.QueryEscape(branch)) + assertRecorderStatus(t, w, http.StatusOK) + + require.Len(t, spy.filters, 1) + assert.Equal(t, branch, spy.filters[0].GitBranch) +} + func TestUsageComparisonRequiresCurrentCost(t *testing.T) { spy := &usageSummaryCountsSpy{} s := newRoutedTestServerWithStore(t, spy) diff --git a/internal/service/direct.go b/internal/service/direct.go index 2cc253603..eca075e13 100644 --- a/internal/service/direct.go +++ b/internal/service/direct.go @@ -159,6 +159,7 @@ func listFilterToDB(f ListFilter) db.SessionFilter { Project: f.Project, ExcludeProject: f.ExcludeProject, Machine: f.Machine, + GitBranch: f.GitBranch, Agent: f.Agent, Date: f.Date, DateFrom: f.DateFrom, @@ -586,6 +587,7 @@ func (b *directBackend) SearchContent( Project: req.Project, ExcludeProject: req.ExcludeProject, Machine: req.Machine, + GitBranch: req.GitBranch, Agent: req.Agent, Date: req.Date, DateFrom: req.DateFrom, diff --git a/internal/service/http.go b/internal/service/http.go index ff802fb4f..43a423d0e 100644 --- a/internal/service/http.go +++ b/internal/service/http.go @@ -96,6 +96,7 @@ func filterToQuery(f ListFilter) url.Values { setIfNotEmpty("project", f.Project) setIfNotEmpty("exclude_project", f.ExcludeProject) setIfNotEmpty("machine", f.Machine) + setIfNotEmpty("git_branch", f.GitBranch) setIfNotEmpty("agent", f.Agent) setIfNotEmpty("date", f.Date) setIfNotEmpty("date_from", f.DateFrom) @@ -344,6 +345,7 @@ func (b *httpBackend) SearchContent( "project": req.Project, "exclude_project": req.ExcludeProject, "machine": req.Machine, + "git_branch": req.GitBranch, "agent": req.Agent, "date": req.Date, "date_from": req.DateFrom, @@ -387,6 +389,7 @@ func (b *httpBackend) UsageSummary( "agent": req.Agent, "project": req.Project, "machine": req.Machine, + "git_branch": req.GitBranch, "exclude_project": req.ExcludeProject, "exclude_agent": req.ExcludeAgent, "exclude_model": req.ExcludeModel, diff --git a/internal/service/service.go b/internal/service/service.go index c0a1ad228..795b94b6a 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -111,6 +111,8 @@ type ContentSearchRequest struct { Project, ExcludeProject, Machine, Agent string Date, DateFrom, DateTo, ActiveSince string IncludeChildren, IncludeAutomated, IncludeOneShot bool + // GitBranch is a branchListSep-joined list of opaque (project, branch) tokens (EncodeBranchFilterToken). + GitBranch string Limit int `json:"limit,omitempty"` Cursor int `json:"cursor,omitempty"` @@ -182,6 +184,7 @@ type ListFilter struct { Project string `json:"project,omitempty"` ExcludeProject string `json:"exclude_project,omitempty"` Machine string `json:"machine,omitempty"` + GitBranch string `json:"git_branch,omitempty"` Agent string `json:"agent,omitempty"` Date string `json:"date,omitempty"` DateFrom string `json:"date_from,omitempty"` diff --git a/internal/service/usage.go b/internal/service/usage.go index 1ad12c76e..1c564d3e4 100644 --- a/internal/service/usage.go +++ b/internal/service/usage.go @@ -20,6 +20,7 @@ type UsageRequest struct { Agent string `json:"agent,omitempty"` Project string `json:"project,omitempty"` Machine string `json:"machine,omitempty"` + GitBranch string `json:"git_branch,omitempty"` ExcludeProject string `json:"exclude_project,omitempty"` ExcludeAgent string `json:"exclude_agent,omitempty"` ExcludeModel string `json:"exclude_model,omitempty"` @@ -87,6 +88,7 @@ func BuildUsageFilter(req UsageRequest) (db.UsageFilter, error) { Agent: req.Agent, Project: req.Project, Machine: req.Machine, + GitBranch: req.GitBranch, ExcludeProject: req.ExcludeProject, ExcludeAgent: req.ExcludeAgent, ExcludeModel: req.ExcludeModel, @@ -150,6 +152,17 @@ type AgentTotal struct { Cost float64 `json:"cost"` } +// BranchTotal holds range-wide token and cost totals per (project, branch). +type BranchTotal struct { + 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 float64 `json:"cost"` +} + // CacheStats summarizes cache hit/miss for the period. type CacheStats struct { CacheReadTokens int `json:"cacheReadTokens"` @@ -171,6 +184,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"` } @@ -192,10 +206,12 @@ func buildUsageSummary( out.ProjectTotals = foldProjectTotals(result.Daily) out.ModelTotals = foldModelTotals(result.Daily) out.AgentTotals = foldAgentTotals(result.Daily) + out.BranchTotals = foldBranchTotals(result.Daily) } else { out.ProjectTotals = []ProjectTotal{} out.ModelTotals = []ModelTotal{} out.AgentTotals = []AgentTotal{} + out.BranchTotals = []BranchTotal{} } return out } @@ -293,6 +309,42 @@ func foldAgentTotals(daily []db.DailyUsageEntry) []AgentTotal { return out } +// foldBranchTotals sums daily (project, branch) breakdowns into range-wide +// totals sorted by cost descending. +func foldBranchTotals(daily []db.DailyUsageEntry) []BranchTotal { + type key struct{ project, branch string } + m := make(map[key]*BranchTotal) + for _, d := range daily { + for _, bb := range d.BranchBreakdowns { + k := key{project: bb.Project, branch: bb.Branch} + bt, ok := m[k] + if !ok { + bt = &BranchTotal{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 + bt.Cost += bb.Cost + } + } + out := make([]BranchTotal, 0, len(m)) + for _, v := range m { + out = append(out, *v) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Cost != out[j].Cost { + return out[i].Cost > out[j].Cost + } + if out[i].Project != out[j].Project { + return out[i].Project < out[j].Project + } + return out[i].Branch < out[j].Branch + }) + return out +} + // 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,