summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/roles.go121
-rw-r--r--internal/api/roles_test.go177
-rw-r--r--internal/api/server.go3
3 files changed, 301 insertions, 0 deletions
diff --git a/internal/api/roles.go b/internal/api/roles.go
new file mode 100644
index 0000000..700bec4
--- /dev/null
+++ b/internal/api/roles.go
@@ -0,0 +1,121 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/role"
+ "github.com/thepeterstone/claudomator/internal/storage"
+)
+
+// roleVersionView is the JSON shape returned for a single role_configs
+// version — decodes the stored config_json back into a role.RoleConfig so
+// clients get structured fields rather than an opaque string.
+type roleVersionView struct {
+ ID string `json:"id"`
+ Role string `json:"role"`
+ Version int `json:"version"`
+ Status string `json:"status"`
+ Config role.RoleConfig `json:"config"`
+ CreatedAt string `json:"created_at"`
+ ActivatedAt string `json:"activated_at,omitempty"`
+ RetiredAt string `json:"retired_at,omitempty"`
+ ProposedBy string `json:"proposed_by,omitempty"`
+}
+
+// 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
+// number for that role. Mirrors handleCreateProject's shape (see
+// internal/api/projects.go).
+func (s *Server) handleCreateRoleVersion(w http.ResponseWriter, r *http.Request) {
+ roleName := r.PathValue("role")
+ if roleName == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "role is required"})
+ return
+ }
+
+ var input struct {
+ role.RoleConfig
+ ProposedBy string `json:"proposed_by"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ input.RoleConfig.Role = roleName
+
+ proposedBy := input.ProposedBy
+ if proposedBy == "" {
+ proposedBy = "human"
+ }
+
+ configJSON, err := json.Marshal(input.RoleConfig)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+
+ created, err := s.store.CreateRoleConfig(roleName, string(configJSON), proposedBy)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusCreated, toRoleVersionView(created))
+}
+
+// handleListRoleVersions handles GET /api/roles/{role}/versions.
+func (s *Server) handleListRoleVersions(w http.ResponseWriter, r *http.Request) {
+ roleName := r.PathValue("role")
+ rows, err := s.store.ListRoleConfigVersions(roleName)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ views := make([]*roleVersionView, 0, len(rows))
+ for _, row := range rows {
+ views = append(views, toRoleVersionView(row))
+ }
+ writeJSON(w, http.StatusOK, views)
+}
+
+// handleActivateRoleVersion handles POST /api/roles/{role}/activate?version=N.
+func (s *Server) handleActivateRoleVersion(w http.ResponseWriter, r *http.Request) {
+ roleName := r.PathValue("role")
+ versionStr := r.URL.Query().Get("version")
+ version, err := strconv.Atoi(versionStr)
+ if err != nil || version < 1 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid or missing version query param"})
+ return
+ }
+ if err := s.store.ActivateRoleConfigVersion(roleName, version); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "message": "role config version activated",
+ "role": roleName,
+ "version": version,
+ })
+}
+
+func toRoleVersionView(row *storage.RoleConfigRow) *roleVersionView {
+ v := &roleVersionView{
+ ID: row.ID,
+ Role: row.Role,
+ Version: row.Version,
+ Status: row.Status,
+ CreatedAt: row.CreatedAt.Format(time.RFC3339),
+ ProposedBy: row.ProposedBy,
+ }
+ if row.ActivatedAt != nil {
+ v.ActivatedAt = row.ActivatedAt.Format(time.RFC3339)
+ }
+ if row.RetiredAt != nil {
+ v.RetiredAt = row.RetiredAt.Format(time.RFC3339)
+ }
+ _ = json.Unmarshal([]byte(row.ConfigJSON), &v.Config)
+ return v
+}
diff --git a/internal/api/roles_test.go b/internal/api/roles_test.go
new file mode 100644
index 0000000..97afdfe
--- /dev/null
+++ b/internal/api/roles_test.go
@@ -0,0 +1,177 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/role"
+)
+
+// 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.
+func TestServer_CreateRoleVersion_CreatesDraft(t *testing.T) {
+ srv, _ := testServer(t)
+
+ body, _ := json.Marshal(map[string]any{
+ "system_prompt": "You are a careful coder.",
+ "escalation_ladder": []map[string]any{
+ {
+ "candidates": []map[string]any{{"provider": "local", "model": "llama3.1:8b"}},
+ "selection_mode": "single",
+ "max_retries": 2,
+ },
+ },
+ })
+ req := httptest.NewRequest("POST", "/api/roles/coder/versions", bytes.NewReader(body))
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var got struct {
+ Role string `json:"role"`
+ Version int `json:"version"`
+ Status string `json:"status"`
+ Config role.RoleConfig `json:"config"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if got.Role != "coder" {
+ t.Errorf("role = %q, want %q", got.Role, "coder")
+ }
+ if got.Version != 1 {
+ t.Errorf("version = %d, want 1", got.Version)
+ }
+ if got.Status != "draft" {
+ t.Errorf("status = %q, want draft", got.Status)
+ }
+ if got.Config.SystemPrompt != "You are a careful coder." {
+ t.Errorf("system_prompt round-trip failed: got %q", got.Config.SystemPrompt)
+ }
+ if len(got.Config.EscalationLadder) != 1 {
+ t.Fatalf("expected 1 tier, got %d", len(got.Config.EscalationLadder))
+ }
+}
+
+// TestServer_ListRoleVersions_ReturnsAllVersions posts two versions and
+// confirms the list endpoint returns both, ordered by version.
+func TestServer_ListRoleVersions_ReturnsAllVersions(t *testing.T) {
+ srv, _ := testServer(t)
+
+ for i := 0; i < 2; i++ {
+ body, _ := json.Marshal(map[string]any{"system_prompt": "v"})
+ req := httptest.NewRequest("POST", "/api/roles/coder/versions", bytes.NewReader(body))
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("create version %d: expected 201, got %d", i, w.Code)
+ }
+ }
+
+ req := httptest.NewRequest("GET", "/api/roles/coder/versions", 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 got []struct {
+ Version int `json:"version"`
+ Status string `json:"status"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("expected 2 versions, got %d", len(got))
+ }
+ if got[0].Version != 1 || got[1].Version != 2 {
+ t.Errorf("expected versions [1,2], got [%d,%d]", got[0].Version, got[1].Version)
+ }
+ for _, v := range got {
+ if v.Status != "draft" {
+ t.Errorf("version %d: expected status draft, got %q", v.Version, v.Status)
+ }
+ }
+}
+
+// TestServer_ActivateRoleVersion_ActivatesAndRetiresPrior exercises the full
+// create -> activate -> create -> activate cycle over HTTP, confirming the
+// exactly-one-active-row invariant surfaces correctly through the API.
+func TestServer_ActivateRoleVersion_ActivatesAndRetiresPrior(t *testing.T) {
+ srv, store := testServer(t)
+
+ post := func(body string) {
+ req := httptest.NewRequest("POST", "/api/roles/coder/versions", bytes.NewReader([]byte(body)))
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("create version: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ }
+ post(`{"system_prompt":"v1"}`)
+ post(`{"system_prompt":"v2"}`)
+
+ activate := func(version int) int {
+ req := httptest.NewRequest("POST", "/api/roles/coder/activate?version="+strconv.Itoa(version), nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ return w.Code
+ }
+
+ if code := activate(1); code != http.StatusOK {
+ t.Fatalf("activate v1: expected 200, got %d", code)
+ }
+ active, err := store.GetActiveRoleConfig("coder")
+ if err != nil {
+ t.Fatalf("GetActiveRoleConfig: %v", err)
+ }
+ if active.Version != 1 {
+ t.Fatalf("expected active version 1, got %d", active.Version)
+ }
+
+ if code := activate(2); code != http.StatusOK {
+ t.Fatalf("activate v2: expected 200, got %d", code)
+ }
+ active, err = store.GetActiveRoleConfig("coder")
+ if err != nil {
+ t.Fatalf("GetActiveRoleConfig: %v", err)
+ }
+ if active.Version != 2 {
+ t.Fatalf("expected active version 2 after activating it, got %d", active.Version)
+ }
+
+ versions, err := store.ListRoleConfigVersions("coder")
+ if err != nil {
+ t.Fatalf("ListRoleConfigVersions: %v", err)
+ }
+ activeCount := 0
+ for _, v := range versions {
+ if v.Status == "active" {
+ activeCount++
+ }
+ }
+ if activeCount != 1 {
+ t.Fatalf("expected exactly 1 active version, got %d", activeCount)
+ }
+}
+
+// TestServer_ActivateRoleVersion_UnknownVersion_Returns400 confirms
+// activating a version that doesn't exist is a client error, not a panic or
+// 500.
+func TestServer_ActivateRoleVersion_UnknownVersion_Returns400(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("POST", "/api/roles/coder/activate?version=99", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
+ }
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 35128d0..0e5b185 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -172,6 +172,9 @@ func (s *Server) routes() {
s.mux.HandleFunc("POST /api/projects", s.handleCreateProject)
s.mux.HandleFunc("GET /api/projects/{id}", s.handleGetProject)
s.mux.HandleFunc("PUT /api/projects/{id}", s.handleUpdateProject)
+ 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/health", s.handleHealth)
s.mux.HandleFunc("GET /api/version", s.handleVersion)
s.mux.HandleFunc("POST /api/webhooks/github", s.handleGitHubWebhook)