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:
105
internal/api/apps.go
Normal file
105
internal/api/apps.go
Normal 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
77
internal/api/router.go
Normal 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
136
internal/api/sessions.go
Normal 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
74
internal/api/ws.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user