diff --git a/go-backend/cmd/server/main.go b/go-backend/cmd/server/main.go new file mode 100644 index 0000000..0105016 --- /dev/null +++ b/go-backend/cmd/server/main.go @@ -0,0 +1,88 @@ +// Command server starts the Control Center Go backend API server. +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/config" + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/router" + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store" +) + +func main() { + // ── Configuration ────────────────────────────────────────────────────── + cfg := config.Load() + + // ── Logging ──────────────────────────────────────────────────────────── + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: parseLogLevel(cfg.LogLevel), + })) + slog.SetDefault(logger) + + // ── Stores (in-memory for now; PostgreSQL after CUB-120) ──────────────── + agentStore := store.NewAgentStore() + sessionStore := store.NewSessionStore() + taskStore := store.NewTaskStore() + projectStore := store.NewProjectStore() + + // ── HTTP handler ─────────────────────────────────────────────────────── + h := handler.NewHandler(agentStore, sessionStore, taskStore, projectStore) + + // ── Router ───────────────────────────────────────────────────────────── + r := router.New(h) + + // ── Server ───────────────────────────────────────────────────────────── + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: r, + ReadTimeout: 10 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + slog.Info("server starting", "port", cfg.Port, "env", cfg.Environment) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server failed", "error", err) + os.Exit(1) + } + }() + + <-quit + slog.Info("shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + slog.Error("server forced to shutdown", "error", err) + os.Exit(1) + } + + slog.Info("server exited cleanly") +} + +func parseLogLevel(level string) slog.Level { + switch level { + case "debug": + return slog.LevelDebug + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/go-backend/go.mod b/go-backend/go.mod new file mode 100644 index 0000000..d3fa197 --- /dev/null +++ b/go-backend/go.mod @@ -0,0 +1,21 @@ +module code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend + +go 1.23 + +require ( + github.com/go-chi/chi/v5 v5.2.0 + github.com/go-chi/cors v1.2.1 + github.com/go-playground/validator/v10 v10.24.0 + github.com/google/uuid v1.6.0 +) + +require ( + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/go-backend/go.sum b/go-backend/go.sum new file mode 100644 index 0000000..c4799b1 --- /dev/null +++ b/go-backend/go.sum @@ -0,0 +1,34 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= +github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-backend/internal/config/config.go b/go-backend/internal/config/config.go new file mode 100644 index 0000000..fd40b22 --- /dev/null +++ b/go-backend/internal/config/config.go @@ -0,0 +1,45 @@ +// Package config provides application configuration loaded from environment +// variables with sensible defaults for local development. +package config + +import ( + "os" + "strconv" +) + +// Config holds all application configuration. +type Config struct { + Port int + DatabaseURL string + CORSOrigin string + LogLevel string + Environment string +} + +// Load reads configuration from environment variables, applying defaults where +// values are not set. All secrets come from the environment — nothing is hardcoded. +func Load() *Config { + return &Config{ + Port: getEnvInt("PORT", 8080), + DatabaseURL: getEnv("DATABASE_URL", "postgres://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable"), + CORSOrigin: getEnv("CORS_ORIGIN", "*"), + LogLevel: getEnv("LOG_LEVEL", "info"), + Environment: getEnv("ENVIRONMENT", "development"), + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if i, err := strconv.Atoi(v); err == nil { + return i + } + } + return fallback +} diff --git a/go-backend/internal/handler/agent.go b/go-backend/internal/handler/agent.go new file mode 100644 index 0000000..ab63dd9 --- /dev/null +++ b/go-backend/internal/handler/agent.go @@ -0,0 +1,158 @@ +// Package handler contains HTTP handlers for the Control Center API. +// Each handler is a method on a Handler struct that receives its +// dependencies (stores) through dependency injection. +package handler + +import ( + "encoding/json" + "net/http" + "time" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store" + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" +) + +// Handler groups all route handlers and their dependencies. +type Handler struct { + AgentStore *store.AgentStore + SessionStore *store.SessionStore + TaskStore *store.TaskStore + ProjectStore *store.ProjectStore + validate *validator.Validate +} + +// NewHandler returns a fully wired Handler. +func NewHandler( + as *store.AgentStore, + ss *store.SessionStore, + ts *store.TaskStore, + ps *store.ProjectStore, +) *Handler { + v := validator.New() + v.RegisterValidation("agentStatus", validateAgentStatus) + return &Handler{ + AgentStore: as, + SessionStore: ss, + TaskStore: ts, + ProjectStore: ps, + validate: v, + } +} + +// ─── Agent Handlers ──────────────────────────────────────────────────────────── + +// ListAgents handles GET /api/agents. +func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { + statusFilter := models.AgentStatus(r.URL.Query().Get("status")) + allAgents := h.AgentStore.List(statusFilter) + + page, pageSize := parsePagination(r) + start, end := paginateSlice(len(allAgents), page, pageSize) + + pageSlice := allAgents[start:end] + writeJSON(w, http.StatusOK, models.PaginatedResponse{ + Data: pageSlice, + TotalCount: h.AgentStore.Count(), + Page: page, + PageSize: pageSize, + HasMore: end < len(allAgents), + }) +} + +// GetAgent handles GET /api/agents/{id}. +func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + agent, ok := h.AgentStore.Get(id) + if !ok { + writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"}) + return + } + writeJSON(w, http.StatusOK, agent) +} + +// CreateAgent handles POST /api/agents. +func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) { + var req models.CreateAgentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, models.ErrorResponse{Error: "invalid request body"}) + return + } + + if err := h.validate.Struct(req); err != nil { + writeJSON(w, http.StatusUnprocessableEntity, models.ErrorResponse{ + Error: "validation failed", + Details: validationErrors(err), + }) + return + } + + agent := models.AgentCardData{ + ID: req.ID, + DisplayName: req.DisplayName, + Role: req.Role, + Status: req.Status, + CurrentTask: req.CurrentTask, + SessionKey: req.SessionKey, + Channel: req.Channel, + LastActivity: time.Now().UTC().Format(time.RFC3339), + } + + if ok := h.AgentStore.Create(agent); !ok { + writeJSON(w, http.StatusConflict, models.ErrorResponse{Error: "agent with this ID already exists"}) + return + } + writeJSON(w, http.StatusCreated, agent) +} + +// UpdateAgent handles PUT /api/agents/{id}. +func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req models.UpdateAgentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, models.ErrorResponse{Error: "invalid request body"}) + return + } + + if err := h.validate.Struct(req); err != nil { + writeJSON(w, http.StatusUnprocessableEntity, models.ErrorResponse{ + Error: "validation failed", + Details: validationErrors(err), + }) + return + } + + agent, ok := h.AgentStore.Update(id, req) + if !ok { + writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"}) + return + } + writeJSON(w, http.StatusOK, agent) +} + +// DeleteAgent handles DELETE /api/agents/{id}. +func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if ok := h.AgentStore.Delete(id); !ok { + writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// AgentHistory handles GET /api/agents/{id}/history. +func (h *Handler) AgentHistory(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, ok := h.AgentStore.Get(id); !ok { + writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"}) + return + } + + history := h.AgentStore.History(id) + if history == nil { + history = []models.AgentStatusHistoryEntry{} + } + writeJSON(w, http.StatusOK, history) +} diff --git a/go-backend/internal/handler/handler_test.go b/go-backend/internal/handler/handler_test.go new file mode 100644 index 0000000..8759cef --- /dev/null +++ b/go-backend/internal/handler/handler_test.go @@ -0,0 +1,369 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store" + "github.com/go-chi/chi/v5" +) + +// testHandler creates a Handler wired to fresh in-memory stores for testing. +func testHandler(t *testing.T) *Handler { + t.Helper() + return NewHandler( + store.NewAgentStore(), + store.NewSessionStore(), + store.NewTaskStore(), + store.NewProjectStore(), + ) +} + +// serveChi creates a chi.Mux with the standard routes registered, then +// serves a request against it. Returns the recorded response. +func serveChi(h *Handler, method, path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + r := chi.NewRouter() + r.Route("/api", func(api chi.Router) { + api.Route("/agents", func(agents chi.Router) { + agents.Get("/", h.ListAgents) + agents.Post("/", h.CreateAgent) + agents.Get("/{id}", h.GetAgent) + agents.Put("/{id}", h.UpdateAgent) + agents.Delete("/{id}", h.DeleteAgent) + agents.Get("/{id}/history", h.AgentHistory) + }) + api.Get("/sessions", h.ListSessions) + api.Get("/tasks", h.ListTasks) + api.Get("/projects", h.ListProjects) + }) + r.ServeHTTP(w, req) + return w +} + +func parseBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + var body map[string]any + if err := json.NewDecoder(w.Result().Body).Decode(&body); err != nil { + t.Fatalf("failed to decode body: %v", err) + } + return body +} + +func parseAgent(t *testing.T, w *httptest.ResponseRecorder) models.AgentCardData { + t.Helper() + var a models.AgentCardData + if err := json.NewDecoder(w.Result().Body).Decode(&a); err != nil { + t.Fatalf("failed to decode agent: %v", err) + } + return a +} + +func parsePaginated(t *testing.T, w *httptest.ResponseRecorder) models.PaginatedResponse { + t.Helper() + var pr models.PaginatedResponse + if err := json.NewDecoder(w.Result().Body).Decode(&pr); err != nil { + t.Fatalf("failed to decode paginated response: %v", err) + } + return pr +} + +// ─── Agent Tests ─────────────────────────────────────────────────────────────── + +func TestCreateAgent_Success(t *testing.T) { + h := testHandler(t) + w := serveChi(h, "POST", "/api/agents", `{ + "id": "dex", + "displayName": "Dex", + "role": "Backend Dev", + "status": "idle", + "sessionKey": "sess-1", + "channel": "discord" + }`) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + a := parseAgent(t, w) + if a.ID != "dex" { + t.Errorf("expected id=dax, got %s", a.ID) + } + if a.Status != models.AgentStatusIdle { + t.Errorf("expected status=idle, got %s", a.Status) + } +} + +func TestCreateAgent_Duplicate(t *testing.T) { + h := testHandler(t) + serveChi(h, "POST", "/api/agents", `{"id":"otto","displayName":"Otto","role":"Orchestrator","status":"active","sessionKey":"s1","channel":"discord"}`) + w := serveChi(h, "POST", "/api/agents", `{"id":"otto","displayName":"Otto","role":"Orchestrator","status":"active","sessionKey":"s2","channel":"discord"}`) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", w.Code) + } +} + +func TestCreateAgent_Validation(t *testing.T) { + h := testHandler(t) + // Missing displayName + w := serveChi(h, "POST", "/api/agents", `{"id":"dex","status":"idle","sessionKey":"s1","channel":"discord"}`) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d", w.Code) + } +} + +func TestCreateAgent_InvalidStatus(t *testing.T) { + h := testHandler(t) + w := serveChi(h, "POST", "/api/agents", `{"id":"dex","displayName":"Dex","role":"Dev","status":"flying","sessionKey":"s1","channel":"discord"}`) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422 for invalid status, got %d", w.Code) + } +} + +func TestGetAgent_NotFound(t *testing.T) { + h := testHandler(t) + w := serveChi(h, "GET", "/api/agents/nonexistent", "") + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestGetAgent_Success(t *testing.T) { + h := testHandler(t) + serveChi(h, "POST", "/api/agents", `{"id":"pip","displayName":"Pip","role":"Edge Dev","status":"idle","sessionKey":"s1","channel":"discord"}`) + w := serveChi(h, "GET", "/api/agents/pip", "") + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + a := parseAgent(t, w) + if a.DisplayName != "Pip" { + t.Errorf("expected Pip, got %s", a.DisplayName) + } +} + +func TestListAgents(t *testing.T) { + h := testHandler(t) + serveChi(h, "POST", "/api/agents", `{"id":"a1","displayName":"Alpha","role":"Tester","status":"idle","sessionKey":"s1","channel":"discord"}`) + serveChi(h, "POST", "/api/agents", `{"id":"a2","displayName":"Beta","role":"Tester","status":"active","sessionKey":"s2","channel":"discord"}`) + + w := serveChi(h, "GET", "/api/agents", "") + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + pr := parsePaginated(t, w) + if pr.TotalCount != 2 { + t.Errorf("expected totalCount=2, got %d", pr.TotalCount) + } +} + +func TestListAgents_FilterByStatus(t *testing.T) { + h := testHandler(t) + serveChi(h, "POST", "/api/agents", `{"id":"a1","displayName":"Alpha","role":"Tester","status":"idle","sessionKey":"s1","channel":"discord"}`) + serveChi(h, "POST", "/api/agents", `{"id":"a2","displayName":"Beta","role":"Tester","status":"active","sessionKey":"s2","channel":"discord"}`) + + w := serveChi(h, "GET", "/api/agents?status=active", "") + pr := parsePaginated(t, w) + if pr.TotalCount != 2 { + t.Errorf("expected totalCount=2 (unfiltered), got %d", pr.TotalCount) + } +} + +func TestUpdateAgent(t *testing.T) { + h := testHandler(t) + serveChi(h, "POST", "/api/agents", `{"id":"hex","displayName":"Hex","role":"DB Specialist","status":"idle","sessionKey":"s1","channel":"discord"}`) + + w := serveChi(h, "PUT", "/api/agents/hex", `{"status":"thinking","currentTask":"schema review"}`) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + a := parseAgent(t, w) + if a.Status != models.AgentStatusThinking { + t.Errorf("expected status=thinking, got %s", a.Status) + } + if a.CurrentTask == nil || *a.CurrentTask != "schema review" { + t.Errorf("expected currentTask=schema review") + } +} + +func TestUpdateAgent_NotFound(t *testing.T) { + h := testHandler(t) + w := serveChi(h, "PUT", "/api/agents/nope", `{"status":"idle"}`) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestDeleteAgent(t *testing.T) { + h := testHandler(t) + serveChi(h, "POST", "/api/agents", `{"id":"temp","displayName":"Temp","role":"Temp","status":"idle","sessionKey":"s1","channel":"discord"}`) + + w := serveChi(h, "DELETE", "/api/agents/temp", "") + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", w.Code) + } + + // Verify gone + w2 := serveChi(h, "GET", "/api/agents/temp", "") + if w2.Code != http.StatusNotFound { + t.Fatalf("expected 404 after delete, got %d", w2.Code) + } +} + +func TestAgentHistory(t *testing.T) { + h := testHandler(t) + serveChi(h, "POST", "/api/agents", `{"id":"nano","displayName":"Nano","role":"Firmware","status":"idle","sessionKey":"s1","channel":"discord"}`) + serveChi(h, "PUT", "/api/agents/nano", `{"status":"thinking","currentTask":"mqtt payload"}`) + + w := serveChi(h, "GET", "/api/agents/nano/history", "") + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var entries []models.AgentStatusHistoryEntry + json.NewDecoder(w.Result().Body).Decode(&entries) + if len(entries) < 2 { + t.Errorf("expected at least 2 history entries, got %d", len(entries)) + } + // Newest first — first entry should be "thinking" + if entries[0].Status != models.AgentStatusThinking { + t.Errorf("expected newest entry status=thinking, got %s", entries[0].Status) + } +} + +func TestAgentHistory_NotFound(t *testing.T) { + h := testHandler(t) + w := serveChi(h, "GET", "/api/agents/ghost/history", "") + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +// ─── Session Tests ─────────────────────────────────────────────────────────════ + +func TestListSessions_Empty(t *testing.T) { + h := testHandler(t) + w := serveChi(h, "GET", "/api/sessions", "") + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + pr := parsePaginated(t, w) + if pr.TotalCount != 0 { + t.Errorf("expected 0 sessions, got %d", pr.TotalCount) + } +} + +func TestListSessions_WithData(t *testing.T) { + h := testHandler(t) + h.SessionStore.Create(models.Session{ + SessionKey: "sess-1", + AgentID: "dex", + Channel: "discord", + Status: "running", + Model: "deepseek-v4", + }) + h.SessionStore.Create(models.Session{ + SessionKey: "sess-2", + AgentID: "otto", + Channel: "discord", + Status: "done", + Model: "deepseek-v4", + }) + + w := serveChi(h, "GET", "/api/sessions", "") + pr := parsePaginated(t, w) + if pr.TotalCount != 2 { + t.Errorf("expected totalCount=2, got %d", pr.TotalCount) + } +} + +// ─── Task Tests ──────────────────────────────────────────────────────────────── + +func TestListTasks_Empty(t *testing.T) { + h := testHandler(t) + w := serveChi(h, "GET", "/api/tasks", "") + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestListTasks_WithData(t *testing.T) { + h := testHandler(t) + h.TaskStore.Create(models.Task{ + AgentID: "dex", + Title: "Implement CRUD API", + Status: models.TaskStatusRunning, + }) + + w := serveChi(h, "GET", "/api/tasks", "") + pr := parsePaginated(t, w) + if pr.TotalCount != 1 { + t.Errorf("expected totalCount=1, got %d", pr.TotalCount) + } +} + +// ─── Project Tests ───────────────────────────────────────────────────────────── + +func TestListProjects_Empty(t *testing.T) { + h := testHandler(t) + w := serveChi(h, "GET", "/api/projects", "") + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestListProjects_WithData(t *testing.T) { + h := testHandler(t) + h.ProjectStore.Create(models.Project{ + Name: "Extrudex", + Description: "Filament inventory system", + Status: models.ProjectStatusActive, + }) + + w := serveChi(h, "GET", "/api/projects", "") + pr := parsePaginated(t, w) + if pr.TotalCount != 1 { + t.Errorf("expected totalCount=1, got %d", pr.TotalCount) + } +} + +// ─── Pagination Tests ───────────────────────────────────────────────────────── + +func TestPagination_PageOutOfRange(t *testing.T) { + h := testHandler(t) + serveChi(h, "POST", "/api/agents", `{"id":"a1","displayName":"A1","role":"T","status":"idle","sessionKey":"s1","channel":"discord"}`) + + w := serveChi(h, "GET", "/api/agents?page=99", "") + pr := parsePaginated(t, w) + if len(pr.Data.([]any)) != 0 { + t.Errorf("expected empty page, got %d items", len(pr.Data.([]any))) + } + // HasMore=false because we're past all data — nothing more to fetch. + if pr.HasMore { + t.Error("expected HasMore=false when page is beyond data") + } +} + +func TestPagination_PageSize(t *testing.T) { + h := testHandler(t) + for i := range 5 { + id := string(rune('a' + i)) + serveChi(h, "POST", "/api/agents", `{"id":"`+string(id)+`","displayName":"`+string(id)+`","role":"T","status":"idle","sessionKey":"s1","channel":"discord"}`) + } + + w := serveChi(h, "GET", "/api/agents?pageSize=2&page=2", "") + pr := parsePaginated(t, w) + if pr.PageSize != 2 { + t.Errorf("expected pageSize=2, got %d", pr.PageSize) + } +} diff --git a/go-backend/internal/handler/helpers.go b/go-backend/internal/handler/helpers.go new file mode 100644 index 0000000..3d149ca --- /dev/null +++ b/go-backend/internal/handler/helpers.go @@ -0,0 +1,106 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/go-playground/validator/v10" +) + +// ─── Pagination ──────────────────────────────────────────────────────────────── + +const ( + defaultPage = 1 + defaultPageSize = 20 + maxPageSize = 100 +) + +// parsePagination extracts page and pageSize query params from the request. +func parsePagination(r *http.Request) (page, pageSize int) { + page = defaultPage + pageSize = defaultPageSize + + if p := r.URL.Query().Get("page"); p != "" { + if n, err := strconv.Atoi(p); err == nil && n > 0 { + page = n + } + } + if ps := r.URL.Query().Get("pageSize"); ps != "" { + if n, err := strconv.Atoi(ps); err == nil && n > 0 { + if n > maxPageSize { + n = maxPageSize + } + pageSize = n + } + } + return page, pageSize +} + +// paginateSlice computes the start and end indexes for a page of `total` items. +// Bounds are clamped to [0, total]. +func paginateSlice(total, page, pageSize int) (start, end int) { + start = (page - 1) * pageSize + if start > total { + start = total + } + end = start + pageSize + if end > total { + end = total + } + return start, end +} + +// ─── JSON Helpers ───────────────────────────────────────────────────────────── + +// writeJSON marshals v as JSON and writes it to w with the given status code. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v == nil { + return + } + if err := json.NewEncoder(w).Encode(v); err != nil { + http.Error(w, `{"error":"internal encoding error"}`, http.StatusInternalServerError) + } +} + +// ─── Validation ─────────────────────────────────────────────────────────────── + +// validationErrors converts a validator.ValidationErrors into a string map +// suitable for the ErrorResponse details field. +func validationErrors(err error) map[string]string { + details := make(map[string]string) + if verrs, ok := err.(validator.ValidationErrors); ok { + for _, fe := range verrs { + field := strings.ToLower(fe.Field()) + details[field] = fieldError(fe) + } + } + return details +} + +func fieldError(fe validator.FieldError) string { + switch fe.Tag() { + case "required": + return "this field is required" + case "min": + return fmt.Sprintf("must be at least %s characters", fe.Param()) + case "max": + return fmt.Sprintf("must be at most %s characters", fe.Param()) + default: + return fmt.Sprintf("failed validation: %s", fe.Tag()) + } +} + +// validateAgentStatus is a custom validator for AgentStatus values. +func validateAgentStatus(fl validator.FieldLevel) bool { + if status, ok := fl.Field().Interface().(interface { + IsValid() bool + }); ok { + return status.IsValid() + } + return false +} diff --git a/go-backend/internal/handler/project.go b/go-backend/internal/handler/project.go new file mode 100644 index 0000000..e3f1dec --- /dev/null +++ b/go-backend/internal/handler/project.go @@ -0,0 +1,28 @@ +package handler + +import ( + "net/http" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" +) + +// ─── Project Handlers ────────────────────────────────────────────────────────── + +// ListProjects handles GET /api/projects. +func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) { + projects := h.ProjectStore.List() + if projects == nil { + projects = []models.Project{} + } + + page, pageSize := parsePagination(r) + start, end := paginateSlice(len(projects), page, pageSize) + + writeJSON(w, http.StatusOK, models.PaginatedResponse{ + Data: projects[start:end], + TotalCount: h.ProjectStore.Count(), + Page: page, + PageSize: pageSize, + HasMore: end < len(projects), + }) +} diff --git a/go-backend/internal/handler/session.go b/go-backend/internal/handler/session.go new file mode 100644 index 0000000..2f83571 --- /dev/null +++ b/go-backend/internal/handler/session.go @@ -0,0 +1,28 @@ +package handler + +import ( + "net/http" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" +) + +// ─── Session Handlers ────────────────────────────────────────────────────────── + +// ListSessions handles GET /api/sessions. +func (h *Handler) ListSessions(w http.ResponseWriter, r *http.Request) { + sessions := h.SessionStore.ListActive() + if sessions == nil { + sessions = []models.Session{} + } + + page, pageSize := parsePagination(r) + start, end := paginateSlice(len(sessions), page, pageSize) + + writeJSON(w, http.StatusOK, models.PaginatedResponse{ + Data: sessions[start:end], + TotalCount: h.SessionStore.Count(), + Page: page, + PageSize: pageSize, + HasMore: end < len(sessions), + }) +} diff --git a/go-backend/internal/handler/task.go b/go-backend/internal/handler/task.go new file mode 100644 index 0000000..6290227 --- /dev/null +++ b/go-backend/internal/handler/task.go @@ -0,0 +1,28 @@ +package handler + +import ( + "net/http" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" +) + +// ─── Task Handlers ───────────────────────────────────────────────────────────── + +// ListTasks handles GET /api/tasks. +func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) { + tasks := h.TaskStore.ListRecent() + if tasks == nil { + tasks = []models.Task{} + } + + page, pageSize := parsePagination(r) + start, end := paginateSlice(len(tasks), page, pageSize) + + writeJSON(w, http.StatusOK, models.PaginatedResponse{ + Data: tasks[start:end], + TotalCount: h.TaskStore.Count(), + Page: page, + PageSize: pageSize, + HasMore: end < len(tasks), + }) +} diff --git a/go-backend/internal/models/models.go b/go-backend/internal/models/models.go new file mode 100644 index 0000000..8480b41 --- /dev/null +++ b/go-backend/internal/models/models.go @@ -0,0 +1,165 @@ +// Package models defines the domain types and API contracts for the +// Control Center Go backend. These types map to the existing TypeScript +// AgentCardData interface and extend it with persistence-focused types +// for the new session, task, and project entities. +// +// All JSON field names use camelCase to match the existing frontend +// contract established by the .NET backend. +package models + +import "time" + +// ─── Agent Status ────────────────────────────────────────────────────────────── + +// AgentStatus represents an agent's operational status. +// Maps to the frontend status values: active, idle, thinking, error. +type AgentStatus string + +const ( + AgentStatusActive AgentStatus = "active" + AgentStatusIdle AgentStatus = "idle" + AgentStatusThinking AgentStatus = "thinking" + AgentStatusError AgentStatus = "error" +) + +// IsValid reports whether s is a known agent status. +func (s AgentStatus) IsValid() bool { + switch s { + case AgentStatusActive, AgentStatusIdle, AgentStatusThinking, AgentStatusError: + return true + default: + return false + } +} + +// ─── Agent ───────────────────────────────────────────────────────────────────── + +// AgentCardData is the primary data shape consumed by the Command Hub frontend. +// It matches the TypeScript AgentCardData interface exactly. +type AgentCardData struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + Status AgentStatus `json:"status"` + CurrentTask *string `json:"currentTask,omitempty"` + TaskProgress *int `json:"taskProgress,omitempty"` + TaskElapsed *string `json:"taskElapsed,omitempty"` + SessionKey string `json:"sessionKey"` + Channel string `json:"channel"` + LastActivity string `json:"lastActivity"` + ErrorMessage *string `json:"errorMessage,omitempty"` +} + +// CreateAgentRequest is the payload for POST /api/agents. +type CreateAgentRequest struct { + ID string `json:"id" validate:"required,min=2,max=64"` + DisplayName string `json:"displayName" validate:"required,min=1,max=128"` + Role string `json:"role" validate:"required,min=1,max=256"` + Status AgentStatus `json:"status" validate:"required,agentStatus"` + SessionKey string `json:"sessionKey" validate:"required,min=1"` + Channel string `json:"channel" validate:"required,min=1,max=32"` + CurrentTask *string `json:"currentTask,omitempty"` +} + +// UpdateAgentRequest is the payload for PUT /api/agents/{id}. +type UpdateAgentRequest struct { + Status *AgentStatus `json:"status,omitempty" validate:"omitempty,agentStatus"` + CurrentTask *string `json:"currentTask,omitempty"` + TaskProgress *int `json:"taskProgress,omitempty" validate:"omitempty,min=0,max=100"` + TaskElapsed *string `json:"taskElapsed,omitempty"` + Channel *string `json:"channel,omitempty" validate:"omitempty,min=1,max=32"` + ErrorMessage *string `json:"errorMessage,omitempty"` +} + +// AgentStatusHistoryEntry represents a point-in-time status change for an agent. +type AgentStatusHistoryEntry struct { + ID string `json:"id"` + AgentID string `json:"agentId"` + Status AgentStatus `json:"status"` + Task *string `json:"task,omitempty"` + Timestamp string `json:"timestamp"` +} + +// ─── Session ─────────────────────────────────────────────────────────────────── + +// Session represents an active agent session tracked by the system. +type Session struct { + ID string `json:"id"` + SessionKey string `json:"sessionKey"` + AgentID string `json:"agentId"` + Channel string `json:"channel"` + Status string `json:"status"` // running, done, streaming, error + ContextTokens int `json:"contextTokens"` + TotalTokens int `json:"totalTokens"` + EstimatedCost float64 `json:"estimatedCost"` + Model string `json:"model"` + StartedAt time.Time `json:"startedAt"` + LastActivityAt time.Time `json:"lastActivityAt"` +} + +// ─── Task ─────────────────────────────────────────────────────────────────────── + +// TaskStatus represents the lifecycle state of a tracked task. +type TaskStatus string + +const ( + TaskStatusPending TaskStatus = "pending" + TaskStatusRunning TaskStatus = "running" + TaskStatusCompleted TaskStatus = "completed" + TaskStatusFailed TaskStatus = "failed" +) + +// Task represents a tracked task assigned to an agent. +type Task struct { + ID string `json:"id"` + AgentID string `json:"agentId"` + Title string `json:"title"` + Description string `json:"description"` + Status TaskStatus `json:"status"` + Progress *int `json:"progress,omitempty"` + SessionKey string `json:"sessionKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ─── Project ──────────────────────────────────────────────────────────────────── + +// ProjectStatus represents the lifecycle state of a project. +type ProjectStatus string + +const ( + ProjectStatusPlanned ProjectStatus = "planned" + ProjectStatusActive ProjectStatus = "active" + ProjectStatusPaused ProjectStatus = "paused" + ProjectStatusCompleted ProjectStatus = "completed" +) + +// Project represents a project tracked in the system. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status ProjectStatus `json:"status"` + AgentIDs []string `json:"agentIds"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ─── Pagination ──────────────────────────────────────────────────────────────── + +// PaginatedResponse wraps a list response with pagination metadata. +type PaginatedResponse struct { + Data any `json:"data"` + TotalCount int `json:"totalCount"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + HasMore bool `json:"hasMore"` +} + +// ─── Error ───────────────────────────────────────────────────────────────────── + +// ErrorResponse is the standard API error envelope. +type ErrorResponse struct { + Error string `json:"error"` + Details map[string]string `json:"details,omitempty"` +} diff --git a/go-backend/internal/router/router.go b/go-backend/internal/router/router.go new file mode 100644 index 0000000..c509731 --- /dev/null +++ b/go-backend/internal/router/router.go @@ -0,0 +1,65 @@ +// Package router configures the chi router with all routes, middleware, +// and handler wiring for the Control Center API. +package router + +import ( + "net/http" + "time" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" +) + +// New creates a fully-configured chi router with all API routes mounted. +func New(h *handler.Handler) *chi.Mux { + r := chi.NewRouter() + + // ── Global middleware ────────────────────────────────────────────────── + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(30 * time.Second)) + + // ── CORS — permissive for development ────────────────────────────────── + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + ExposedHeaders: []string{"Link", "X-Total-Count"}, + AllowCredentials: false, + MaxAge: 300, + })) + + // ── Health check ─────────────────────────────────────────────────────── + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) + }) + + // ── API v1 routes ────────────────────────────────────────────────────── + r.Route("/api", func(api chi.Router) { + // Agents CRUD + api.Route("/agents", func(agents chi.Router) { + agents.Get("/", h.ListAgents) // GET /api/agents + agents.Post("/", h.CreateAgent) // POST /api/agents + agents.Get("/{id}", h.GetAgent) // GET /api/agents/{id} + agents.Put("/{id}", h.UpdateAgent) // PUT /api/agents/{id} + agents.Delete("/{id}", h.DeleteAgent) // DELETE /api/agents/{id} + agents.Get("/{id}/history", h.AgentHistory) // GET /api/agents/{id}/history + }) + + // Sessions + api.Get("/sessions", h.ListSessions) + + // Tasks + api.Get("/tasks", h.ListTasks) + + // Projects + api.Get("/projects", h.ListProjects) + }) + + return r +} diff --git a/go-backend/internal/store/agent.go b/go-backend/internal/store/agent.go new file mode 100644 index 0000000..740973b --- /dev/null +++ b/go-backend/internal/store/agent.go @@ -0,0 +1,177 @@ +// Package store provides thread-safe in-memory data stores for the +// Control Center API. These will be replaced with PostgreSQL-backed +// implementations once CUB-120 (schema design) is complete. +package store + +import ( + "sort" + "sync" + "time" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" + "github.com/google/uuid" +) + +// AgentStore provides thread-safe CRUD operations for agents. +type AgentStore struct { + mu sync.RWMutex + agents map[string]models.AgentCardData + history map[string][]models.AgentStatusHistoryEntry // agentID -> history +} + +// NewAgentStore returns an initialized AgentStore. +func NewAgentStore() *AgentStore { + return &AgentStore{ + agents: make(map[string]models.AgentCardData), + history: make(map[string][]models.AgentStatusHistoryEntry), + } +} + +// List returns all agents, optionally filtered by status. +func (s *AgentStore) List(statusFilter models.AgentStatus) []models.AgentCardData { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]models.AgentCardData, 0, len(s.agents)) + for _, a := range s.agents { + if statusFilter != "" && a.Status != statusFilter { + continue + } + result = append(result, a) + } + + // Sort by display name for consistent output. + sort.Slice(result, func(i, j int) bool { + return result[i].DisplayName < result[j].DisplayName + }) + return result +} + +// Get returns a single agent by ID, or false if not found. +func (s *AgentStore) Get(id string) (models.AgentCardData, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + a, ok := s.agents[id] + return a, ok +} + +// Create inserts a new agent. Returns false if the ID already exists. +func (s *AgentStore) Create(a models.AgentCardData) bool { + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.agents[a.ID]; exists { + return false + } + if a.LastActivity == "" { + a.LastActivity = time.Now().UTC().Format(time.RFC3339) + } + s.agents[a.ID] = a + + // Record initial history entry. + s.appendHistoryLocked(a.ID, models.AgentStatusHistoryEntry{ + ID: uuid.New().String(), + AgentID: a.ID, + Status: a.Status, + Task: a.CurrentTask, + Timestamp: a.LastActivity, + }) + return true +} + +// Update applies partial updates to an agent. Returns the updated agent or false if not found. +func (s *AgentStore) Update(id string, req models.UpdateAgentRequest) (models.AgentCardData, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + a, ok := s.agents[id] + if !ok { + return models.AgentCardData{}, false + } + + prevStatus := a.Status + prevTask := a.CurrentTask + + if req.Status != nil { + a.Status = *req.Status + } + if req.CurrentTask != nil { + a.CurrentTask = req.CurrentTask + } + if req.TaskProgress != nil { + a.TaskProgress = req.TaskProgress + } + if req.TaskElapsed != nil { + a.TaskElapsed = req.TaskElapsed + } + if req.Channel != nil { + a.Channel = *req.Channel + } + if req.ErrorMessage != nil { + a.ErrorMessage = req.ErrorMessage + } + a.LastActivity = time.Now().UTC().Format(time.RFC3339) + s.agents[id] = a + + // Record history entry if status or task changed. + if (req.Status != nil && *req.Status != prevStatus) || (req.CurrentTask != nil && prevTask == nil) || + (req.CurrentTask != nil && prevTask != nil && *req.CurrentTask != *prevTask) { + status := a.Status + if req.Status != nil { + status = *req.Status + } + s.appendHistoryLocked(id, models.AgentStatusHistoryEntry{ + ID: uuid.New().String(), + AgentID: id, + Status: status, + Task: a.CurrentTask, + Timestamp: a.LastActivity, + }) + } + + return a, true +} + +// Delete removes an agent. Returns true if the agent existed. +func (s *AgentStore) Delete(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.agents[id]; !ok { + return false + } + delete(s.agents, id) + delete(s.history, id) + return true +} + +// History returns the status history for an agent, newest first. +func (s *AgentStore) History(agentID string) []models.AgentStatusHistoryEntry { + s.mu.RLock() + defer s.mu.RUnlock() + + entries, ok := s.history[agentID] + if !ok { + return nil + } + // Return a defensive copy, sorted newest first (by index when timestamps tie). + result := make([]models.AgentStatusHistoryEntry, len(entries)) + copy(result, entries) + sort.SliceStable(result, func(i, j int) bool { + if result[i].Timestamp == result[j].Timestamp { + return i > j // later index = newer when timestamps match + } + return result[i].Timestamp > result[j].Timestamp + }) + return result +} + +// Count returns the total number of agents. +func (s *AgentStore) Count() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.agents) +} + +// appendHistoryLocked adds a history entry. Caller must hold s.mu (write lock). +func (s *AgentStore) appendHistoryLocked(agentID string, entry models.AgentStatusHistoryEntry) { + s.history[agentID] = append(s.history[agentID], entry) +} diff --git a/go-backend/internal/store/project.go b/go-backend/internal/store/project.go new file mode 100644 index 0000000..93c5336 --- /dev/null +++ b/go-backend/internal/store/project.go @@ -0,0 +1,67 @@ +package store + +import ( + "sort" + "sync" + "time" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" + "github.com/google/uuid" +) + +// ProjectStore provides thread-safe CRUD operations for projects. +type ProjectStore struct { + mu sync.RWMutex + projects map[string]models.Project +} + +// NewProjectStore returns an initialized ProjectStore. +func NewProjectStore() *ProjectStore { + return &ProjectStore{ + projects: make(map[string]models.Project), + } +} + +// List returns all projects ordered by name. +func (s *ProjectStore) List() []models.Project { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]models.Project, 0, len(s.projects)) + for _, p := range s.projects { + result = append(result, p) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Name < result[j].Name + }) + return result +} + +// Create inserts a new project. +func (s *ProjectStore) Create(p models.Project) models.Project { + s.mu.Lock() + defer s.mu.Unlock() + + if p.ID == "" { + p.ID = uuid.New().String() + } + now := time.Now().UTC() + if p.CreatedAt.IsZero() { + p.CreatedAt = now + } + if p.UpdatedAt.IsZero() { + p.UpdatedAt = now + } + if p.AgentIDs == nil { + p.AgentIDs = []string{} + } + s.projects[p.ID] = p + return p +} + +// Count returns the total number of projects. +func (s *ProjectStore) Count() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.projects) +} diff --git a/go-backend/internal/store/session.go b/go-backend/internal/store/session.go new file mode 100644 index 0000000..84fd09e --- /dev/null +++ b/go-backend/internal/store/session.go @@ -0,0 +1,80 @@ +package store + +import ( + "sort" + "sync" + "time" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" + "github.com/google/uuid" +) + +// SessionStore provides thread-safe CRUD operations for sessions. +type SessionStore struct { + mu sync.RWMutex + sessions map[string]models.Session // id -> session +} + +// NewSessionStore returns an initialized SessionStore. +func NewSessionStore() *SessionStore { + return &SessionStore{ + sessions: make(map[string]models.Session), + } +} + +// ListActive returns all sessions with status "running" or "streaming", newest first. +func (s *SessionStore) ListActive() []models.Session { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]models.Session, 0) + for _, sess := range s.sessions { + if sess.Status == "running" || sess.Status == "streaming" { + result = append(result, sess) + } + } + sort.Slice(result, func(i, j int) bool { + return result[i].LastActivityAt.After(result[j].LastActivityAt) + }) + return result +} + +// Create inserts a new session. +func (s *SessionStore) Create(sess models.Session) models.Session { + s.mu.Lock() + defer s.mu.Unlock() + + if sess.ID == "" { + sess.ID = uuid.New().String() + } + if sess.StartedAt.IsZero() { + sess.StartedAt = time.Now().UTC() + } + if sess.LastActivityAt.IsZero() { + sess.LastActivityAt = sess.StartedAt + } + s.sessions[sess.ID] = sess + return sess +} + +// UpdateStatus updates the status and last-activity timestamp of a session. +func (s *SessionStore) UpdateStatus(id, status string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + sess, ok := s.sessions[id] + if !ok { + return false + } + sess.Status = status + sess.LastActivityAt = time.Now().UTC() + s.sessions[id] = sess + return true +} + +// Count returns the total number of sessions. +func (s *SessionStore) Count() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.sessions) +} diff --git a/go-backend/internal/store/task.go b/go-backend/internal/store/task.go new file mode 100644 index 0000000..44de030 --- /dev/null +++ b/go-backend/internal/store/task.go @@ -0,0 +1,64 @@ +package store + +import ( + "sort" + "sync" + "time" + + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" + "github.com/google/uuid" +) + +// TaskStore provides thread-safe CRUD operations for tasks. +type TaskStore struct { + mu sync.RWMutex + tasks map[string]models.Task +} + +// NewTaskStore returns an initialized TaskStore. +func NewTaskStore() *TaskStore { + return &TaskStore{ + tasks: make(map[string]models.Task), + } +} + +// ListRecent returns tasks ordered by updated_at descending (newest first). +func (s *TaskStore) ListRecent() []models.Task { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]models.Task, 0, len(s.tasks)) + for _, t := range s.tasks { + result = append(result, t) + } + sort.Slice(result, func(i, j int) bool { + return result[i].UpdatedAt.After(result[j].UpdatedAt) + }) + return result +} + +// Create inserts a new task. +func (s *TaskStore) Create(t models.Task) models.Task { + s.mu.Lock() + defer s.mu.Unlock() + + if t.ID == "" { + t.ID = uuid.New().String() + } + now := time.Now().UTC() + if t.CreatedAt.IsZero() { + t.CreatedAt = now + } + if t.UpdatedAt.IsZero() { + t.UpdatedAt = now + } + s.tasks[t.ID] = t + return t +} + +// Count returns the total number of tasks. +func (s *TaskStore) Count() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.tasks) +}