diff options
Diffstat (limited to 'internal/api')
| -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 |
5 files changed, 272 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) |
