diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-04 08:50:46 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-04 08:50:46 +0000 |
| commit | f8ae821240f33d615a9e91cdfeb6c026b7970782 (patch) | |
| tree | 458ce067db47a92248f163c26ce5fcf71299f3b0 | |
| parent | d105eca610b0e737c3313e4978d6a917b4f55d10 (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
| -rw-r--r-- | internal/api/dashboard.go | 75 | ||||
| -rw-r--r-- | internal/api/dashboard_test.go | 131 | ||||
| -rw-r--r-- | internal/api/roles.go | 17 | ||||
| -rw-r--r-- | internal/api/roles_test.go | 46 | ||||
| -rw-r--r-- | internal/api/server.go | 3 | ||||
| -rw-r--r-- | internal/storage/dashboard.go | 91 | ||||
| -rw-r--r-- | internal/storage/dashboard_test.go | 151 | ||||
| -rw-r--r-- | internal/storage/roleconfig.go | 22 | ||||
| -rw-r--r-- | internal/storage/roleconfig_test.go | 37 | ||||
| -rw-r--r-- | web/app.js | 636 | ||||
| -rw-r--r-- | web/index.html | 4 | ||||
| -rw-r--r-- | web/style.css | 299 | ||||
| -rw-r--r-- | web/test/dashboard.test.mjs | 154 |
13 files changed, 1666 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 { @@ -1366,6 +1366,12 @@ function renderActiveTab(allTasks) { case 'drops': renderDropsPanel(); break; + case 'budget': + renderBudgetPanel(); + break; + case 'roles': + renderRolesPanel(); + break; case 'settings': renderSettingsPanel(); break; @@ -4094,6 +4100,636 @@ function closeStoryModal() { if (modal) modal.close(); } +// ── Budget & Escalation dashboard (Phase 9b) ────────────────────────────────── +// Per-provider spend headroom (GET /api/budget, already wired for the header +// chips), the escalation funnel (GET /api/escalation-funnel — how much work +// resolved at each rung of a role's ladder), and spend-over-time +// (GET /api/spend-timeseries), all per the dataviz skill: provider identity is +// a categorical color job, so it gets a fixed-order palette distinct from the +// --state-* task-state tokens (which are constrained/reused elsewhere and +// pre-date this phase); everything is also always paired with a text +// label/legend so identity never depends on color alone. + +// Fixed categorical order + validated (dark-surface) hex per provider — see +// dataviz skill's palette.md categorical slots 1/2/3/4/5/6 (blue/aqua/yellow/ +// green/violet/red), run through scripts/validate_palette.js against this +// app's dark chart surface (~#0f172a): all 6 pass the lightness/chroma/ +// contrast checks, CVD separation lands in the 8-12 "floor" band, which is +// legal only paired with direct labels/legend — hence every render below +// carries a legend and/or text label, never color alone. +const PROVIDER_COLOR_ORDER = ['local', 'anthropic', 'google', 'groq', 'openrouter', 'openai']; +const PROVIDER_COLORS = { + local: '#3987e5', // blue + anthropic: '#199e70', // aqua + google: '#c98500', // yellow + groq: '#008300', // green + openrouter: '#9085e9', // violet + openai: '#e66767', // red +}; +const PROVIDER_COLOR_FALLBACK = '#898781'; // muted ink — any provider outside the fixed order + +export function colorForProvider(agent) { + return PROVIDER_COLORS[agent] || PROVIDER_COLOR_FALLBACK; +} + +export function providerLabel(agent) { + if (!agent) return 'Unknown'; + return agent.charAt(0).toUpperCase() + agent.slice(1); +} + +function sortProvidersCanonically(agents) { + return [...agents].sort((a, b) => { + const ia = PROVIDER_COLOR_ORDER.indexOf(a); + const ib = PROVIDER_COLOR_ORDER.indexOf(b); + return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib); + }); +} + +// Dataviz skill's "good/warning/critical" status palette (fixed, never +// themed, distinct from the categorical provider slots above) — used for the +// budget meter fill, since remaining-headroom severity is a status job, not +// an identity job. +const BUDGET_STATUS_GOOD = '#0ca30c'; +const BUDGET_STATUS_WARNING = '#fab219'; +const BUDGET_STATUS_CRITICAL = '#d03b3b'; + +function budgetStatusColor(fractionRemaining) { + if (fractionRemaining < 0.2) return BUDGET_STATUS_CRITICAL; + if (fractionRemaining < 0.5) return BUDGET_STATUS_WARNING; + return BUDGET_STATUS_GOOD; +} + +// computeEscalationFunnel reshapes the raw (rung, agent, count, cost_usd) +// aggregate rows from GET /api/escalation-funnel into per-rung totals plus a +// provider breakdown, sorted by rung ascending (rung 0 first — the harness's +// "resolved without escalating" story). +export function computeEscalationFunnel(buckets) { + const byRung = new Map(); + let grandTotal = 0; + for (const b of (buckets || [])) { + grandTotal += b.count; + let r = byRung.get(b.rung); + if (!r) { + r = { rung: b.rung, total: 0, cost: 0, byAgent: [] }; + byRung.set(b.rung, r); + } + r.total += b.count; + r.cost += b.cost_usd || 0; + r.byAgent.push({ agent: b.agent || '', count: b.count, cost: b.cost_usd || 0 }); + } + const rungs = Array.from(byRung.values()).sort((a, b) => a.rung - b.rung); + return { rungs, grandTotal }; +} + +// computeSpendSeries reshapes the raw (bucket, agent, cost_usd) points from +// GET /api/spend-timeseries into aligned per-provider arrays over a shared, +// sorted bucket axis (missing points fill as 0), plus the max value for +// scaling a chart's y-axis. +export function computeSpendSeries(points) { + const bucketsSet = new Set(); + const agentsSet = new Set(); + const byKey = new Map(); + for (const p of (points || [])) { + bucketsSet.add(p.bucket); + const agent = p.agent || ''; + agentsSet.add(agent); + byKey.set(`${p.bucket}|${agent}`, p.cost_usd || 0); + } + const buckets = Array.from(bucketsSet).sort(); + const agents = Array.from(agentsSet).sort(); + const series = {}; + let maxCost = 0; + for (const agent of agents) { + series[agent] = buckets.map(b => { + const v = byKey.get(`${b}|${agent}`) || 0; + if (v > maxCost) maxCost = v; + return v; + }); + } + return { buckets, agents, series, maxCost }; +} + +// formatBucketLabel turns a spend-timeseries bucket key — either an RFC3339 +// hour ("2026-07-02T14:00:00Z") or a calendar day ("2026-07-02") — into a +// short human label. +export function formatBucketLabel(bucket) { + if (!bucket) return ''; + if (bucket.includes('T')) { + return new Date(bucket).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit' }); + } + return new Date(bucket + 'T12:00:00Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +function renderProviderLegend(agents, doc = document) { + const legend = doc.createElement('div'); + legend.className = 'provider-legend'; + for (const agent of sortProvidersCanonically(agents)) { + const item = doc.createElement('span'); + item.className = 'provider-legend-item'; + const swatch = doc.createElement('span'); + swatch.className = 'provider-legend-swatch'; + swatch.style.background = colorForProvider(agent); + item.appendChild(swatch); + const text = doc.createElement('span'); + text.textContent = providerLabel(agent); + item.appendChild(text); + legend.appendChild(item); + } + return legend; +} + +// renderEscalationFunnel builds the funnel chart: one horizontal bar per +// rung (bar length = share of the window's total executions — the "how much +// falls through each stage" story), stacked by provider within the bar. Bar +// length already encodes the funnel shape via ordinal position + decreasing +// magnitude, so rung identity needs no color of its own; provider identity +// (the stacked segments) is the one categorical color job here, backed by a +// legend and per-segment title tooltips (never color alone). +export function renderEscalationFunnel(funnelData, doc = (typeof document !== 'undefined' ? document : null)) { + if (doc == null) return null; + const wrap = doc.createElement('div'); + wrap.className = 'funnel-chart'; + + if (!funnelData.rungs.length) { + const empty = doc.createElement('p'); + empty.className = 'task-meta'; + empty.textContent = 'No executions in this window.'; + wrap.appendChild(empty); + return wrap; + } + + const maxTotal = Math.max(...funnelData.rungs.map(r => r.total), 1); + const allAgents = new Set(); + + for (const r of funnelData.rungs) { + const row = doc.createElement('div'); + row.className = 'funnel-row'; + + const label = doc.createElement('span'); + label.className = 'funnel-row-label'; + label.textContent = `Rung ${r.rung}`; + row.appendChild(label); + + // trackOuter is the full-width baseline; track is sized to this rung's + // share of the largest rung (the funnel taper), and holds the + // provider-colored segments distributed by flex-grow within it. + const trackOuter = doc.createElement('div'); + trackOuter.className = 'funnel-track-outer'; + + const track = doc.createElement('div'); + track.className = 'funnel-track'; + track.style.width = `${((r.total / maxTotal) * 100).toFixed(1)}%`; + + for (const a of sortProvidersCanonically(r.byAgent.map(x => x.agent)).map(agent => r.byAgent.find(x => x.agent === agent))) { + allAgents.add(a.agent); + const seg = doc.createElement('div'); + seg.className = 'funnel-seg'; + seg.style.background = colorForProvider(a.agent); + seg.style.flexGrow = String(a.count); + const pct = r.total > 0 ? Math.round((a.count / r.total) * 100) : 0; + seg.title = `${providerLabel(a.agent)}: ${a.count} (${pct}%)`; + track.appendChild(seg); + } + + trackOuter.appendChild(track); + row.appendChild(trackOuter); + + const countEl = doc.createElement('span'); + countEl.className = 'funnel-count'; + const pctOfGrand = funnelData.grandTotal > 0 ? Math.round((r.total / funnelData.grandTotal) * 100) : 0; + countEl.textContent = `${r.total} (${pctOfGrand}%)`; + row.appendChild(countEl); + + wrap.appendChild(row); + } + + wrap.appendChild(renderProviderLegend(Array.from(allAgents), doc)); + return wrap; +} + +// renderSpendTimeseries builds a multi-line SVG chart, one line per provider +// (categorical color, fixed order) — cost trend over time is the story, and +// "tell distinct series apart over time" is exactly the multi-line job per +// the dataviz skill's form table. Direct end-labels only when there are few +// enough series to not collide (<=4); a legend always carries identity +// regardless. Hover tooltips (native <title>) on every point per the skill's +// "ship a crosshair+tooltip on line/area" interaction guidance — kept to +// native titles rather than a custom crosshair overlay, consistent with this +// app's existing charts (stats tab's throughput/billing bars use the same +// col.title convention). +export function renderSpendTimeseries(seriesData, doc = (typeof document !== 'undefined' ? document : null)) { + if (doc == null) return null; + const { buckets, agents, series, maxCost } = seriesData; + const wrap = doc.createElement('div'); + wrap.className = 'spend-chart-wrap'; + + if (buckets.length === 0 || agents.length === 0) { + const empty = doc.createElement('p'); + empty.className = 'task-meta'; + empty.textContent = 'No spend data in this window.'; + wrap.appendChild(empty); + return wrap; + } + + const width = 640, height = 200; + const padL = 8, padR = 52, padT = 12, padB = 24; + const plotW = width - padL - padR; + const plotH = height - padT - padB; + const yMax = maxCost > 0 ? maxCost * 1.15 : 1; + + const xFor = i => (buckets.length > 1 ? padL + (i / (buckets.length - 1)) * plotW : padL + plotW / 2); + const yFor = v => (height - padB) - (v / yMax) * plotH; + + const svg = doc.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + svg.setAttribute('width', '100%'); + svg.setAttribute('height', String(height)); + svg.classList.add('spend-chart-svg'); + + const axis = doc.createElementNS(SVG_NS, 'line'); + axis.setAttribute('x1', String(padL)); + axis.setAttribute('x2', String(width - padR)); + axis.setAttribute('y1', String(height - padB)); + axis.setAttribute('y2', String(height - padB)); + axis.setAttribute('class', 'spend-chart-axis'); + svg.appendChild(axis); + + const sortedAgents = sortProvidersCanonically(agents); + const directLabels = sortedAgents.length <= 4; + + // Pre-compute end-label eligibility: when two series' final values land + // close together in y, only the first (top-most) keeps its direct label; + // per the dataviz skill's "when end-labels collide, don't stack them" + // rule, the rest fall back to the legend + hover tooltip rather than + // being nudged apart (which would detach a label from its line). + const labelEligible = new Set(); + if (directLabels) { + const MIN_LABEL_GAP_PX = 14; + const candidates = sortedAgents + .map(agent => { + const values = series[agent] || []; + return values.length > 0 ? { agent, y: yFor(values[values.length - 1]) } : null; + }) + .filter(Boolean) + .sort((a, b) => a.y - b.y); + let lastLabeledY = -Infinity; + for (const c of candidates) { + if (c.y - lastLabeledY >= MIN_LABEL_GAP_PX) { + labelEligible.add(c.agent); + lastLabeledY = c.y; + } + } + } + + for (const agent of sortedAgents) { + const values = series[agent] || []; + const color = colorForProvider(agent); + const pointsAttr = values.map((v, i) => `${xFor(i).toFixed(1)},${yFor(v).toFixed(1)}`).join(' '); + + const poly = doc.createElementNS(SVG_NS, 'polyline'); + poly.setAttribute('points', pointsAttr); + poly.setAttribute('fill', 'none'); + poly.setAttribute('stroke', color); + poly.setAttribute('stroke-width', '2'); + poly.setAttribute('stroke-linejoin', 'round'); + poly.setAttribute('stroke-linecap', 'round'); + poly.classList.add('spend-chart-line'); + svg.appendChild(poly); + + values.forEach((v, i) => { + const dot = doc.createElementNS(SVG_NS, 'circle'); + dot.setAttribute('cx', xFor(i).toFixed(1)); + dot.setAttribute('cy', yFor(v).toFixed(1)); + dot.setAttribute('r', i === values.length - 1 ? '4' : '3'); + dot.setAttribute('fill', color); + dot.classList.add('spend-chart-dot'); + const title = doc.createElementNS(SVG_NS, 'title'); + title.textContent = `${providerLabel(agent)} · ${formatBucketLabel(buckets[i])}: $${v.toFixed(3)}`; + dot.appendChild(title); + svg.appendChild(dot); + }); + + if (labelEligible.has(agent) && values.length > 0) { + const last = values[values.length - 1]; + const label = doc.createElementNS(SVG_NS, 'text'); + label.setAttribute('x', (xFor(values.length - 1) + 6).toFixed(1)); + label.setAttribute('y', (yFor(last) + 4).toFixed(1)); + label.setAttribute('class', 'spend-chart-label'); + label.textContent = `$${last.toFixed(2)}`; + svg.appendChild(label); + } + } + + const tickIdxs = buckets.length > 1 ? [0, Math.floor((buckets.length - 1) / 2), buckets.length - 1] : [0]; + const seenTicks = new Set(); + for (const i of tickIdxs) { + if (seenTicks.has(i)) continue; + seenTicks.add(i); + const t = doc.createElementNS(SVG_NS, 'text'); + t.setAttribute('x', xFor(i).toFixed(1)); + t.setAttribute('y', String(height - 6)); + t.setAttribute('class', 'spend-chart-tick'); + t.setAttribute('text-anchor', i === 0 ? 'start' : (i === buckets.length - 1 ? 'end' : 'middle')); + t.textContent = formatBucketLabel(buckets[i]); + svg.appendChild(t); + } + + wrap.appendChild(svg); + wrap.appendChild(renderProviderLegend(sortedAgents, doc)); + return wrap; +} + +// renderBudgetMeters builds a bar/meter per limited provider showing spend +// vs. its rolling-window cap. Fill color is a status (severity) job — how +// much headroom is left — not an identity job, so it uses the dataviz +// skill's fixed good/warning/critical status palette (deliberately distinct +// from both the --state-* task tokens and the provider categorical palette +// above), always paired with the numeric label so severity never rides on +// color alone. +export function renderBudgetMeters(headrooms, doc = (typeof document !== 'undefined' ? document : null)) { + if (doc == null) return null; + const wrap = doc.createElement('div'); + wrap.className = 'budget-meters'; + + const limited = (headrooms || []).filter(h => h && h.limited); + if (limited.length === 0) { + const empty = doc.createElement('p'); + empty.className = 'task-meta'; + empty.textContent = 'No provider spend limits configured.'; + wrap.appendChild(empty); + return wrap; + } + + for (const h of limited) { + const row = doc.createElement('div'); + row.className = 'budget-meter-row'; + + const label = doc.createElement('div'); + label.className = 'budget-meter-label'; + const name = doc.createElement('span'); + name.textContent = providerLabel(h.provider); + const value = doc.createElement('span'); + value.className = 'budget-meter-value'; + const pctLeft = Math.round((h.fraction_remaining || 0) * 100); + value.textContent = `$${(h.spent_usd || 0).toFixed(2)} / $${(h.limit_usd || 0).toFixed(2)} · ${pctLeft}% left`; + label.append(name, value); + row.appendChild(label); + + const track = doc.createElement('div'); + track.className = 'budget-meter-track'; + const fill = doc.createElement('div'); + fill.className = 'budget-meter-fill'; + const spentPct = h.limit_usd > 0 ? Math.min(100, ((h.spent_usd || 0) / h.limit_usd) * 100) : 0; + fill.style.width = `${spentPct.toFixed(1)}%`; + fill.style.background = budgetStatusColor(h.fraction_remaining || 0); + track.appendChild(fill); + row.appendChild(track); + + wrap.appendChild(row); + } + + return wrap; +} + +function getBudgetWindow() { + return localStorage.getItem('budgetWindow') || '24h'; +} + +function setBudgetWindow(w) { + localStorage.setItem('budgetWindow', w); +} + +async function renderBudgetPanel() { + const panel = document.querySelector('[data-panel="budget"]'); + if (!panel) return; + + const win = getBudgetWindow(); + try { + const [headrooms, funnelRaw, spendRaw] = await Promise.all([ + fetch(`${BASE_PATH}/api/budget`).then(r => r.ok ? r.json() : []), + fetch(`${BASE_PATH}/api/escalation-funnel?window=${encodeURIComponent(win)}`).then(r => r.ok ? r.json() : []), + fetch(`${BASE_PATH}/api/spend-timeseries?window=${encodeURIComponent(win)}`).then(r => r.ok ? r.json() : []), + ]); + + panel.innerHTML = ''; + + // ── Provider spend headroom ── + const budgetSection = document.createElement('div'); + budgetSection.className = 'stats-section'; + const budgetHeading = document.createElement('h2'); + budgetHeading.textContent = 'Provider Spend Headroom'; + budgetSection.appendChild(budgetHeading); + budgetSection.appendChild(renderBudgetMeters(headrooms)); + panel.appendChild(budgetSection); + + // ── Window selector (shared by funnel + spend-over-time below) ── + const windowRow = document.createElement('div'); + windowRow.className = 'dashboard-window-row'; + const windowLabel = document.createElement('label'); + windowLabel.textContent = 'Window: '; + const windowSelect = document.createElement('select'); + windowSelect.className = 'agent-selector dashboard-window-select'; + for (const opt of [['5h', 'Last 5 hours'], ['24h', 'Last 24 hours'], ['7d', 'Last 7 days']]) { + const o = document.createElement('option'); + o.value = opt[0]; + o.textContent = opt[1]; + if (opt[0] === win) o.selected = true; + windowSelect.appendChild(o); + } + windowSelect.addEventListener('change', () => { + setBudgetWindow(windowSelect.value); + renderBudgetPanel(); + }); + windowLabel.appendChild(windowSelect); + windowRow.appendChild(windowLabel); + panel.appendChild(windowRow); + + // ── Escalation funnel ── + const funnelSection = document.createElement('div'); + funnelSection.className = 'stats-section'; + const funnelHeading = document.createElement('h2'); + funnelHeading.textContent = 'Escalation Funnel'; + funnelSection.appendChild(funnelHeading); + const funnelNote = document.createElement('p'); + funnelNote.className = 'task-meta funnel-note'; + funnelNote.textContent = 'Share of executions resolved at each escalation rung (rung 0 = first tier / local-first; higher rungs = escalated to a costlier provider). Non-role-typed tasks always show at rung 0 alongside role-typed tasks resolved there.'; + funnelSection.appendChild(funnelNote); + funnelSection.appendChild(renderEscalationFunnel(computeEscalationFunnel(funnelRaw))); + panel.appendChild(funnelSection); + + // ── Spend over time ── + const spendSection = document.createElement('div'); + spendSection.className = 'stats-section'; + const spendHeading = document.createElement('h2'); + spendHeading.textContent = 'Spend Over Time'; + spendSection.appendChild(spendHeading); + spendSection.appendChild(renderSpendTimeseries(computeSpendSeries(spendRaw))); + panel.appendChild(spendSection); + } catch (err) { + panel.innerHTML = `<div class="panel-fetch-error">Failed to load budget dashboard: ${err.message}</div>`; + } +} + +// ── Role/config management panel (Phase 9b) ─────────────────────────────────── +// Lists every role with at least one role_configs row (GET /api/roles), each +// role's version history (GET /api/roles/{role}/versions), and an Activate +// button on draft versions (POST /api/roles/{role}/activate?version=N) — the +// human-facing side of the Phase 8 retro loop. + +// formatEscalationLadder turns a role.RoleConfig's escalation_ladder into a +// small readable structure (tier index, selection mode, retry budget, and a +// "provider/model" string per candidate) instead of a raw JSON dump. +export function formatEscalationLadder(ladder) { + if (!ladder || ladder.length === 0) return []; + return ladder.map((tier, i) => ({ + tier: i, + mode: tier.selection_mode || 'round_robin', + maxRetries: tier.max_retries || 0, + candidates: (tier.candidates || []).map(c => (c.model ? `${c.provider}/${c.model}` : c.provider)), + })); +} + +function renderEscalationLadderTable(ladder, doc = document) { + const rows = formatEscalationLadder(ladder); + if (rows.length === 0) { + const empty = doc.createElement('p'); + empty.className = 'task-meta'; + empty.textContent = 'No escalation ladder configured.'; + return empty; + } + const list = doc.createElement('ol'); + list.className = 'escalation-ladder-list'; + for (const row of rows) { + const li = doc.createElement('li'); + li.className = 'escalation-ladder-item'; + const tierLabel = doc.createElement('span'); + tierLabel.className = 'escalation-ladder-tier'; + tierLabel.textContent = `Tier ${row.tier}`; + const candidates = doc.createElement('span'); + candidates.className = 'escalation-ladder-candidates'; + candidates.textContent = row.candidates.join(', ') || '(none)'; + const meta = doc.createElement('span'); + meta.className = 'escalation-ladder-meta'; + meta.textContent = `${row.mode}, max ${row.maxRetries} retr${row.maxRetries === 1 ? 'y' : 'ies'} before escalating`; + li.append(tierLabel, candidates, meta); + list.appendChild(li); + } + return list; +} + +async function handleActivateRoleVersionClick(roleName, version, btn) { + btn.disabled = true; + const original = btn.textContent; + btn.textContent = 'Activating…'; + try { + const res = await fetch(`${API_BASE}/api/roles/${encodeURIComponent(roleName)}/activate?version=${version}`, { method: 'POST' }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + await renderRolesPanel(); + } catch (err) { + btn.disabled = false; + btn.textContent = original; + alert(`Failed to activate version ${version}: ${err.message}`); + } +} + +function renderRoleCard(roleName, versions, doc = document) { + const card = doc.createElement('div'); + card.className = 'role-card'; + + const header = doc.createElement('div'); + header.className = 'role-card-header'; + const title = doc.createElement('h3'); + title.textContent = roleName; + header.appendChild(title); + + const active = versions.find(v => v.status === 'active'); + const activeBadge = doc.createElement('span'); + activeBadge.className = 'role-active-badge' + (active ? '' : ' role-active-badge--none'); + activeBadge.textContent = active ? `Active: v${active.version}` : 'No active version'; + header.appendChild(activeBadge); + card.appendChild(header); + + if (active) { + const ladderSection = doc.createElement('div'); + ladderSection.className = 'role-ladder-section'; + const ladderLabel = doc.createElement('div'); + ladderLabel.className = 'panel-section-title'; + ladderLabel.textContent = `Escalation Ladder (v${active.version})`; + ladderSection.appendChild(ladderLabel); + ladderSection.appendChild(renderEscalationLadderTable(active.config && active.config.escalation_ladder, doc)); + card.appendChild(ladderSection); + } + + const list = doc.createElement('div'); + list.className = 'role-version-list'; + const sorted = [...versions].sort((a, b) => b.version - a.version); + for (const v of sorted) { + const row = doc.createElement('div'); + row.className = 'role-version-row role-version-row--' + v.status; + + const vLabel = doc.createElement('span'); + vLabel.className = 'role-version-num'; + vLabel.textContent = `v${v.version}`; + row.appendChild(vLabel); + + const statusBadge = doc.createElement('span'); + statusBadge.className = 'role-version-status role-version-status--' + v.status; + statusBadge.textContent = v.status; + row.appendChild(statusBadge); + + const proposedBy = doc.createElement('span'); + proposedBy.className = 'role-version-meta'; + proposedBy.textContent = v.proposed_by ? `by ${v.proposed_by}` : ''; + row.appendChild(proposedBy); + + const created = doc.createElement('span'); + created.className = 'role-version-meta'; + created.textContent = v.created_at ? formatDate(v.created_at) : ''; + row.appendChild(created); + + if (v.status === 'draft') { + const activateBtn = doc.createElement('button'); + activateBtn.className = 'btn-primary btn-sm'; + activateBtn.textContent = 'Activate'; + activateBtn.addEventListener('click', () => handleActivateRoleVersionClick(roleName, v.version, activateBtn)); + row.appendChild(activateBtn); + } + + list.appendChild(row); + } + card.appendChild(list); + + return card; +} + +async function renderRolesPanel() { + const panel = document.querySelector('[data-panel="roles"]'); + if (!panel) return; + + try { + const namesRes = await fetch(`${BASE_PATH}/api/roles`); + const names = namesRes.ok ? await namesRes.json() : []; + if (!names || names.length === 0) { + panel.innerHTML = '<div class="task-empty">No role configs yet.</div>'; + return; + } + + const versionsByRole = await Promise.all(names.map(name => + fetch(`${BASE_PATH}/api/roles/${encodeURIComponent(name)}/versions`).then(r => r.ok ? r.json() : []), + )); + + panel.innerHTML = ''; + names.forEach((name, i) => { + panel.appendChild(renderRoleCard(name, versionsByRole[i] || [])); + }); + } catch (err) { + panel.innerHTML = `<div class="panel-fetch-error">Failed to load roles: ${err.message}</div>`; + } +} + // ── Tab switching ───────────────────────────────────────────────────────────── function switchTab(name) { diff --git a/web/index.html b/web/index.html index 523edd9..32c029e 100644 --- a/web/index.html +++ b/web/index.html @@ -47,6 +47,8 @@ <button class="tab" data-tab="stories" title="Stories">📋</button> <button class="tab" data-tab="drops" title="Drops">📁</button> <button class="tab" data-tab="stats" title="Stats">📊</button> + <button class="tab" data-tab="budget" title="Budget & Escalation">💰</button> + <button class="tab" data-tab="roles" title="Roles">🎛️</button> <button class="tab" data-tab="settings" title="Settings">⚙️</button> </nav> <main id="app"> @@ -81,6 +83,8 @@ <div class="drops-panel"></div> </div> <div data-panel="stats" hidden></div> + <div data-panel="budget" hidden></div> + <div data-panel="roles" hidden></div> <div data-panel="settings" hidden> <p class="task-meta" style="padding:1rem">Settings coming soon.</p> </div> diff --git a/web/style.css b/web/style.css index b11faf6..a5fefb7 100644 --- a/web/style.css +++ b/web/style.css @@ -16,6 +16,14 @@ --text: #e2e8f0; --text-muted: #94a3b8; --accent: #38bdf8; + + /* Approximate solid chart surface (this app has no opaque surface token — + --surface/--bg are translucent over the rotating background image). + Used as the SVG chart background, unlabeled figure, and marker rings + (see marks-and-anatomy.md's "surface ring" spec) for the Phase 9b + budget/escalation charts; the dataviz palette validator was run against + this exact hex. */ + --chart-surface: #0f172a; } /* Reset */ @@ -2408,3 +2416,294 @@ dialog label select:focus { border-radius: 0; } } + +/* ── Budget & Escalation dashboard (Phase 9b) ────────────────────────────── + Provider identity uses a fixed categorical palette (PROVIDER_COLORS in + app.js), deliberately distinct from --state-* — see app.js's doc comment + for why and how it was validated. ── */ + +.provider-legend { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 1rem; + margin-top: 0.6rem; + font-size: 0.75rem; + color: var(--text-muted); +} + +.provider-legend-item { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.provider-legend-swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; +} + +/* Budget meters */ +.budget-meters { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.budget-meter-row { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.budget-meter-label { + display: flex; + justify-content: space-between; + font-size: 0.82rem; +} + +.budget-meter-label > span:first-child { + font-weight: 600; + text-transform: capitalize; +} + +.budget-meter-value { + color: var(--text-muted); + font-size: 0.78rem; +} + +.budget-meter-track { + height: 10px; + border-radius: 999px; + background: var(--border); + overflow: hidden; +} + +.budget-meter-fill { + height: 100%; + border-radius: 999px; + transition: width 0.2s; +} + +/* Window selector shared by the funnel + spend-over-time charts */ +.dashboard-window-row { + margin: 0.5rem 0 1.5rem; + font-size: 0.85rem; + color: var(--text-muted); +} + +.dashboard-window-select { + margin-left: 0.35rem; +} + +.funnel-note { + margin-bottom: 0.75rem; + max-width: 60ch; +} + +/* Escalation funnel */ +.funnel-chart { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.funnel-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.funnel-row-label { + width: 60px; + flex-shrink: 0; + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); +} + +.funnel-track-outer { + flex: 1; + min-width: 0; +} + +.funnel-track { + display: flex; + height: 22px; + border-radius: 4px; + overflow: hidden; + min-width: 4px; +} + +.funnel-seg { + height: 100%; +} + +/* The 2px surface-color gap between stacked segments (see + marks-and-anatomy.md's "surface gap" spec) — border rather than margin so + it doesn't shrink the flex-grow proportions. */ +.funnel-seg + .funnel-seg { + border-left: 2px solid var(--chart-surface); +} + +.funnel-count { + width: 90px; + flex-shrink: 0; + text-align: right; + font-size: 0.78rem; + color: var(--text-muted); + white-space: nowrap; +} + +/* Spend-over-time line chart */ +.spend-chart-wrap { + margin-top: 0.5rem; +} + +.spend-chart-svg { + background: var(--chart-surface); + border-radius: 8px; + display: block; +} + +.spend-chart-axis { + stroke: var(--border); + stroke-width: 1; +} + +.spend-chart-tick { + fill: var(--text-muted); + font-size: 9px; +} + +.spend-chart-label { + fill: var(--text-muted); + font-size: 9px; +} + +.spend-chart-dot { + stroke: var(--chart-surface); + stroke-width: 2; +} + +/* Role/config management panel */ +.role-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; +} + +.role-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.role-card-header h3 { + font-size: 1rem; + font-weight: 700; + text-transform: capitalize; +} + +.role-active-badge { + font-size: 0.72rem; + font-weight: 600; + padding: 0.2em 0.6em; + border-radius: 999px; + background: var(--state-completed); + color: #0f172a; + white-space: nowrap; +} + +.role-active-badge--none { + background: var(--border); + color: var(--text-muted); +} + +.role-ladder-section { + margin-bottom: 0.85rem; +} + +.escalation-ladder-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-top: 0.35rem; +} + +.escalation-ladder-item { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; + font-size: 0.82rem; + padding: 0.35rem 0.6rem; + background: rgba(148, 163, 184, 0.08); + border-radius: 6px; +} + +.escalation-ladder-tier { + font-weight: 700; + min-width: 3.5rem; +} + +.escalation-ladder-candidates { + font-family: ui-monospace, monospace; + font-size: 0.78rem; +} + +.escalation-ladder-meta { + color: var(--text-muted); + font-size: 0.75rem; + margin-left: auto; +} + +.role-version-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.role-version-row { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.82rem; + padding: 0.4rem 0.1rem; + border-top: 1px solid var(--border); +} + +.role-version-num { + font-weight: 700; + min-width: 2.5rem; +} + +.role-version-status { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.15em 0.5em; + border-radius: 999px; + color: #0f172a; +} + +.role-version-status--active { background: var(--state-completed); } +.role-version-status--draft { background: var(--state-queued); } +.role-version-status--retired { background: var(--border); color: var(--text-muted); } + +.role-version-meta { + color: var(--text-muted); + font-size: 0.75rem; +} + +.role-version-row .btn-primary.btn-sm { + margin-left: auto; +} diff --git a/web/test/dashboard.test.mjs b/web/test/dashboard.test.mjs new file mode 100644 index 0000000..0f96c08 --- /dev/null +++ b/web/test/dashboard.test.mjs @@ -0,0 +1,154 @@ +// dashboard.test.mjs — Unit tests for the Phase 9b budget/escalation +// dashboard's pure logic: reshaping the raw escalation-funnel and +// spend-timeseries aggregate rows, provider color/label lookup, and +// escalation-ladder formatting for the role/config panel. Mirrors +// stories-board.test.mjs's convention of testing pure computation only — +// DOM-building render functions are verified against a real running server +// (see Phase 9b's manual verification notes), not unit-tested here. +// +// Run with: node --test web/test/dashboard.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + computeEscalationFunnel, + computeSpendSeries, + formatBucketLabel, + colorForProvider, + providerLabel, + formatEscalationLadder, +} from '../app.js'; + +describe('computeEscalationFunnel', () => { + it('groups by rung, summing counts/cost and collecting per-agent breakdown', () => { + const buckets = [ + { rung: 0, agent: 'local', count: 5, cost_usd: 0 }, + { rung: 0, agent: 'anthropic', count: 1, cost_usd: 0.02 }, + { rung: 1, agent: 'anthropic', count: 2, cost_usd: 0.10 }, + ]; + const { rungs, grandTotal } = computeEscalationFunnel(buckets); + assert.equal(grandTotal, 8); + assert.equal(rungs.length, 2); + assert.equal(rungs[0].rung, 0); + assert.equal(rungs[0].total, 6); + assert.equal(rungs[0].byAgent.length, 2); + assert.equal(rungs[1].rung, 1); + assert.equal(rungs[1].total, 2); + assert.equal(rungs[1].cost, 0.10); + }); + + it('sorts rungs ascending regardless of input order', () => { + const buckets = [ + { rung: 2, agent: 'openai', count: 1, cost_usd: 0.5 }, + { rung: 0, agent: 'local', count: 3, cost_usd: 0 }, + { rung: 1, agent: 'google', count: 2, cost_usd: 0.1 }, + ]; + const { rungs } = computeEscalationFunnel(buckets); + assert.deepEqual(rungs.map(r => r.rung), [0, 1, 2]); + }); + + it('returns an empty rungs array and zero grand total for no data', () => { + const { rungs, grandTotal } = computeEscalationFunnel([]); + assert.deepEqual(rungs, []); + assert.equal(grandTotal, 0); + }); + + it('handles a missing/null buckets argument gracefully', () => { + const { rungs, grandTotal } = computeEscalationFunnel(undefined); + assert.deepEqual(rungs, []); + assert.equal(grandTotal, 0); + }); +}); + +describe('computeSpendSeries', () => { + it('aligns per-provider series over a shared sorted bucket axis, filling gaps with 0', () => { + const points = [ + { bucket: '2026-07-01', agent: 'local', cost_usd: 1.0 }, + { bucket: '2026-07-02', agent: 'local', cost_usd: 2.0 }, + { bucket: '2026-07-02', agent: 'anthropic', cost_usd: 0.5 }, + ]; + const { buckets, agents, series, maxCost } = computeSpendSeries(points); + assert.deepEqual(buckets, ['2026-07-01', '2026-07-02']); + assert.deepEqual(agents, ['anthropic', 'local']); + assert.deepEqual(series.local, [1.0, 2.0]); + assert.deepEqual(series.anthropic, [0, 0.5]); + assert.equal(maxCost, 2.0); + }); + + it('returns empty structures for no data', () => { + const { buckets, agents, series, maxCost } = computeSpendSeries([]); + assert.deepEqual(buckets, []); + assert.deepEqual(agents, []); + assert.deepEqual(series, {}); + assert.equal(maxCost, 0); + }); +}); + +describe('formatBucketLabel', () => { + it('formats an hourly RFC3339 bucket with the hour', () => { + const label = formatBucketLabel('2026-07-02T14:00:00Z'); + assert.match(label, /\d/); // exact format is locale-dependent; just confirm it's non-empty and date-like + }); + + it('formats a daily YYYY-MM-DD bucket', () => { + const label = formatBucketLabel('2026-07-02'); + assert.match(label, /\d/); + }); + + it('returns empty string for falsy input', () => { + assert.equal(formatBucketLabel(''), ''); + assert.equal(formatBucketLabel(null), ''); + }); +}); + +describe('colorForProvider / providerLabel', () => { + it('returns a distinct hex color for each known provider', () => { + const providers = ['local', 'anthropic', 'google', 'groq', 'openrouter', 'openai']; + const colors = new Set(providers.map(colorForProvider)); + assert.equal(colors.size, providers.length, 'expected all known providers to have distinct colors'); + for (const c of colors) assert.match(c, /^#[0-9a-f]{6}$/i); + }); + + it('falls back to a muted color for an unknown provider', () => { + assert.match(colorForProvider('some-future-provider'), /^#[0-9a-f]{6}$/i); + }); + + it('capitalizes provider labels', () => { + assert.equal(providerLabel('anthropic'), 'Anthropic'); + assert.equal(providerLabel('local'), 'Local'); + }); + + it('labels a missing/empty provider as Unknown', () => { + assert.equal(providerLabel(''), 'Unknown'); + assert.equal(providerLabel(undefined), 'Unknown'); + }); +}); + +describe('formatEscalationLadder', () => { + it('formats each tier with index, mode, retry budget, and candidate strings', () => { + const ladder = [ + { candidates: [{ provider: 'local', model: 'llama3.1:8b' }], selection_mode: 'single', max_retries: 2 }, + { candidates: [{ provider: 'anthropic', model: 'claude-haiku' }, { provider: 'google', model: 'gemini-flash' }], max_retries: 0 }, + ]; + const rows = formatEscalationLadder(ladder); + assert.equal(rows.length, 2); + assert.equal(rows[0].tier, 0); + assert.equal(rows[0].mode, 'single'); + assert.equal(rows[0].maxRetries, 2); + assert.deepEqual(rows[0].candidates, ['local/llama3.1:8b']); + + assert.equal(rows[1].tier, 1); + assert.equal(rows[1].mode, 'round_robin'); // default when unset + assert.deepEqual(rows[1].candidates, ['anthropic/claude-haiku', 'google/gemini-flash']); + }); + + it('formats a provider-only candidate (no model) without a trailing slash', () => { + const rows = formatEscalationLadder([{ candidates: [{ provider: 'local' }], max_retries: 0 }]); + assert.deepEqual(rows[0].candidates, ['local']); + }); + + it('returns an empty array for an empty/missing ladder', () => { + assert.deepEqual(formatEscalationLadder([]), []); + assert.deepEqual(formatEscalationLadder(undefined), []); + }); +}); |
