summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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
-rw-r--r--web/app.js636
-rw-r--r--web/index.html4
-rw-r--r--web/style.css299
-rw-r--r--web/test/dashboard.test.mjs154
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 {
diff --git a/web/app.js b/web/app.js
index 6f45253..894aaec 100644
--- a/web/app.js
+++ b/web/app.js
@@ -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 &amp; 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), []);
+ });
+});