initial agent-mgr: app builder platform MVP

Go API server + Preact UI + Claude Code adapter.
- App-centric model (ideas, not repos)
- AgentProvider interface for multi-agent support
- K8s pod lifecycle for sandboxed agent sessions
- Gitea integration (create repos, push branches)
- WebSocket streaming for live session output
- Woodpecker CI/CD pipelines (kaniko build + kubectl deploy)
This commit is contained in:
Steven Hooker
2026-02-18 15:56:32 +01:00
commit e5b07cc1d8
39 changed files with 3120 additions and 0 deletions

105
internal/api/apps.go Normal file
View File

@@ -0,0 +1,105 @@
package api
import (
"encoding/json"
"net/http"
"github.com/agentsphere/agent-mgr/internal/store"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
type createAppRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Provider string `json:"provider,omitempty"`
Config json.RawMessage `json:"config,omitempty"`
}
func (s *Server) createApp(w http.ResponseWriter, r *http.Request) {
var req createAppRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if req.Name == "" && req.Description == "" {
http.Error(w, `{"error":"name or description required"}`, http.StatusBadRequest)
return
}
if req.Name == "" {
// Auto-generate name from first few words of description
name := req.Description
if len(name) > 40 {
name = name[:40]
}
req.Name = name
}
app, sess, err := s.app.CreateApp(r.Context(), req.Name, req.Description, req.Provider, req.Config)
if err != nil {
s.log.Error("create app failed", "err", err)
http.Error(w, `{"error":"failed to create app"}`, http.StatusInternalServerError)
return
}
resp := map[string]any{"app": app}
if sess != nil {
resp["session"] = sess
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}
func (s *Server) listApps(w http.ResponseWriter, r *http.Request) {
apps, err := s.store.ListApps(r.Context())
if err != nil {
s.log.Error("list apps failed", "err", err)
http.Error(w, `{"error":"failed to list apps"}`, http.StatusInternalServerError)
return
}
if apps == nil {
apps = []store.App{}
}
json.NewEncoder(w).Encode(map[string]any{"apps": apps})
}
func (s *Server) getApp(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "appID"))
if err != nil {
http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest)
return
}
app, err := s.store.GetApp(r.Context(), id)
if err != nil {
http.Error(w, `{"error":"failed to get app"}`, http.StatusInternalServerError)
return
}
if app == nil {
http.Error(w, `{"error":"app not found"}`, http.StatusNotFound)
return
}
sessions, _ := s.store.ListSessionsByApp(r.Context(), id)
if sessions == nil {
sessions = []store.Session{}
}
json.NewEncoder(w).Encode(map[string]any{"app": app, "sessions": sessions})
}
func (s *Server) deleteApp(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "appID"))
if err != nil {
http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest)
return
}
if err := s.store.DeleteApp(r.Context(), id); err != nil {
http.Error(w, `{"error":"failed to delete app"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

77
internal/api/router.go Normal file
View File

@@ -0,0 +1,77 @@
package api
import (
"io/fs"
"log/slog"
"net/http"
"github.com/agentsphere/agent-mgr/internal/app"
"github.com/agentsphere/agent-mgr/internal/events"
"github.com/agentsphere/agent-mgr/internal/provider"
"github.com/agentsphere/agent-mgr/internal/store"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
type Server struct {
store *store.Store
app *app.Service
registry *provider.Registry
events *events.Bus
log *slog.Logger
}
func NewServer(st *store.Store, appSvc *app.Service, reg *provider.Registry, bus *events.Bus, log *slog.Logger) *Server {
return &Server{store: st, app: appSvc, registry: reg, events: bus, log: log}
}
func (s *Server) Handler(staticFS fs.FS) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(middleware.Compress(5))
// API routes
r.Route("/api/v1", func(r chi.Router) {
r.Use(middleware.SetHeader("Content-Type", "application/json"))
r.Post("/apps", s.createApp)
r.Get("/apps", s.listApps)
r.Get("/apps/{appID}", s.getApp)
r.Delete("/apps/{appID}", s.deleteApp)
r.Post("/apps/{appID}/sessions", s.createSession)
r.Get("/apps/{appID}/sessions", s.listSessions)
r.Get("/sessions/{sessionID}", s.getSession)
r.Post("/sessions/{sessionID}/stop", s.stopSession)
r.Post("/sessions/{sessionID}/message", s.sendMessage)
r.Get("/sessions/{sessionID}/ws", s.streamSession)
r.Get("/providers", s.listProviders)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"ok"}`))
})
})
// Serve SPA for all other routes
if staticFS != nil {
fileServer := http.FileServer(http.FS(staticFS))
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
// Try to serve the file; if not found, serve index.html (SPA routing)
path := r.URL.Path
f, err := staticFS.Open(path[1:]) // strip leading /
if err != nil {
// Serve index.html for SPA client-side routing
r.URL.Path = "/"
} else {
f.Close()
}
fileServer.ServeHTTP(w, r)
})
}
return r
}

136
internal/api/sessions.go Normal file
View File

@@ -0,0 +1,136 @@
package api
import (
"encoding/json"
"net/http"
"github.com/agentsphere/agent-mgr/internal/store"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
type createSessionRequest struct {
Prompt string `json:"prompt"`
Provider string `json:"provider,omitempty"`
Config json.RawMessage `json:"config,omitempty"`
}
type sendMessageRequest struct {
Message string `json:"message"`
}
func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
appID, err := uuid.Parse(chi.URLParam(r, "appID"))
if err != nil {
http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest)
return
}
var req createSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if req.Prompt == "" {
http.Error(w, `{"error":"prompt required"}`, http.StatusBadRequest)
return
}
app, err := s.store.GetApp(r.Context(), appID)
if err != nil || app == nil {
http.Error(w, `{"error":"app not found"}`, http.StatusNotFound)
return
}
sess, err := s.app.StartSession(r.Context(), app, req.Prompt, req.Provider, req.Config)
if err != nil {
s.log.Error("create session failed", "err", err)
http.Error(w, `{"error":"failed to create session"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{"session": sess})
}
func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) {
appID, err := uuid.Parse(chi.URLParam(r, "appID"))
if err != nil {
http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest)
return
}
sessions, err := s.store.ListSessionsByApp(r.Context(), appID)
if err != nil {
http.Error(w, `{"error":"failed to list sessions"}`, http.StatusInternalServerError)
return
}
if sessions == nil {
sessions = []store.Session{}
}
json.NewEncoder(w).Encode(map[string]any{"sessions": sessions})
}
func (s *Server) getSession(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "sessionID"))
if err != nil {
http.Error(w, `{"error":"invalid session id"}`, http.StatusBadRequest)
return
}
sess, err := s.store.GetSession(r.Context(), id)
if err != nil || sess == nil {
http.Error(w, `{"error":"session not found"}`, http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(map[string]any{"session": sess})
}
func (s *Server) stopSession(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "sessionID"))
if err != nil {
http.Error(w, `{"error":"invalid session id"}`, http.StatusBadRequest)
return
}
if err := s.app.StopSession(r.Context(), id); err != nil {
s.log.Error("stop session failed", "err", err)
http.Error(w, `{"error":"failed to stop session"}`, http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]any{"status": "stopped"})
}
func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "sessionID"))
if err != nil {
http.Error(w, `{"error":"invalid session id"}`, http.StatusBadRequest)
return
}
var req sendMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if req.Message == "" {
http.Error(w, `{"error":"message required"}`, http.StatusBadRequest)
return
}
if err := s.app.SendMessage(r.Context(), id, req.Message); err != nil {
s.log.Error("send message failed", "err", err)
http.Error(w, `{"error":"failed to send message"}`, http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]any{"status": "sent"})
}
func (s *Server) listProviders(w http.ResponseWriter, r *http.Request) {
infos := s.registry.List()
json.NewEncoder(w).Encode(map[string]any{"providers": infos})
}

74
internal/api/ws.go Normal file
View File

@@ -0,0 +1,74 @@
package api
import (
"bufio"
"io"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"nhooyr.io/websocket"
"github.com/agentsphere/agent-mgr/internal/provider"
"github.com/agentsphere/agent-mgr/internal/provider/claudecode"
)
func (s *Server) streamSession(w http.ResponseWriter, r *http.Request) {
sessID, err := uuid.Parse(chi.URLParam(r, "sessionID"))
if err != nil {
http.Error(w, "invalid session id", http.StatusBadRequest)
return
}
sess, err := s.store.GetSession(r.Context(), sessID)
if err != nil || sess == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
p, err := s.registry.Get(sess.Provider)
if err != nil {
http.Error(w, "provider not found", http.StatusInternalServerError)
return
}
handle := &provider.SessionHandle{SessionID: sess.ID, PodName: sess.PodName}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true, // Traefik handles TLS/origin
})
if err != nil {
s.log.Error("websocket accept failed", "err", err)
return
}
defer conn.CloseNow()
ctx := conn.CloseRead(r.Context())
stream, err := p.StreamOutput(ctx, handle)
if err != nil {
s.log.Error("stream output failed", "err", err, "pod", sess.PodName)
conn.Close(websocket.StatusInternalError, "failed to stream logs")
return
}
defer stream.Close()
tracker := claudecode.NewProgressTracker()
scanner := bufio.NewScanner(stream)
scanner.Buffer(make([]byte, 64*1024), 64*1024)
for scanner.Scan() {
line := scanner.Text()
tracker.ProcessLine(line)
if err := conn.Write(ctx, websocket.MessageText, []byte(line)); err != nil {
break
}
}
if err := scanner.Err(); err != nil && err != io.EOF {
s.log.Warn("stream scanner error", "err", err)
}
conn.Close(websocket.StatusNormalClosure, "stream ended")
}