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

646 lines
18 KiB
Go

// ABOUTME: `agentsview stats` top-level command — window-scoped
// ABOUTME: workspace analytics emitting the v1 SessionStats JSON schema.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/service"
)
func newStatsCommand() *cobra.Command {
var (
since, until, agent, timezone string
includeProjects, excludeProjects []string
includeOneShot, includeAutomated bool
includeGitOutcomes, includeGHOutcomes bool
)
cmd := &cobra.Command{
Use: "stats",
Short: "Window-scoped workspace analytics (v1 schema)",
GroupID: groupData,
Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
svc, cleanup, err := openStatsService(cmd)
if err != nil {
return err
}
defer cleanup()
// "all" is the human-facing default and echoes through to
// stats.Filters.Agent, but the db layer treats any non-empty
// Agent as a literal filter. Pass "" when the user hasn't
// scoped to a specific agent so the window includes every
// agent's sessions.
agentFilter := agent
if agentFilter == "all" {
agentFilter = ""
}
if includeGHOutcomes {
includeGitOutcomes = true
}
var ghToken string
if includeGHOutcomes {
ghToken = resolveGitHubToken(cmd.Context())
}
stats, err := svc.Stats(cmd.Context(), service.StatsFilter{
ApplyDefaultVisibility: true,
Since: since,
Until: until,
Agent: agentFilter,
IncludeOneShot: includeOneShot,
IncludeAutomated: includeAutomated,
IncludeProjects: includeProjects,
ExcludeProjects: excludeProjects,
Timezone: timezone,
IncludeGitOutcomes: includeGitOutcomes,
IncludeGitHubOutcomes: includeGHOutcomes,
GHToken: ghToken,
})
if err != nil {
return err
}
if outputFormat(cmd) == "json" {
return json.NewEncoder(cmd.OutOrStdout()).Encode(stats)
}
return printStatsHuman(cmd.OutOrStdout(), stats)
},
}
registerFormatFlags(cmd.Flags())
registerStatsFlags(cmd,
&since, &until, &agent, &timezone,
&includeOneShot, &includeAutomated,
&includeProjects, &excludeProjects,
&includeGitOutcomes, &includeGHOutcomes,
)
return cmd
}
// resolveGitHubToken returns a token for `gh search` aggregation,
// preferring AGENTSVIEW_GITHUB_TOKEN (an app-scoped secret) and
// falling back to whatever `gh auth token` prints. Returns "" when
// neither source yields a token; the stats pipeline interprets that
// as "skip PR aggregation" rather than an error.
//
// The flag-based path was removed so the token never appears in
// argv (visible to other local users via ps/proc/cmdline, and
// commonly captured by CI logs and crash reporters).
func resolveGitHubToken(ctx context.Context) string {
if t := strings.TrimSpace(os.Getenv("AGENTSVIEW_GITHUB_TOKEN")); t != "" {
return t
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
cmd := exec.CommandContext(cctx, "gh", "auth", "token")
cmd.Stderr = io.Discard
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// registerStatsFlags wires the `agentsview stats` flags onto cmd. Split
// out so the long flag-registration block doesn't pad newStatsCommand
// past the 100-line cap.
func registerStatsFlags(
cmd *cobra.Command,
since, until, agent, timezone *string,
includeOneShot, includeAutomated *bool,
includeProjects, excludeProjects *[]string,
includeGitOutcomes, includeGHOutcomes *bool,
) {
f := cmd.Flags()
f.StringVar(since, "since", "28d",
"Start of window (duration like 28d, or YYYY-MM-DD)")
f.StringVar(until, "until", "",
"End of window (YYYY-MM-DD; default: now)")
f.StringVar(agent, "agent", "all",
"Filter by agent (claude, codex, cursor, ... or 'all')")
f.BoolVar(includeOneShot, "include-one-shot", false,
"Include one-shot sessions (excluded by default)")
f.BoolVar(includeAutomated, "include-automated", false,
"Include automated sessions (excluded by default)")
f.StringArrayVar(includeProjects, "include-project", nil,
"Restrict to these projects (repeatable)")
f.StringArrayVar(excludeProjects, "exclude-project", nil,
"Exclude these projects (repeatable)")
f.StringVar(timezone, "timezone", "",
"Timezone for temporal (default: local system timezone)")
f.BoolVar(includeGitOutcomes, "include-git-outcomes", false,
"Include git-derived outcome stats (commits, LOC, files)")
f.BoolVar(includeGHOutcomes, "include-github-outcomes", false,
"Include GitHub PR outcome stats via gh (implies --include-git-outcomes)")
}
func openStatsService(
cmd *cobra.Command,
) (service.SessionService, func(), error) {
cfg, err := config.LoadPFlags(cmd.Flags())
if err != nil {
return nil, nil, fmt.Errorf("loading config: %w", err)
}
tr, err := ensureTransport(&cfg, transportIntentRead, 0)
if err != nil {
return nil, nil, err
}
if tr.Mode == transportHTTP && tr.ReadOnly {
d, err := openReadOnlyDB(cfg)
if err != nil {
return nil, nil, fmt.Errorf("opening db: %w", err)
}
return service.NewDirectBackend(d, nil), func() { d.Close() }, nil
}
return newService(cfg, tr)
}
// printStatsHuman renders a human-readable summary of a SessionStats
// payload. Sections driven by nil-pointer fields are omitted when
// absent, and a zero-session window prints a short "no sessions"
// message instead of rows of zeros.
//
// Each helper returns the first write error it encountered so a broken
// pipe or short write surfaces from the command instead of silently
// truncating the output. The first error short-circuits rendering.
func printStatsHuman(w io.Writer, stats *service.SessionStats) error {
ew := &errWriter{w: w}
printHeader(ew, stats)
if stats.Totals.SessionsAll == 0 {
fmt.Fprintln(ew, "Totals")
fmt.Fprintln(ew, " (no sessions in window)")
if stats.CodeAttribution != nil {
printCodeAttribution(ew, stats.CodeAttribution)
}
return ew.err
}
printTotals(ew, stats)
printArchetypes(ew, stats)
printSessionShape(ew, stats)
printVelocity(ew, stats)
printToolMix(ew, stats)
printModelMix(ew, stats)
printAgentPortfolio(ew, stats)
if stats.CacheEconomics != nil {
printCacheEconomics(ew, stats.CacheEconomics)
}
if stats.Adoption != nil {
printAdoption(ew, stats.Adoption)
}
printTemporal(ew, stats)
if stats.OutcomeStats != nil {
printOutcomeStats(ew, stats.OutcomeStats)
}
if stats.Outcomes != nil {
printOutcomes(ew, stats.Outcomes)
}
if stats.CodeAttribution != nil {
printCodeAttribution(ew, stats.CodeAttribution)
}
return ew.err
}
// errWriter wraps an io.Writer and remembers the first write error.
// Subsequent writes become no-ops so the rest of the formatter can run
// to completion without re-checking err on every Fprintf, and tabwriter
// flushes propagate failures the same way.
type errWriter struct {
w io.Writer
err error
}
func (e *errWriter) Write(p []byte) (int, error) {
if e.err != nil {
return len(p), nil
}
n, err := e.w.Write(p)
if err != nil {
e.err = err
}
return n, err
}
func printHeader(w io.Writer, s *service.SessionStats) {
fmt.Fprintf(w, "Session window: %s -> %s (%d days)\n",
s.Window.Since, s.Window.Until, s.Window.Days)
agent := s.Filters.Agent
if agent == "" {
agent = "all"
}
fmt.Fprintf(w, "Agent filter: %s\n", agent)
fmt.Fprintf(w, "Timezone: %s\n", s.Filters.Timezone)
if len(s.Filters.ProjectsIncluded) > 0 {
fmt.Fprintf(w, "Include: %s\n",
strings.Join(s.Filters.ProjectsIncluded, ", "))
}
if len(s.Filters.ProjectsExcluded) > 0 {
fmt.Fprintf(w, "Exclude: %s\n",
strings.Join(s.Filters.ProjectsExcluded, ", "))
}
fmt.Fprintln(w)
}
func printTotals(w io.Writer, s *service.SessionStats) {
fmt.Fprintln(w, "Totals")
fmt.Fprintf(w,
" Sessions: %s (human %s, automation %s, subagent %s)\n",
fmtInt(s.Totals.SessionsAll),
fmtInt(s.Totals.SessionsHuman),
fmtInt(s.Totals.SessionsAutomation),
fmtInt(s.Totals.SessionsSubagent))
fmt.Fprintf(w, " Messages: %s (user %s)\n",
fmtInt(s.Totals.MessagesTotal),
fmtInt(s.Totals.UserMessagesTotal))
fmt.Fprintln(w)
}
func printArchetypes(w io.Writer, s *service.SessionStats) {
a := s.Archetypes
fmt.Fprintln(w, "Archetypes")
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
rows := []struct {
name string
count int
}{
{"Automation", a.Automation},
{"Quick", a.Quick},
{"Standard", a.Standard},
{"Deep", a.Deep},
{"Marathon", a.Marathon},
}
for _, r := range rows {
fmt.Fprintf(tw, " %s\t%s\n", r.name, fmtInt(r.count))
}
tw.Flush()
if a.Primary != "" {
fmt.Fprintf(w, " Primary: %s (primary_human: %s)\n",
a.Primary, a.PrimaryHuman)
}
fmt.Fprintln(w)
}
func printSessionShape(w io.Writer, s *service.SessionStats) {
d := s.Distributions
fmt.Fprintln(w, "Session shape (means)")
fmt.Fprintf(w, " Duration (min): mean=%s (scope_all); mean=%s (scope_human)\n",
fmtFloat(d.DurationMinutes.ScopeAll.Mean),
fmtFloat(d.DurationMinutes.ScopeHuman.Mean))
fmt.Fprintf(w, " User messages: mean=%s (scope_all); mean=%s (scope_human)\n",
fmtFloat(d.UserMessages.ScopeAll.Mean),
fmtFloat(d.UserMessages.ScopeHuman.Mean))
fmt.Fprintf(w, " Peak context (tokens): mean=%s (scope_all); null_count=%s\n",
fmtInt64(int64(d.PeakContextTokens.ScopeAll.Mean+0.5)),
fmtInt(d.PeakContextTokens.NullCount))
fmt.Fprintf(w, " Tools per turn: mean=%s (scope_all); mean=%s (scope_human)\n",
fmtFloat(d.ToolsPerTurn.ScopeAll.Mean),
fmtFloat(d.ToolsPerTurn.ScopeHuman.Mean))
fmt.Fprintln(w)
}
func printVelocity(w io.Writer, s *service.SessionStats) {
v := s.Velocity
fmt.Fprintln(w, "Velocity")
fmt.Fprintf(w, " Turn cycle (s): p50=%s p90=%s mean=%s\n",
fmtFloat(v.TurnCycleSeconds.P50),
fmtFloat(v.TurnCycleSeconds.P90),
fmtFloat(v.TurnCycleSeconds.Mean))
fmt.Fprintf(w, " First response (s): p50=%s p90=%s mean=%s\n",
fmtFloat(v.FirstResponseSeconds.P50),
fmtFloat(v.FirstResponseSeconds.P90),
fmtFloat(v.FirstResponseSeconds.Mean))
fmt.Fprintf(w, " Messages per hour: %s\n",
fmtFloat(v.MessagesPerActiveHour))
fmt.Fprintln(w)
}
func printToolMix(w io.Writer, s *service.SessionStats) {
m := s.ToolMix
if m.TotalCalls == 0 && len(m.ByCategory) == 0 {
return
}
fmt.Fprintln(w, "Tool mix (top 5 categories)")
entries := sortedIntMap(m.ByCategory)
top := entries
if len(top) > 5 {
top = top[:5]
}
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
for _, e := range top {
name := e.key
if name == "" {
name = "(uncategorized)"
}
fmt.Fprintf(tw, " %s\t%s\n", name, fmtInt(e.val))
}
tw.Flush()
fmt.Fprintf(w, " (total tool calls: %s)\n", fmtInt(m.TotalCalls))
fmt.Fprintln(w)
}
func printModelMix(w io.Writer, s *service.SessionStats) {
m := s.ModelMix
if len(m.ByTokens) == 0 {
return
}
fmt.Fprintln(w, "Model mix (tokens)")
entries := sortedInt64Map(m.ByTokens)
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
for _, e := range entries {
fmt.Fprintf(tw, " %s\t%s\n", e.key, fmtInt64(e.val))
}
tw.Flush()
fmt.Fprintln(w)
}
func printAgentPortfolio(w io.Writer, s *service.SessionStats) {
p := s.AgentPortfolio
if len(p.BySessions) == 0 {
return
}
fmt.Fprintln(w, "Agent portfolio")
entries := sortedIntMap(p.BySessions)
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
for _, e := range entries {
agent := e.key
sessions := e.val
tokens := p.ByTokens[agent]
msgs := p.ByMessages[agent]
marker := ""
if agent == p.Primary {
marker = " [primary]"
}
fmt.Fprintf(tw, " %s\t%s sessions\t%s tokens\t%s msgs%s\n",
agent,
fmtInt(sessions),
fmtInt64(tokens),
fmtInt(msgs),
marker)
}
tw.Flush()
fmt.Fprintln(w)
}
func printCacheEconomics(w io.Writer, c *db.StatsCacheEconomics) {
fmt.Fprintln(w, "Cache economics (claude-only)")
fmt.Fprintf(w, " Overall hit ratio: %.2f\n",
c.CacheHitRatio.Overall)
fmt.Fprintf(w, " $ spent: $%.2f\n", c.DollarsSpent)
fmt.Fprintf(w, " $ saved vs uncached: $%.2f\n",
c.DollarsSavedVsUncached)
fmt.Fprintln(w)
}
func printAdoption(w io.Writer, a *db.StatsAdoption) {
fmt.Fprintln(w, "Adoption (claude-only)")
fmt.Fprintf(w, " Plan mode rate: %.0f%%\n",
a.PlanModeRate*100)
fmt.Fprintf(w, " Subagents/session: %s\n",
fmtFloat(a.SubagentsPerSession))
fmt.Fprintf(w, " Distinct skills: %s\n",
fmtInt(a.DistinctSkills))
fmt.Fprintln(w)
}
func printTemporal(w io.Writer, s *service.SessionStats) {
t := s.Temporal
active := 0
for _, h := range t.HourlyUTC {
if h.Sessions > 0 || h.UserMessages > 0 {
active++
}
}
if active == 0 && t.ReporterTimezone == "" {
return
}
fmt.Fprintln(w, "Temporal")
fmt.Fprintf(w, " Hours with activity: %s\n", fmtInt(active))
if t.ReporterTimezone != "" {
fmt.Fprintf(w, " Reporter timezone: %s\n", t.ReporterTimezone)
}
fmt.Fprintln(w)
}
func printOutcomeStats(w io.Writer, o *db.StatsOutcomeStats) {
fmt.Fprintln(w, "Outcome stats (git)")
fmt.Fprintf(w, " Repos active: %s\n", fmtInt(o.ReposActive))
fmt.Fprintf(w, " Commits: %s\n", fmtInt(o.Commits))
fmt.Fprintf(w, " LOC added/removed: +%s / -%s\n",
fmtInt(o.LOCAdded), fmtInt(o.LOCRemoved))
fmt.Fprintf(w, " Files changed: %s\n", fmtInt(o.FilesChanged))
if o.PRsOpened != nil {
fmt.Fprintf(w, " PRs opened: %s\n", fmtInt(*o.PRsOpened))
}
if o.PRsMerged != nil {
fmt.Fprintf(w, " PRs merged: %s\n", fmtInt(*o.PRsMerged))
}
fmt.Fprintln(w)
}
func printOutcomes(w io.Writer, o *db.StatsOutcomes) {
fmt.Fprintln(w, "Outcomes")
fmt.Fprintf(w, " Success / Failure / Unknown: %s / %s / %s\n",
fmtInt(o.Success), fmtInt(o.Failure), fmtInt(o.Unknown))
if len(o.GradeDistribution) > 0 {
fmt.Fprintf(w, " Grade distribution: %s\n",
formatGrades(o.GradeDistribution))
}
fmt.Fprintf(w, " Tool retry rate: %.1f%%\n",
o.ToolRetryRate*100)
fmt.Fprintf(w, " Compactions/session: %s\n",
fmtFloat(o.CompactionsPerSession))
fmt.Fprintf(w, " Avg edit churn: %s\n",
fmtFloat(o.AvgEditChurn))
fmt.Fprintln(w)
}
func printCodeAttribution(w io.Writer, c *db.CodeAttribution) {
fmt.Fprintln(w, "Code attribution")
for _, source := range c.Sources {
printCodeAttributionSource(w, source)
}
fmt.Fprintln(w)
}
func printCodeAttributionSource(w io.Writer, s db.CodeAttributionSource) {
fmt.Fprintf(w, " Source: %s\n", s.Provider)
if s.Status != "" {
fmt.Fprintf(w, " Status: %s\n", s.Status)
}
if s.Scope != "" {
fmt.Fprintf(w, " Scope: %s\n", s.Scope)
}
for _, warning := range s.Warnings {
fmt.Fprintf(w, " Warning: %s\n", warning)
}
switch s.Status {
case "unavailable", "error", "unsupported_filter":
return
case "empty":
fmt.Fprintln(w, " Records: none in window")
return
}
if s.Provider == "cursor" && s.Metrics != nil {
printCursorAttributionMetrics(w, s.Metrics)
}
}
func printCursorAttributionMetrics(w io.Writer, c *db.CursorAttributionMetrics) {
fmt.Fprintf(w, " Scored commits: %s\n",
fmtInt64(c.ScoredCommits))
fmt.Fprintf(w, " Lines added/deleted: %s / %s\n",
fmtInt64(c.LinesAdded), fmtInt64(c.LinesDeleted))
fmt.Fprintf(w, " Tab lines: +%s / -%s\n",
fmtInt64(c.TabLinesAdded), fmtInt64(c.TabLinesDeleted))
fmt.Fprintf(w, " Composer lines: +%s / -%s\n",
fmtInt64(c.ComposerLinesAdded), fmtInt64(c.ComposerLinesDeleted))
fmt.Fprintf(w, " Human lines: +%s / -%s\n",
fmtInt64(c.HumanLinesAdded), fmtInt64(c.HumanLinesDeleted))
fmt.Fprintf(w, " Blank lines: +%s / -%s\n",
fmtInt64(c.BlankLinesAdded), fmtInt64(c.BlankLinesDeleted))
fmt.Fprintf(w, " AI-authored pct: %.1f%%\n",
c.AIAuthoredPct*100)
if len(c.ConversationCounts) > 0 {
fmt.Fprintln(w, " Conversation counts")
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
for _, entry := range c.ConversationCounts {
model := entry.Model
if model == "" {
model = "(empty)"
}
mode := entry.Mode
if mode == "" {
mode = "(empty)"
}
fmt.Fprintf(tw, " %s / %s\t%s\n",
model, mode, fmtInt64(entry.Count))
}
tw.Flush()
}
}
// formatGrades renders a grade histogram in canonical A..F order so
// two runs on the same data produce identical text.
func formatGrades(g map[string]int) string {
order := []string{"A", "B", "C", "D", "F"}
seen := map[string]bool{}
parts := make([]string, 0, len(g))
for _, k := range order {
if v, ok := g[k]; ok {
parts = append(parts, fmt.Sprintf("%s=%s", k, fmtInt(v)))
seen[k] = true
}
}
// Append any extra keys we didn't know about, sorted, for safety.
extras := make([]string, 0)
for k := range g {
if !seen[k] {
extras = append(extras, k)
}
}
sort.Strings(extras)
for _, k := range extras {
parts = append(parts, fmt.Sprintf("%s=%s", k, fmtInt(g[k])))
}
return strings.Join(parts, " ")
}
// fmtInt formats an integer with ASCII thousands separators.
func fmtInt(n int) string {
return fmtInt64(int64(n))
}
// fmtInt64 is the int64 variant of fmtInt.
func fmtInt64(n int64) string {
neg := n < 0
if neg {
n = -n
}
s := strconv.FormatInt(n, 10)
if len(s) <= 3 {
if neg {
return "-" + s
}
return s
}
var b strings.Builder
if neg {
b.WriteByte('-')
}
// Compute the length of the leading group (1-3 digits).
lead := len(s) % 3
if lead == 0 {
lead = 3
}
b.WriteString(s[:lead])
for i := lead; i < len(s); i += 3 {
b.WriteByte(',')
b.WriteString(s[i : i+3])
}
return b.String()
}
// fmtFloat renders a float with one decimal place, matching the style
// of the target output. Zero renders as "0".
func fmtFloat(f float64) string {
if f == 0 {
return "0"
}
return strconv.FormatFloat(f, 'f', 1, 64)
}
// kvInt is a sortable (key, int) pair used when ordering maps for
// deterministic, human-friendly output (largest first, ties broken
// alphabetically).
type kvInt struct {
key string
val int
}
// kvInt64 is the int64 variant of kvInt.
type kvInt64 struct {
key string
val int64
}
func sortedIntMap(m map[string]int) []kvInt {
out := make([]kvInt, 0, len(m))
for k, v := range m {
out = append(out, kvInt{k, v})
}
sort.Slice(out, func(i, j int) bool {
if out[i].val != out[j].val {
return out[i].val > out[j].val
}
return out[i].key < out[j].key
})
return out
}
func sortedInt64Map(m map[string]int64) []kvInt64 {
out := make([]kvInt64, 0, len(m))
for k, v := range m {
out = append(out, kvInt64{k, v})
}
sort.Slice(out, func(i, j int) bool {
if out[i].val != out[j].val {
return out[i].val > out[j].val
}
return out[i].key < out[j].key
})
return out
}