summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-04 08:50:46 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-04 08:50:46 +0000
commitf8ae821240f33d615a9e91cdfeb6c026b7970782 (patch)
tree458ce067db47a92248f163c26ce5fcf71299f3b0 /internal
parentd105eca610b0e737c3313e4978d6a917b4f55d10 (diff)
feat(web,api): add Budget/Roles dashboard -- final phase of the harness redesign (Phase 9b)
Two new tabs, matching Phase 9a's conventions: Budget (per-provider spend meters, escalation funnel, spend-over-time) and Roles (version history, draft activation, readable escalation-ladder view) -- the human-facing surface for the token-husbanding value proposition and the Phase 5/8 versioned role-config system. New backend (minimal, additive, matching existing endpoint conventions -- unauthenticated like /api/budget and /api/roles/*): - internal/storage/dashboard.go: QueryEscalationFunnel (executions grouped by escalation_rung + agent, count + cost) and QuerySpendTimeseries (cost per provider bucketed hourly/daily, mirroring QueryDashboardStats' existing bucketing expressions). Documented honestly: rung 0 is not exclusively "resolved locally" -- escalation_rung defaults to 0 uniformly, so non-role-typed executions (which never climb a ladder) land there too, alongside role-typed tasks genuinely resolved at tier 0. A role-only variant would need a join against tasks.config_json with no queryable role column on executions -- intentionally out of scope. - internal/api/dashboard.go: GET /api/escalation-funnel, GET /api/spend-timeseries (both ?window=5h|24h|7d|<duration>, default 24h). - internal/storage.ListRoleNames + GET /api/roles: the "which roles exist" gap -- there was no way to discover role names before this, only to list versions for a role you already knew the name of. Chart-form decisions (dataviz skill, invoked before writing chart code): horizontal stacked bar for the escalation funnel (rung order already encodes the funnel shape positionally; color only needed for per-provider segments within each rung); multi-line for spend-over-time; a fixed-order categorical palette from the skill's validated palette.md slots for provider identity (re-validated against this app's dark surface, passing); a separate status (good/warning/critical) palette for budget meters, deliberately distinct from both --state-* and the provider palette. Caught and fixed a real bug during visual QA: converging near-zero end-labels on the spend chart were overlapping (an anti-pattern the skill explicitly flags) -- fixed with a 14px minimum-gap check before direct-labeling an endpoint, leaning on the legend/tooltip otherwise. Verified with a real running server, real seeded data (65 executions across rungs 0-2 with a realistic provider mix, 2 roles with active/draft/retired role_configs versions written directly via internal/storage), and a real headless-browser session (reusing Phase 9a's Chromium/proxy scaffinding): confirmed correct rung totals/percentages, provider legends, a 3-line spend chart, live window-selector re-render, correct active-version highlighting on the role panel, and a real Activate click on a draft version -- verified via both DOM re-render and a direct backend GET that it truly persisted. go build/vet/test -race -count=1 all pass, full suite. node --test web/test/*.mjs: 291/291 passing (16 new). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal')
-rw-r--r--internal/api/dashboard.go75
-rw-r--r--internal/api/dashboard_test.go131
-rw-r--r--internal/api/roles.go17
-rw-r--r--internal/api/roles_test.go46
-rw-r--r--internal/api/server.go3
-rw-r--r--internal/storage/dashboard.go91
-rw-r--r--internal/storage/dashboard_test.go151
-rw-r--r--internal/storage/roleconfig.go22
-rw-r--r--internal/storage/roleconfig_test.go37
9 files changed, 573 insertions, 0 deletions
diff --git a/internal/api/dashboard.go b/internal/api/dashboard.go
new file mode 100644
index 0000000..1238295
--- /dev/null
+++ b/internal/api/dashboard.go
@@ -0,0 +1,75 @@
+package api
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/storage"
+)
+
+// parseWindow resolves a ?window= query param to a lookback duration,
+// defaulting to def when absent/unrecognized. Mirrors
+// handleGetDashboardStats' "24h vs 7d" special-casing, generalized with a
+// few friendly aliases plus a time.ParseDuration fallback for anything else
+// (e.g. "2h", "90m").
+func parseWindow(v string, def time.Duration) time.Duration {
+ switch v {
+ case "":
+ return def
+ case "5h":
+ return 5 * time.Hour
+ case "24h":
+ return 24 * time.Hour
+ case "7d":
+ return 7 * 24 * time.Hour
+ default:
+ if d, err := time.ParseDuration(v); err == nil && d > 0 {
+ return d
+ }
+ return def
+ }
+}
+
+// handleGetEscalationFunnel returns the (escalation_rung, agent) aggregate
+// that drives the budget dashboard's escalation funnel — the harness's core
+// "resolve locally, escalate only when needed" story made visible.
+// GET /api/escalation-funnel?window=24h|5h|7d|<Go duration>
+//
+// Defaults to a 24h window: wider than the budget accountant's 5h rolling
+// spend window (which exists to gate live dispatch), since this is a
+// historical/observability view, not a live gate — 24h gives a more
+// representative sample of rung distribution without requiring the caller
+// to know the accountant's configured window.
+func (s *Server) handleGetEscalationFunnel(w http.ResponseWriter, r *http.Request) {
+ window := parseWindow(r.URL.Query().Get("window"), 24*time.Hour)
+ since := time.Now().Add(-window)
+
+ buckets, err := s.store.QueryEscalationFunnel(since)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if buckets == nil {
+ buckets = []storage.EscalationBucket{}
+ }
+ writeJSON(w, http.StatusOK, buckets)
+}
+
+// handleGetSpendTimeseries returns cost-per-provider bucketed over time —
+// hourly for windows of a day or less, daily otherwise (mirroring
+// QueryDashboardStats' own hourly-throughput/daily-billing split).
+// GET /api/spend-timeseries?window=24h|5h|7d|<Go duration>
+func (s *Server) handleGetSpendTimeseries(w http.ResponseWriter, r *http.Request) {
+ window := parseWindow(r.URL.Query().Get("window"), 24*time.Hour)
+ since := time.Now().Add(-window)
+
+ points, err := s.store.QuerySpendTimeseries(since, window <= 26*time.Hour)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if points == nil {
+ points = []storage.SpendPoint{}
+ }
+ writeJSON(w, http.StatusOK, points)
+}
diff --git a/internal/api/dashboard_test.go b/internal/api/dashboard_test.go
new file mode 100644
index 0000000..2b80de3
--- /dev/null
+++ b/internal/api/dashboard_test.go
@@ -0,0 +1,131 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+func TestHandleGetEscalationFunnel_AggregatesWithinDefaultWindow(t *testing.T) {
+ srv, store := testServer(t)
+ tk := createTaskWithState(t, store, "funnel-task", task.StateCompleted)
+
+ now := time.Now().UTC()
+ mustCreateAPIExecution(t, store, "f1", tk.ID, "local", 0, 0, now.Add(-1*time.Hour))
+ mustCreateAPIExecution(t, store, "f2", tk.ID, "local", 0, 0, now.Add(-2*time.Hour))
+ mustCreateAPIExecution(t, store, "f3", tk.ID, "anthropic", 1, 0.10, now.Add(-3*time.Hour))
+ // Outside the default 24h window.
+ mustCreateAPIExecution(t, store, "f4", tk.ID, "local", 0, 0, now.Add(-48*time.Hour))
+
+ req := httptest.NewRequest("GET", "/api/escalation-funnel", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var buckets []storage.EscalationBucket
+ if err := json.Unmarshal(w.Body.Bytes(), &buckets); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(buckets) != 2 {
+ t.Fatalf("expected 2 buckets (rung 0/local, rung 1/anthropic), got %d: %+v", len(buckets), buckets)
+ }
+ for _, b := range buckets {
+ if b.Rung == 0 && b.Agent == "local" && b.Count != 2 {
+ t.Errorf("rung0/local count = %d, want 2", b.Count)
+ }
+ if b.Rung == 1 && b.Agent == "anthropic" && (b.Count != 1 || b.CostUSD != 0.10) {
+ t.Errorf("rung1/anthropic = %+v, want count 1 cost 0.10", b)
+ }
+ }
+}
+
+func TestHandleGetEscalationFunnel_WindowParam_Narrows(t *testing.T) {
+ srv, store := testServer(t)
+ tk := createTaskWithState(t, store, "funnel-window-task", task.StateCompleted)
+ now := time.Now().UTC()
+ mustCreateAPIExecution(t, store, "fw1", tk.ID, "local", 0, 0, now.Add(-10*time.Hour))
+
+ req := httptest.NewRequest("GET", "/api/escalation-funnel?window=5h", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ var buckets []storage.EscalationBucket
+ if err := json.Unmarshal(w.Body.Bytes(), &buckets); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(buckets) != 0 {
+ t.Errorf("expected no buckets within a 5h window, got %+v", buckets)
+ }
+}
+
+func TestHandleGetSpendTimeseries_ReturnsPerProviderPoints(t *testing.T) {
+ srv, store := testServer(t)
+ tk := createTaskWithState(t, store, "spend-task", task.StateCompleted)
+ now := time.Now().UTC()
+ mustCreateAPIExecution(t, store, "s1", tk.ID, "local", 0, 0.01, now.Add(-1*time.Hour))
+ mustCreateAPIExecution(t, store, "s2", tk.ID, "anthropic", 0, 0.20, now.Add(-1*time.Hour))
+
+ req := httptest.NewRequest("GET", "/api/spend-timeseries", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var points []storage.SpendPoint
+ if err := json.Unmarshal(w.Body.Bytes(), &points); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ agents := map[string]bool{}
+ for _, p := range points {
+ agents[p.Agent] = true
+ }
+ if !agents["local"] || !agents["anthropic"] {
+ t.Errorf("expected points for both local and anthropic, got %+v", points)
+ }
+}
+
+func TestHandleGetSpendTimeseries_EmptyDB_ReturnsEmptyArray(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/spend-timeseries", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d", w.Code)
+ }
+ var points []storage.SpendPoint
+ if err := json.Unmarshal(w.Body.Bytes(), &points); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(points) != 0 {
+ t.Errorf("expected empty slice, got %+v", points)
+ }
+}
+
+// mustCreateAPIExecution mirrors createExecution (executions_test.go) but
+// also sets Agent/EscalationRung, which the funnel/spend-timeseries
+// endpoints aggregate on.
+func mustCreateAPIExecution(t *testing.T, store *storage.DB, id, taskID, agent string, rung int, cost float64, start time.Time) {
+ t.Helper()
+ exec := &storage.Execution{
+ ID: id,
+ TaskID: taskID,
+ StartTime: start,
+ EndTime: start.Add(time.Minute),
+ Status: "COMPLETED",
+ CostUSD: cost,
+ Agent: agent,
+ EscalationRung: rung,
+ }
+ if err := store.CreateExecution(exec); err != nil {
+ t.Fatalf("createExecution(%s): %v", id, err)
+ }
+}
diff --git a/internal/api/roles.go b/internal/api/roles.go
index 700bec4..aa5c807 100644
--- a/internal/api/roles.go
+++ b/internal/api/roles.go
@@ -25,6 +25,23 @@ type roleVersionView struct {
ProposedBy string `json:"proposed_by,omitempty"`
}
+// handleListRoleNames handles GET /api/roles — every distinct role name
+// that has at least one role_configs row. There is no other way to discover
+// which roles exist short of guessing names; the role/config management
+// panel uses this as its entry point before fetching each role's version
+// history via GET /api/roles/{role}/versions.
+func (s *Server) handleListRoleNames(w http.ResponseWriter, _ *http.Request) {
+ names, err := s.store.ListRoleNames()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if names == nil {
+ names = []string{}
+ }
+ writeJSON(w, http.StatusOK, names)
+}
+
// handleCreateRoleVersion handles POST /api/roles/{role}/versions. The
// request body is a role.RoleConfig (config_json shape, plus an optional
// proposed_by field); a new draft version is created with the next version
diff --git a/internal/api/roles_test.go b/internal/api/roles_test.go
index 97afdfe..1f17426 100644
--- a/internal/api/roles_test.go
+++ b/internal/api/roles_test.go
@@ -11,6 +11,52 @@ import (
"github.com/thepeterstone/claudomator/internal/role"
)
+// TestServer_ListRoleNames_ReturnsDistinctRoles verifies GET /api/roles — the
+// "which roles exist" discovery endpoint the role/config management panel
+// needs, since there's no other way to enumerate role names.
+func TestServer_ListRoleNames_ReturnsDistinctRoles(t *testing.T) {
+ srv, store := testServer(t)
+
+ if _, err := store.CreateRoleConfig("coder", `{"role":"coder"}`, "human"); err != nil {
+ t.Fatalf("CreateRoleConfig coder: %v", err)
+ }
+ if _, err := store.CreateRoleConfig("coder", `{"role":"coder","system_prompt":"v2"}`, "human"); err != nil {
+ t.Fatalf("CreateRoleConfig coder v2: %v", err)
+ }
+ if _, err := store.CreateRoleConfig("reviewer", `{"role":"reviewer"}`, "human"); err != nil {
+ t.Fatalf("CreateRoleConfig reviewer: %v", err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/roles", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var names []string
+ if err := json.Unmarshal(w.Body.Bytes(), &names); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(names) != 2 || names[0] != "coder" || names[1] != "reviewer" {
+ t.Errorf("expected [coder reviewer], got %v", names)
+ }
+}
+
+func TestServer_ListRoleNames_Empty_ReturnsEmptyArray(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/roles", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+ if got := w.Body.String(); got != "[]\n" && got != "[]" {
+ t.Errorf("expected empty JSON array, got %q", got)
+ }
+}
+
// TestServer_CreateRoleVersion_CreatesDraft mirrors the projects endpoint
// test pattern (see server_test.go's testServer/httptest.NewRequest usage):
// POSTing a role.RoleConfig body creates a new draft version.
diff --git a/internal/api/server.go b/internal/api/server.go
index 8d03021..9123f4d 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -185,9 +185,12 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/stories/{id}/task-tree", s.handleStoryTaskTree)
s.mux.HandleFunc("GET /api/stories/{id}/events", s.handleListStoryEvents)
s.mux.HandleFunc("POST /api/stories/{id}/accept", s.handleAcceptStory)
+ s.mux.HandleFunc("GET /api/roles", s.handleListRoleNames)
s.mux.HandleFunc("POST /api/roles/{role}/versions", s.handleCreateRoleVersion)
s.mux.HandleFunc("GET /api/roles/{role}/versions", s.handleListRoleVersions)
s.mux.HandleFunc("POST /api/roles/{role}/activate", s.handleActivateRoleVersion)
+ s.mux.HandleFunc("GET /api/escalation-funnel", s.handleGetEscalationFunnel)
+ s.mux.HandleFunc("GET /api/spend-timeseries", s.handleGetSpendTimeseries)
s.mux.HandleFunc("GET /api/health", s.handleHealth)
s.mux.HandleFunc("GET /api/version", s.handleVersion)
s.mux.HandleFunc("POST /api/webhooks/github", s.handleGitHubWebhook)
diff --git a/internal/storage/dashboard.go b/internal/storage/dashboard.go
new file mode 100644
index 0000000..e432af1
--- /dev/null
+++ b/internal/storage/dashboard.go
@@ -0,0 +1,91 @@
+package storage
+
+import "time"
+
+// EscalationBucket is one (escalation_rung, provider) aggregate over
+// executions in a window — the raw material for the budget dashboard's
+// escalation funnel (GET /api/escalation-funnel), the harness's
+// "how much falls through each rung" story made visible.
+type EscalationBucket struct {
+ Rung int `json:"rung"`
+ Agent string `json:"agent"`
+ Count int `json:"count"`
+ CostUSD float64 `json:"cost_usd"`
+}
+
+// QueryEscalationFunnel aggregates executions started at or after `since` by
+// (escalation_rung, agent), counting executions and summing cost. Excludes
+// executions still in flight (RUNNING/QUEUED/PENDING have no terminal
+// outcome yet to attribute to a rung).
+//
+// Rung 0 is not exclusively "resolved locally": executions.escalation_rung
+// defaults to 0 uniformly, so every non-role-typed execution (which never
+// climbs a ladder at all) also lands there alongside role-typed tasks
+// genuinely resolved at their ladder's first tier. This mirrors the
+// aggregate shape suggested for this phase and keeps the query a plain
+// GROUP BY with no join — a role-only variant would need to join tasks and
+// inspect config_json (no queryable role column on executions), which is
+// intentionally left out of scope here.
+func (s *DB) QueryEscalationFunnel(since time.Time) ([]EscalationBucket, error) {
+ rows, err := s.db.Query(`
+ SELECT escalation_rung, COALESCE(agent, ''), COUNT(*), COALESCE(SUM(cost_usd), 0)
+ FROM executions
+ WHERE start_time >= ? AND status NOT IN ('RUNNING', 'QUEUED', 'PENDING')
+ GROUP BY escalation_rung, agent
+ ORDER BY escalation_rung ASC, agent ASC`, since.UTC())
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ out := []EscalationBucket{}
+ for rows.Next() {
+ var b EscalationBucket
+ if err := rows.Scan(&b.Rung, &b.Agent, &b.Count, &b.CostUSD); err != nil {
+ return nil, err
+ }
+ out = append(out, b)
+ }
+ return out, rows.Err()
+}
+
+// SpendPoint is one (time bucket, provider) aggregate of cost — the raw
+// material for the budget dashboard's spend-over-time chart
+// (GET /api/spend-timeseries).
+type SpendPoint struct {
+ Bucket string `json:"bucket"` // RFC3339 truncated to the hour, or YYYY-MM-DD
+ Agent string `json:"agent"`
+ CostUSD float64 `json:"cost_usd"`
+}
+
+// QuerySpendTimeseries aggregates cost per provider bucketed by hour
+// (hourly=true) or by calendar day (hourly=false), for executions started at
+// or after since. Mirrors QueryDashboardStats' own Throughput (hourly) /
+// Billing (daily) bucketing expressions, just grouped additionally by
+// provider so per-provider trend lines can be drawn.
+func (s *DB) QuerySpendTimeseries(since time.Time, hourly bool) ([]SpendPoint, error) {
+ bucketExpr := "date(start_time)"
+ if hourly {
+ bucketExpr = "strftime('%Y-%m-%dT%H:00:00Z', start_time)"
+ }
+ rows, err := s.db.Query(`
+ SELECT `+bucketExpr+` as bucket, COALESCE(agent, ''), COALESCE(SUM(cost_usd), 0)
+ FROM executions
+ WHERE start_time >= ?
+ GROUP BY bucket, agent
+ ORDER BY bucket ASC, agent ASC`, since.UTC())
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ out := []SpendPoint{}
+ for rows.Next() {
+ var p SpendPoint
+ if err := rows.Scan(&p.Bucket, &p.Agent, &p.CostUSD); err != nil {
+ return nil, err
+ }
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
diff --git a/internal/storage/dashboard_test.go b/internal/storage/dashboard_test.go
new file mode 100644
index 0000000..b7e425d
--- /dev/null
+++ b/internal/storage/dashboard_test.go
@@ -0,0 +1,151 @@
+package storage
+
+import (
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// mustCreateTask is a small helper mirroring the pattern used in db_test.go
+// (TestCreateTask_AndGetTask) — a minimal valid task row so executions can
+// reference a real task_id.
+func mustCreateTask(t *testing.T, db *DB, id string) {
+ t.Helper()
+ now := time.Now().UTC().Truncate(time.Second)
+ tk := &task.Task{
+ ID: id,
+ Name: "dashboard-test-" + id,
+ State: task.StatePending,
+ DependsOn: []string{},
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := db.CreateTask(tk); err != nil {
+ t.Fatalf("CreateTask(%s): %v", id, err)
+ }
+}
+
+func mustCreateExecution(t *testing.T, db *DB, id, taskID, agent string, rung int, cost float64, start time.Time, status string) {
+ t.Helper()
+ exec := &Execution{
+ ID: id,
+ TaskID: taskID,
+ StartTime: start,
+ EndTime: start.Add(time.Minute),
+ Status: status,
+ CostUSD: cost,
+ Agent: agent,
+ EscalationRung: rung,
+ }
+ if err := db.CreateExecution(exec); err != nil {
+ t.Fatalf("CreateExecution(%s): %v", id, err)
+ }
+}
+
+func TestQueryEscalationFunnel_AggregatesByRungAndAgent(t *testing.T) {
+ db := testDB(t)
+ mustCreateTask(t, db, "t1")
+
+ now := time.Now().UTC()
+ // Rung 0: 2 local executions.
+ mustCreateExecution(t, db, "e1", "t1", "local", 0, 0, now.Add(-3*time.Hour), "COMPLETED")
+ mustCreateExecution(t, db, "e2", "t1", "local", 0, 0, now.Add(-2*time.Hour), "COMPLETED")
+ // Rung 1: 1 anthropic execution.
+ mustCreateExecution(t, db, "e3", "t1", "anthropic", 1, 0.05, now.Add(-1*time.Hour), "COMPLETED")
+ // Still in flight — must be excluded.
+ mustCreateExecution(t, db, "e4", "t1", "anthropic", 1, 0, now, "RUNNING")
+ // Too old — outside the window.
+ mustCreateExecution(t, db, "e5", "t1", "local", 0, 0, now.Add(-48*time.Hour), "COMPLETED")
+
+ buckets, err := db.QueryEscalationFunnel(now.Add(-24 * time.Hour))
+ if err != nil {
+ t.Fatalf("QueryEscalationFunnel: %v", err)
+ }
+
+ want := map[string]struct {
+ count int
+ cost float64
+ }{
+ "0|local": {2, 0},
+ "1|anthropic": {1, 0.05},
+ }
+ if len(buckets) != len(want) {
+ t.Fatalf("expected %d buckets, got %d: %+v", len(want), len(buckets), buckets)
+ }
+ for _, b := range buckets {
+ key := strconv.Itoa(b.Rung) + "|" + b.Agent
+ w, ok := want[key]
+ if !ok {
+ t.Errorf("unexpected bucket %+v", b)
+ continue
+ }
+ if b.Count != w.count {
+ t.Errorf("bucket %s: count = %d, want %d", key, b.Count, w.count)
+ }
+ if b.CostUSD != w.cost {
+ t.Errorf("bucket %s: cost = %v, want %v", key, b.CostUSD, w.cost)
+ }
+ }
+}
+
+func TestQueryEscalationFunnel_Empty_ReturnsEmptySlice(t *testing.T) {
+ db := testDB(t)
+ buckets, err := db.QueryEscalationFunnel(time.Now().Add(-24 * time.Hour))
+ if err != nil {
+ t.Fatalf("QueryEscalationFunnel: %v", err)
+ }
+ if buckets == nil || len(buckets) != 0 {
+ t.Errorf("expected empty non-nil slice, got %v", buckets)
+ }
+}
+
+func TestQuerySpendTimeseries_HourlyBucketsByProvider(t *testing.T) {
+ db := testDB(t)
+ mustCreateTask(t, db, "t1")
+
+ // Pin two executions to the exact same hour bucket for two providers.
+ hourStart := time.Now().UTC().Truncate(time.Hour)
+ mustCreateExecution(t, db, "e1", "t1", "local", 0, 0.10, hourStart.Add(5*time.Minute), "COMPLETED")
+ mustCreateExecution(t, db, "e2", "t1", "anthropic", 0, 0.20, hourStart.Add(10*time.Minute), "COMPLETED")
+ mustCreateExecution(t, db, "e3", "t1", "anthropic", 0, 0.05, hourStart.Add(40*time.Minute), "COMPLETED")
+
+ points, err := db.QuerySpendTimeseries(hourStart.Add(-time.Hour), true)
+ if err != nil {
+ t.Fatalf("QuerySpendTimeseries: %v", err)
+ }
+
+ totals := map[string]float64{}
+ for _, p := range points {
+ totals[p.Agent] += p.CostUSD
+ }
+ if totals["local"] != 0.10 {
+ t.Errorf("local total = %v, want 0.10", totals["local"])
+ }
+ if got, want := totals["anthropic"], 0.25; got < want-1e-9 || got > want+1e-9 {
+ t.Errorf("anthropic total = %v, want %v", got, want)
+ }
+}
+
+func TestQuerySpendTimeseries_DailyBucketing(t *testing.T) {
+ db := testDB(t)
+ mustCreateTask(t, db, "t1")
+
+ today := time.Now().UTC()
+ mustCreateExecution(t, db, "e1", "t1", "local", 0, 1.5, today, "COMPLETED")
+
+ points, err := db.QuerySpendTimeseries(today.Add(-24*time.Hour), false)
+ if err != nil {
+ t.Fatalf("QuerySpendTimeseries: %v", err)
+ }
+ if len(points) != 1 {
+ t.Fatalf("expected 1 point, got %d: %+v", len(points), points)
+ }
+ if points[0].Bucket != today.Format("2006-01-02") {
+ t.Errorf("bucket = %q, want %q", points[0].Bucket, today.Format("2006-01-02"))
+ }
+ if points[0].CostUSD != 1.5 {
+ t.Errorf("cost = %v, want 1.5", points[0].CostUSD)
+ }
+}
diff --git a/internal/storage/roleconfig.go b/internal/storage/roleconfig.go
index 6ae043b..f9478f2 100644
--- a/internal/storage/roleconfig.go
+++ b/internal/storage/roleconfig.go
@@ -125,6 +125,28 @@ func (s *DB) ActivateRoleConfigVersion(roleName string, version int) error {
return tx.Commit()
}
+// ListRoleNames returns every distinct role name that has at least one
+// role_configs row, alphabetically. There is no other way to discover which
+// roles exist short of guessing names — this backs GET /api/roles, the
+// "which roles exist" entry point the role/config management panel needs.
+func (s *DB) ListRoleNames() ([]string, error) {
+ rows, err := s.db.Query(`SELECT DISTINCT role FROM role_configs ORDER BY role ASC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ out := []string{}
+ for rows.Next() {
+ var r string
+ if err := rows.Scan(&r); err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
func scanRoleConfigRow(row scanner) (*RoleConfigRow, error) {
var r RoleConfigRow
var createdAt time.Time
diff --git a/internal/storage/roleconfig_test.go b/internal/storage/roleconfig_test.go
index b18eaef..e5c8242 100644
--- a/internal/storage/roleconfig_test.go
+++ b/internal/storage/roleconfig_test.go
@@ -136,6 +136,43 @@ func TestActivateRoleConfigVersion_ExactlyOneActive(t *testing.T) {
}
}
+func TestListRoleNames_ReturnsDistinctRolesAlphabetically(t *testing.T) {
+ db := testDB(t)
+
+ if _, err := db.CreateRoleConfig("coder", `{"role":"coder"}`, "human"); err != nil {
+ t.Fatalf("CreateRoleConfig coder: %v", err)
+ }
+ // A second version for the same role must not produce a duplicate entry.
+ if _, err := db.CreateRoleConfig("coder", `{"role":"coder","system_prompt":"v2"}`, "human"); err != nil {
+ t.Fatalf("CreateRoleConfig coder v2: %v", err)
+ }
+ if _, err := db.CreateRoleConfig("builder", `{"role":"builder"}`, "human"); err != nil {
+ t.Fatalf("CreateRoleConfig builder: %v", err)
+ }
+
+ names, err := db.ListRoleNames()
+ if err != nil {
+ t.Fatalf("ListRoleNames: %v", err)
+ }
+ if len(names) != 2 {
+ t.Fatalf("expected 2 distinct roles, got %d: %v", len(names), names)
+ }
+ if names[0] != "builder" || names[1] != "coder" {
+ t.Errorf("expected [builder coder] alphabetically, got %v", names)
+ }
+}
+
+func TestListRoleNames_Empty_ReturnsEmptySlice(t *testing.T) {
+ db := testDB(t)
+ names, err := db.ListRoleNames()
+ if err != nil {
+ t.Fatalf("ListRoleNames: %v", err)
+ }
+ if names == nil || len(names) != 0 {
+ t.Errorf("expected empty (non-nil) slice, got %v", names)
+ }
+}
+
func TestActivateRoleConfigVersion_UnknownVersion_Errors(t *testing.T) {
db := testDB(t)
if _, err := db.CreateRoleConfig("coder", `{"role":"coder"}`, "human"); err != nil {