Skip to content

Commit 76dd4d5

Browse files
committed
feat(activity): add Activity reporting dashboard
Add a time-windowed Activity view that summarizes agent sessions: summary cards, a concurrency timeline, breakdowns by project, model, and agent with a toggle between agent-minutes and cost, a sessions table, and generated insights. Range controls move between time windows. Split activity into automated and interactive segments, building on the existing automated-session detection, so roborev CI and other automated runs are measured separately from interactive work. Implement the activity report query across SQLite, PostgreSQL, and DuckDB with matching shape and ordering, exposed through a huma OpenAPI route and an activity CLI subcommand. Attribute CI worktree sessions (ci-worktrees/<repo>/...) to their owning repository instead of the per-run scratch worktree name, so CI reviews group under the repo in project breakdowns.
1 parent 99a23c0 commit 76dd4d5

85 files changed

Lines changed: 10131 additions & 51 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ Instructions for autonomous coding agents working in this repository.
1010

1111
## Required Git Rules
1212

13-
1. Commit every turn.
13+
1. Commit every turn that changes tracked files.
14+
1. Do not make empty commits. If a turn is read-only or only changes ignored
15+
files, state that no commit was made.
1416
1. Do not amend commits.
1517
1. Do not change branches without explicit user permission.
1618

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,11 +245,11 @@ test-postgres: ensure-embed-dir postgres-up
245245
@echo "Waiting for postgres to be ready..."
246246
@sleep 2
247247
TEST_PG_URL="postgres://agentsview_test:agentsview_test_password@localhost:5433/agentsview_test?sslmode=disable" \
248-
CGO_ENABLED=1 go test -tags "fts5,pgtest" -v ./internal/postgres/... -count=1
248+
CGO_ENABLED=1 go test -tags "fts5,pgtest" -v ./internal/postgres/... ./internal/activity/... -count=1
249249

250250
# PostgreSQL integration tests for CI (postgres already running as service)
251251
test-postgres-ci: ensure-embed-dir
252-
CGO_ENABLED=1 go test -tags "fts5,pgtest" -v ./internal/postgres/... -count=1
252+
CGO_ENABLED=1 go test -tags "fts5,pgtest" -v ./internal/postgres/... ./internal/activity/... -count=1
253253

254254
# Start test SSH container
255255
ssh-up:

cmd/agentsview/activity.go

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"text/tabwriter"
9+
"time"
10+
11+
"go.kenn.io/agentsview/internal/activity"
12+
"go.kenn.io/agentsview/internal/db"
13+
)
14+
15+
// ActivityReportConfig holds the flags for `agentsview activity report`.
16+
type ActivityReportConfig struct {
17+
Preset string
18+
Date string
19+
From string
20+
To string
21+
Timezone string
22+
Bucket string
23+
Project string
24+
Agent string
25+
Machine string
26+
JSON bool
27+
NoSync bool
28+
Offline bool
29+
}
30+
31+
// runActivityReport syncs, resolves the range, runs the report, and prints it.
32+
func runActivityReport(cfg ActivityReportConfig) {
33+
database, appCfg := openUsageDB()
34+
defer database.Close()
35+
36+
ensureFreshData(appCfg, database, cfg.NoSync)
37+
38+
r, err := resolveActivityReportPriced(cfg, database)
39+
if err != nil {
40+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
41+
os.Exit(1)
42+
}
43+
44+
if cfg.JSON {
45+
enc := json.NewEncoder(os.Stdout)
46+
enc.SetIndent("", " ")
47+
if err := enc.Encode(r); err != nil {
48+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
49+
os.Exit(1)
50+
}
51+
return
52+
}
53+
54+
printActivityReport(r)
55+
}
56+
57+
// resolveActivityReportPriced seeds fallback pricing so fresh-DB token usage is
58+
// costed, then resolves the report. runActivityReport and the pricing test
59+
// share this seam so the test exercises the same seeding the command performs.
60+
func resolveActivityReportPriced(
61+
cfg ActivityReportConfig, database *db.DB,
62+
) (activity.Report, error) {
63+
ensurePricing(database, cfg.Offline)
64+
return resolveActivityReport(cfg, database)
65+
}
66+
67+
// resolveActivityReport defaults the timezone and date, resolves the range
68+
// query, and runs the report against the database. It is the testable seam:
69+
// all validation (timezone, bounds, bucket allow-list, range limits) happens
70+
// inside activity.ResolveQuery before any database query.
71+
func resolveActivityReport(
72+
cfg ActivityReportConfig, database *db.DB,
73+
) (activity.Report, error) {
74+
tz := cfg.Timezone
75+
if tz == "" {
76+
tz = localTimezone()
77+
}
78+
79+
date := cfg.Date
80+
if cfg.Preset != "custom" && cfg.From == "" && date == "" {
81+
date = todayIn(tz)
82+
}
83+
84+
input := activity.QueryInput{
85+
Preset: cfg.Preset,
86+
Date: date,
87+
From: cfg.From,
88+
To: cfg.To,
89+
Timezone: tz,
90+
BucketOverride: cfg.Bucket,
91+
}
92+
q, err := activity.ResolveQuery(input, time.Now())
93+
if err != nil {
94+
return activity.Report{}, err
95+
}
96+
97+
f := db.AnalyticsFilter{
98+
Timezone: tz,
99+
Project: cfg.Project,
100+
Agent: cfg.Agent,
101+
Machine: cfg.Machine,
102+
ExcludeOneShot: false,
103+
ExcludeAutomated: false,
104+
}
105+
return database.GetActivityReport(context.Background(), f, q)
106+
}
107+
108+
// todayIn returns today's date as YYYY-MM-DD in the given IANA timezone,
109+
// falling back to the local zone when tz is unknown.
110+
func todayIn(tz string) string {
111+
loc, err := time.LoadLocation(tz)
112+
if err != nil {
113+
loc = time.Local
114+
}
115+
return time.Now().In(loc).Format("2006-01-02")
116+
}
117+
118+
// printActivityReport renders the human-readable report: a header, totals,
119+
// peak concurrency, top breakdowns, and top sessions. It deliberately omits
120+
// the dense per-bucket timeline, which only the --json output exposes.
121+
func printActivityReport(r activity.Report) {
122+
loc, err := time.LoadLocation(r.Timezone)
123+
if err != nil {
124+
loc = time.UTC
125+
}
126+
fmt.Printf(
127+
"Activity %s to %s (%s, %s buckets)\n",
128+
fmtRangeBound(r.RangeStart, loc), fmtRangeBound(r.RangeEnd, loc),
129+
r.Timezone, r.BucketUnit,
130+
)
131+
if r.Partial {
132+
fmt.Printf("Partial range, data as of %s\n", fmtInstant(r.AsOf, loc))
133+
}
134+
fmt.Println()
135+
136+
printActivityTotals(r.Totals)
137+
fmt.Println()
138+
printActivityPeak(r.Peak, loc)
139+
fmt.Println()
140+
printKeyMinutes("By project", r.ByProject)
141+
printKeyMinutes("By model", r.ByModel)
142+
printKeyMinutes("By agent", r.ByAgent)
143+
printActivitySessions(r.BySession)
144+
}
145+
146+
// printActivityTotals prints the totals block via a tabwriter.
147+
func printActivityTotals(t activity.Totals) {
148+
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
149+
fmt.Fprintf(w, "Active minutes\t%.1f\n", t.ActiveMinutes)
150+
fmt.Fprintf(w, "Idle minutes\t%.1f\n", t.IdleMinutes)
151+
fmt.Fprintf(w, "Agent minutes\t%.1f\n", t.AgentMinutes)
152+
fmt.Fprintf(w, "Sessions\t%d (%d untimed)\n", t.Sessions, t.UntimedSessions)
153+
fmt.Fprintf(w, "Distinct projects\t%d\n", t.DistinctProjects)
154+
fmt.Fprintf(w, "Distinct models\t%d\n", t.DistinctModels)
155+
fmt.Fprintf(w, "Output tokens\t%d\n", t.OutputTokens)
156+
fmt.Fprintf(w, "Cost\t%s\n", fmtCost(t.Cost))
157+
w.Flush()
158+
}
159+
160+
// printActivityPeak prints peak concurrency and when it occurred, in loc.
161+
func printActivityPeak(p activity.Peak, loc *time.Location) {
162+
fmt.Printf("Peak concurrency: %d agents at %s\n",
163+
p.Agents, fmtInstant(p.At, loc))
164+
}
165+
166+
// printKeyMinutes prints the top 5 rows of a key/agent-minutes breakdown.
167+
func printKeyMinutes(label string, rows []activity.KeyMinutes) {
168+
fmt.Printf("%s (top 5):\n", label)
169+
if len(rows) == 0 {
170+
fmt.Println(" (none)")
171+
fmt.Println()
172+
return
173+
}
174+
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
175+
for _, row := range topKeyMinutes(rows, 5) {
176+
fmt.Fprintf(w, " %s\t%.1f min\n", row.Key, row.AgentMinutes)
177+
}
178+
w.Flush()
179+
fmt.Println()
180+
}
181+
182+
// printActivitySessions prints the top 5 sessions by appearance order.
183+
func printActivitySessions(rows []activity.SessionRow) {
184+
fmt.Println("Top sessions (top 5):")
185+
if len(rows) == 0 {
186+
fmt.Println(" (none)")
187+
return
188+
}
189+
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
190+
fmt.Fprintln(w, " TITLE\tPROJECT\tAGENT\tMINUTES\tCOST")
191+
limit := min(len(rows), 5)
192+
for _, s := range rows[:limit] {
193+
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n",
194+
s.Title, s.Project, s.Agent,
195+
fmtMinutes(s.AgentMinutes), fmtCost(s.Cost),
196+
)
197+
}
198+
w.Flush()
199+
}
200+
201+
// topKeyMinutes returns the first n rows of rows (already sorted by the query).
202+
func topKeyMinutes(rows []activity.KeyMinutes, n int) []activity.KeyMinutes {
203+
return rows[:min(len(rows), n)]
204+
}
205+
206+
// fmtRangeBound renders an RFC3339 range bound in loc, dropping the time
207+
// component when the local wall time is exactly midnight.
208+
func fmtRangeBound(ts string, loc *time.Location) string {
209+
t, err := time.Parse(time.RFC3339, ts)
210+
if err != nil {
211+
return ts
212+
}
213+
t = t.In(loc)
214+
if t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 {
215+
return t.Format("2006-01-02")
216+
}
217+
return t.Format("2006-01-02 15:04")
218+
}
219+
220+
// fmtMinutes renders an agent-minutes value, printing a dash for untimed
221+
// sessions whose pointer is nil.
222+
func fmtMinutes(m *float64) string {
223+
if m == nil {
224+
return "—"
225+
}
226+
return fmt.Sprintf("%.1f", *m)
227+
}
228+
229+
// fmtInstant renders a nullable RFC3339 instant in loc as "YYYY-MM-DD HH:MM",
230+
// printing a dash when nil.
231+
func fmtInstant(ts *string, loc *time.Location) string {
232+
if ts == nil {
233+
return "—"
234+
}
235+
if t, err := time.Parse(time.RFC3339, *ts); err == nil {
236+
return t.In(loc).Format("2006-01-02 15:04")
237+
}
238+
return *ts
239+
}

0 commit comments

Comments
 (0)