Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ jobs:

- name: Install docs check tools
run: |
# The runner image's Microsoft apt repos intermittently fail
# signature validation and abort `apt-get update`; ripgrep comes
# from the Ubuntu archive, so drop them rather than fail on them.
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list \
/etc/apt/sources.list.d/microsoft-prod.sources \
/etc/apt/sources.list.d/azure-cli.list \
/etc/apt/sources.list.d/azure-cli.sources
sudo apt-get update
sudo apt-get install -y ripgrep

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ __pycache__/
.cache
.gomodcache/
.gopath/
# per-worktree golangci-lint cache (see GOLANGCI_LINT_CACHE in Makefile)
/.golangci-cache/

# Docs build and hydrated assets
docs/.venv/
Expand Down
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ GOLANGCI_LINT_VERSION ?= v2.11.4
GOLANGCI_LINT_CACHE ?= $(CURDIR)/.golangci-cache
export GOLANGCI_LINT_CACHE
CUSTOM_GCL := ./custom-gcl
# Isolate golangci-lint's cache per checkout. Sibling worktrees share the
# default cache (~/Library/Caches/golangci-lint), and cached suggested-fixes
# resolve through worktree-relative paths, so `run --fix` can apply byte
# offsets computed against another checkout's version of a file and corrupt
# it in place.
GOLANGCI_LINT_CACHE ?= $(CURDIR)/.golangci-cache
export GOLANGCI_LINT_CACHE
PRICING_SNAPSHOT_FILE := internal/pricing/snapshot/litellm_snapshot.json.gz

# sqlite-vec's cgo bindings #include "sqlite3.h". Without an override the
Expand Down
47 changes: 42 additions & 5 deletions cmd/agentsview/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type ActivityReportConfig struct {
Timezone string
Bucket string
Project string
Branch string
Agent string
Machine string
JSON bool
Expand All @@ -39,6 +40,10 @@ var activityReportNow = time.Now
// runActivityReport syncs, resolves the range, runs the report, and prints it.
func runActivityReport(cfg ActivityReportConfig) {
ctx := context.Background()
if _, err := branchFilterToken(cfg.Project, cfg.Branch); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
backend, cleanup, err := resolveArchiveQueryBackend(ctx, archiveQueryPolicy{
Offline: cfg.Offline,
NoSync: cfg.NoSync,
Expand Down Expand Up @@ -92,6 +97,11 @@ func fetchHTTPActivityReport(
setIfNotEmpty("project", cfg.Project)
setIfNotEmpty("agent", cfg.Agent)
setIfNotEmpty("machine", cfg.Machine)
gitBranch, err := branchFilterToken(cfg.Project, cfg.Branch)
if err != nil {
return activity.Report{}, err
}
setIfNotEmpty("git_branch", gitBranch)

endpoint := strings.TrimSuffix(tr.URL, "/") +
"/api/v1/activity/report?" + q.Encode()
Expand Down Expand Up @@ -165,9 +175,14 @@ func resolveActivityReport(
return activity.Report{}, err
}

gitBranch, err := branchFilterToken(cfg.Project, cfg.Branch)
if err != nil {
return activity.Report{}, err
}
f := db.AnalyticsFilter{
Timezone: tz,
Project: cfg.Project,
GitBranch: gitBranch,
Agent: cfg.Agent,
Machine: cfg.Machine,
ExcludeOneShot: false,
Expand Down Expand Up @@ -211,6 +226,7 @@ func printActivityReport(r activity.Report) {
printKeyMinutes("By project", r.ByProject)
printKeyMinutes("By model", r.ByModel)
printKeyMinutes("By agent", r.ByAgent)
printBranchKeyMinutes("By branch", r.ByBranch)
printActivitySessions(r.BySession)
}

Expand Down Expand Up @@ -243,14 +259,37 @@ func printKeyMinutes(label string, rows []activity.KeyMinutes) {
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
for _, row := range topKeyMinutes(rows, 5) {
for _, row := range firstN(rows, 5) {
fmt.Fprintf(w, " %s\t%.1f min\n",
sanitizeTerminal(row.Key), row.AgentMinutes)
}
w.Flush()
fmt.Println()
}

func printBranchKeyMinutes(label string, rows []activity.BranchKeyMinutes) {
fmt.Printf("%s (top 5):\n", label)
if len(rows) == 0 {
fmt.Println(" (none)")
fmt.Println()
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
for _, row := range firstN(rows, 5) {
branch := row.Branch
if branch == "" {
branch = "(no branch)"
}
key := branch
if row.Project != "" {
key = row.Project + "/" + branch
}
fmt.Fprintf(w, " %s\t%.1f min\n", sanitizeTerminal(key), row.AgentMinutes)
}
w.Flush()
fmt.Println()
}

// printActivitySessions prints the top 5 sessions by appearance order.
func printActivitySessions(rows []activity.SessionRow) {
fmt.Println("Top sessions (top 5):")
Expand All @@ -260,8 +299,7 @@ func printActivitySessions(rows []activity.SessionRow) {
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, " TITLE\tPROJECT\tAGENT\tMINUTES\tCOST")
limit := min(len(rows), 5)
for _, s := range rows[:limit] {
for _, s := range firstN(rows, 5) {
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n",
sanitizeTerminal(s.Title), sanitizeTerminal(s.Project),
sanitizeTerminal(s.Agent),
Expand All @@ -271,8 +309,7 @@ func printActivitySessions(rows []activity.SessionRow) {
w.Flush()
}

// topKeyMinutes returns the first n rows of rows (already sorted by the query).
func topKeyMinutes(rows []activity.KeyMinutes, n int) []activity.KeyMinutes {
func firstN[T any](rows []T, n int) []T {
return rows[:min(len(rows), n)]
}

Expand Down
9 changes: 9 additions & 0 deletions cmd/agentsview/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ const (

const dataVersionTooNewExitCode = 3

func branchFilterToken(project, branch string) (string, error) {
tok, err := db.BranchFilterToken(project, branch)
if errors.Is(err, db.ErrBranchWithoutProject) {
return "", errors.New("--branch requires --project")
}
return tok, err
}

type cliExitError struct {
code int
err error
Expand Down Expand Up @@ -580,6 +588,7 @@ func newActivityReportCommand() *cobra.Command {
cmd.Flags().StringVar(&cfg.Timezone, "timezone", "", "IANA timezone for range bucketing")
cmd.Flags().StringVar(&cfg.Bucket, "bucket", "", "Bucket size: 5m, 15m, 1h, 1d, 1w")
cmd.Flags().StringVar(&cfg.Project, "project", "", "Filter by project")
cmd.Flags().StringVar(&cfg.Branch, "branch", "", "Filter by git branch name (requires --project)")
cmd.Flags().StringVar(&cfg.Agent, "agent", "", "Filter by agent name")
cmd.Flags().StringVar(&cfg.Machine, "machine", "", "Filter by machine name")
registerFormatFlags(cmd.Flags())
Expand Down
28 changes: 28 additions & 0 deletions cmd/agentsview/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,31 @@ func TestSyncHelpMentionsConfiguredHosts(t *testing.T) {
assert.Contains(t, help, want, "sync help missing %q", want)
}
}

func TestBranchFilterToken(t *testing.T) {
tests := []struct {
name string
project string
branch string
wantErr bool
want string
}{
{name: "empty branch yields no token", project: "proj", branch: ""},
{name: "branch without project errors", project: "", branch: "main", wantErr: true},
{
name: "project and branch encode a token", project: "proj", branch: "main",
want: db.EncodeBranchFilterToken("proj", "main"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tok, err := branchFilterToken(tt.project, tt.branch)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, tok)
})
}
}
3 changes: 3 additions & 0 deletions cmd/agentsview/session_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ func printSessionDetailHuman(w io.Writer, s *service.SessionDetail) error {
fmt.Fprintf(w, "%s %s\n", label("ID"), sanitizeTerminal(s.ID))
fmt.Fprintf(w, "%s %s\n", label("Name"), sanitizeTerminal(name))
fmt.Fprintf(w, "%s %s\n", label("Project"), sanitizeTerminal(s.Project))
if s.GitBranch != "" {
fmt.Fprintf(w, "%s %s\n", label("Branch"), sanitizeTerminal(s.GitBranch))
}
fmt.Fprintf(w, "%s %s\n", label("Agent"), sanitizeTerminal(s.Agent))
fmt.Fprintf(w, "%s %s\n", label("Machine"), sanitizeTerminal(s.Machine))
fmt.Fprintf(w, "%s %s\n",
Expand Down
8 changes: 8 additions & 0 deletions cmd/agentsview/session_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
func newSessionListCommand() *cobra.Command {
var (
project, excludeProject, machine, agent string
branch string
date, dateFrom, dateTo, activeSince string
since string
minMessages, maxMessages int
Expand Down Expand Up @@ -47,6 +48,10 @@ func newSessionListCommand() *cobra.Command {
}
activeSince = resolvedActiveSince

gitBranch, err := branchFilterToken(project, branch)
if err != nil {
return err
}
svc, cleanup, err := resolveService(cmd)
if err != nil {
return err
Expand All @@ -57,6 +62,7 @@ func newSessionListCommand() *cobra.Command {
Project: project,
ExcludeProject: excludeProject,
Machine: machine,
GitBranch: gitBranch,
Agent: agent,
Date: date,
DateFrom: dateFrom,
Expand Down Expand Up @@ -141,6 +147,8 @@ func newSessionListCommand() *cobra.Command {
"Exclude sessions from the given project")
flags.StringVar(&machine, "machine", "",
"Filter by machine name")
flags.StringVar(&branch, "branch", "",
"Filter by git branch name (requires --project)")
flags.StringVar(&agent, "agent", "",
"Filter by agent (claude, codex, cursor, ...)")
flags.StringVar(&date, "date", "",
Expand Down
9 changes: 8 additions & 1 deletion cmd/agentsview/session_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ func newSessionSearchCommand() *cobra.Command {
in, scope string
excludeSystem, reveal bool
project, excludeProject, agent string
machine, date, dateFrom, dateTo string
machine, branch string
date, dateFrom, dateTo string
activeSince, since string
includeChildren, includeAutomated bool
includeOneShot bool
Expand Down Expand Up @@ -53,6 +54,10 @@ func newSessionSearchCommand() *cobra.Command {
if err != nil {
return err
}
gitBranch, err := branchFilterToken(project, branch)
if err != nil {
return err
}
svc, cleanup, err := resolveService(cmd)
if err != nil {
return err
Expand All @@ -68,6 +73,7 @@ func newSessionSearchCommand() *cobra.Command {
Project: project,
ExcludeProject: excludeProject,
Machine: machine,
GitBranch: gitBranch,
Agent: agent,
Date: date,
DateFrom: dateFrom,
Expand Down Expand Up @@ -110,6 +116,7 @@ func newSessionSearchCommand() *cobra.Command {
flags.StringVar(&project, "project", "", "Filter by project name")
flags.StringVar(&excludeProject, "exclude-project", "", "Exclude project")
flags.StringVar(&machine, "machine", "", "Filter by machine")
flags.StringVar(&branch, "branch", "", "Filter by git branch name (requires --project)")
flags.StringVar(&agent, "agent", "", "Filter by agent")
flags.StringVar(&date, "date", "", "Sessions active on YYYY-MM-DD")
flags.StringVar(&dateFrom, "date-from", "", "Sessions active on or after YYYY-MM-DD")
Expand Down
8 changes: 4 additions & 4 deletions cmd/benchgate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,10 +410,10 @@ func render(r results) (string, int) {
return b.String(), 0
}

// renderSyntax reports unparseable result lines in one capture. A
// benchmark whose result line is corrupted (e.g. by interleaved log
// output) parses on neither side and would otherwise vanish from
// the gate without a trace.
// renderSyntax reports unparseable result lines in one capture
// (e.g. log output interleaved into a result line). Candidate-side
// corruption is fatal in render; baseline-side corruption only drops
// the affected benchmark to the missing-baseline not-gated path.
func renderSyntax(b *strings.Builder, side string, errs []string) {
if len(errs) == 0 {
return
Expand Down
26 changes: 14 additions & 12 deletions cmd/testfixture/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,31 +24,32 @@ type sessionSpec struct {
parentSessionID string
relationshipType string
terminationStatus string
gitBranch string
}

var specs = []sessionSpec{
{"project-alpha", "small-2", 2, 2, "", "", ""},
{"project-alpha", "small-5", 5, 3, "", "", ""},
{"project-alpha", "small-2", 2, 2, "", "", "", "main"},
{"project-alpha", "small-5", 5, 3, "", "", "", "feature/login"},
// One unclean session for e2e termination tests.
{"project-beta", "mixed-content-7", 7, 3, "", "",
"tool_call_pending"},
{"project-beta", "medium-8", 8, 4, "", "", ""},
{"project-beta", "medium-100", 100, 50, "", "", ""},
{"project-gamma", "large-200", 200, 100, "", "", ""},
{"project-gamma", "large-1500", 1500, 750, "", "", ""},
{"project-delta", "xlarge-5500", 5500, 2750, "", "", ""},
"tool_call_pending", "main"},
{"project-beta", "medium-8", 8, 4, "", "", "", "fix/crash"},
{"project-beta", "medium-100", 100, 50, "", "", "", "main"},
{"project-gamma", "large-200", 200, 100, "", "", "", "release/v2"},
{"project-gamma", "large-1500", 1500, 750, "", "", "", ""},
{"project-delta", "xlarge-5500", 5500, 2750, "", "", "", "main"},

// Sub-agent and fork sessions: must NOT appear in session
// list, stats, or analytics summary counts.
{"project-alpha", "subagent-1", 12, 6,
"test-session-small-5", "subagent", ""},
"test-session-small-5", "subagent", "", "feature/login"},
{"project-alpha", "subagent-2", 8, 4,
"test-session-small-5", "subagent", ""},
"test-session-small-5", "subagent", "", "feature/login"},
{"project-beta", "fork-1", 15, 7,
"test-session-medium-8", "fork", ""},
"test-session-medium-8", "fork", "", "fix/crash"},

// Empty session (0 messages): must also be excluded.
{"project-gamma", "empty-0", 0, 0, "", "", ""},
{"project-gamma", "empty-0", 0, 0, "", "", "", ""},
}

func main() {
Expand Down Expand Up @@ -244,6 +245,7 @@ func createSessionFixture(
Project: spec.project,
Machine: "test-machine",
Agent: "claude",
GitBranch: spec.gitBranch,
StartedAt: new(startedAt.Format(time.RFC3339Nano)),
EndedAt: new(endedAt.Format(time.RFC3339Nano)),
MessageCount: spec.msgCount,
Expand Down
Loading
Loading