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")
|
||||
}
|
||||
170
internal/app/service.go
Normal file
170
internal/app/service.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/agentsphere/agent-mgr/internal/gitea"
|
||||
"github.com/agentsphere/agent-mgr/internal/provider"
|
||||
"github.com/agentsphere/agent-mgr/internal/store"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
gitea *gitea.Client
|
||||
registry *provider.Registry
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewService(s *store.Store, g *gitea.Client, r *provider.Registry, log *slog.Logger) *Service {
|
||||
return &Service{store: s, gitea: g, registry: r, log: log}
|
||||
}
|
||||
|
||||
// CreateApp creates an app record, a Gitea repo, and starts the first build session.
|
||||
func (svc *Service) CreateApp(ctx context.Context, name, description, providerName string, providerConfig json.RawMessage) (*store.App, *store.Session, error) {
|
||||
repoName := slugify(name)
|
||||
|
||||
app, err := svc.store.CreateApp(ctx, name, description)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create app: %w", err)
|
||||
}
|
||||
|
||||
repo, err := svc.gitea.CreateRepo(ctx, repoName, description)
|
||||
if err != nil {
|
||||
_ = svc.store.DeleteApp(ctx, app.ID)
|
||||
return nil, nil, fmt.Errorf("create gitea repo: %w", err)
|
||||
}
|
||||
|
||||
if err := svc.store.UpdateAppRepo(ctx, app.ID, repo.FullName[:strings.Index(repo.FullName, "/")], repo.Name); err != nil {
|
||||
return nil, nil, fmt.Errorf("update app repo: %w", err)
|
||||
}
|
||||
app.RepoOwner = repo.FullName[:strings.Index(repo.FullName, "/")]
|
||||
app.RepoName = repo.Name
|
||||
|
||||
sess, err := svc.StartSession(ctx, app, description, providerName, providerConfig)
|
||||
if err != nil {
|
||||
return app, nil, fmt.Errorf("start initial session: %w", err)
|
||||
}
|
||||
|
||||
return app, sess, nil
|
||||
}
|
||||
|
||||
// StartSession creates a new session on an existing app and spawns the agent pod.
|
||||
func (svc *Service) StartSession(ctx context.Context, app *store.App, prompt, providerName string, providerConfig json.RawMessage) (*store.Session, error) {
|
||||
if providerName == "" {
|
||||
providerName = "claude-code"
|
||||
}
|
||||
|
||||
p, err := svc.registry.Get(providerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sessID := uuid.New()
|
||||
branch := fmt.Sprintf("agent/%s", sessID.String()[:8])
|
||||
|
||||
configBytes := providerConfig
|
||||
if configBytes == nil {
|
||||
configBytes = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
sess, err := svc.store.CreateSession(ctx, app.ID, providerName, prompt, branch, configBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
// Override the auto-generated ID with our pre-generated one for branch consistency
|
||||
// (the DB generates its own UUID, but the branch name uses sessID)
|
||||
// We'll use the DB-generated ID going forward
|
||||
branch = fmt.Sprintf("agent/%s", sess.ID.String()[:8])
|
||||
_ = svc.store.UpdateSessionStatus(ctx, sess.ID, "pending")
|
||||
sess.Branch = branch
|
||||
|
||||
cloneURL := svc.gitea.AuthCloneURL(app.RepoOwner, app.RepoName)
|
||||
|
||||
systemPrompt := fmt.Sprintf("You are building an app called %q. The user's idea: %s\nCreate a complete, working application. When done, ensure all files are committed.", app.Name, app.Description)
|
||||
fullPrompt := prompt
|
||||
if prompt == app.Description {
|
||||
fullPrompt = systemPrompt
|
||||
}
|
||||
|
||||
handle, err := p.CreateSession(ctx, provider.SessionConfig{
|
||||
SessionID: sess.ID,
|
||||
AppName: app.Name,
|
||||
RepoClone: cloneURL,
|
||||
Branch: branch,
|
||||
Prompt: fullPrompt,
|
||||
Provider: configBytes,
|
||||
})
|
||||
if err != nil {
|
||||
_ = svc.store.UpdateSessionStatus(ctx, sess.ID, "failed")
|
||||
return nil, fmt.Errorf("create agent session: %w", err)
|
||||
}
|
||||
|
||||
_ = svc.store.UpdateSessionPod(ctx, sess.ID, handle.PodName)
|
||||
_ = svc.store.UpdateSessionStatus(ctx, sess.ID, "running")
|
||||
_ = svc.store.UpdateAppStatus(ctx, app.ID, "building")
|
||||
|
||||
sess.PodName = handle.PodName
|
||||
sess.Status = "running"
|
||||
|
||||
svc.log.Info("session started", "app", app.Name, "session", sess.ID, "pod", handle.PodName)
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (svc *Service) StopSession(ctx context.Context, sessID uuid.UUID) error {
|
||||
sess, err := svc.store.GetSession(ctx, sessID)
|
||||
if err != nil || sess == nil {
|
||||
return fmt.Errorf("session not found: %w", err)
|
||||
}
|
||||
|
||||
p, err := svc.registry.Get(sess.Provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
handle := &provider.SessionHandle{SessionID: sess.ID, PodName: sess.PodName}
|
||||
if err := p.StopSession(ctx, handle); err != nil {
|
||||
svc.log.Warn("failed to stop pod", "pod", sess.PodName, "err", err)
|
||||
}
|
||||
|
||||
_ = svc.store.UpdateSessionStatus(ctx, sessID, "stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) SendMessage(ctx context.Context, sessID uuid.UUID, msg string) error {
|
||||
sess, err := svc.store.GetSession(ctx, sessID)
|
||||
if err != nil || sess == nil {
|
||||
return fmt.Errorf("session not found")
|
||||
}
|
||||
if sess.Status != "running" {
|
||||
return fmt.Errorf("session not running (status: %s)", sess.Status)
|
||||
}
|
||||
|
||||
p, err := svc.registry.Get(sess.Provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
handle := &provider.SessionHandle{SessionID: sess.ID, PodName: sess.PodName}
|
||||
return p.SendMessage(ctx, handle, msg)
|
||||
}
|
||||
|
||||
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||
|
||||
func slugify(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = nonAlphaNum.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if len(s) > 50 {
|
||||
s = s[:50]
|
||||
}
|
||||
if s == "" {
|
||||
s = "app"
|
||||
}
|
||||
return s
|
||||
}
|
||||
71
internal/events/valkey.go
Normal file
71
internal/events/valkey.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const channel = "agent-mgr:events"
|
||||
|
||||
type Bus struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Type string `json:"type"` // "session.started", "session.completed", "session.failed", "session.stopped", "app.status"
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func NewBus(addr, password string) *Bus {
|
||||
return &Bus{
|
||||
rdb: redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: password,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) Publish(ctx context.Context, evt Event) error {
|
||||
data, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal event: %w", err)
|
||||
}
|
||||
return b.rdb.Publish(ctx, channel, data).Err()
|
||||
}
|
||||
|
||||
func (b *Bus) Subscribe(ctx context.Context) (<-chan Event, error) {
|
||||
sub := b.rdb.Subscribe(ctx, channel)
|
||||
ch := make(chan Event, 64)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
defer sub.Close()
|
||||
for {
|
||||
msg, err := sub.ReceiveMessage(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var evt Event
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &evt); err != nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case ch <- evt:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (b *Bus) Close() error {
|
||||
return b.rdb.Close()
|
||||
}
|
||||
142
internal/gitea/client.go
Normal file
142
internal/gitea/client.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
botUser string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL, token, botUser string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
botUser: botUser,
|
||||
http: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Empty bool `json:"empty"`
|
||||
}
|
||||
|
||||
type Branch struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateRepo(ctx context.Context, name, description string) (*Repo, error) {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": name,
|
||||
"description": description,
|
||||
"auto_init": true,
|
||||
"default_branch": "main",
|
||||
"private": false,
|
||||
})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/user/repos", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create repo: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("create repo: status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
|
||||
var repo Repo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
|
||||
return nil, fmt.Errorf("decode repo: %w", err)
|
||||
}
|
||||
return &repo, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListRepos(ctx context.Context) ([]Repo, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/user/repos?limit=50", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list repos: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("list repos: status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
|
||||
var repos []Repo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
|
||||
return nil, fmt.Errorf("decode repos: %w", err)
|
||||
}
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListBranches(ctx context.Context, owner, repo string) ([]Branch, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches", c.baseURL, owner, repo)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list branches: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("list branches: status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
|
||||
var branches []Branch
|
||||
if err := json.NewDecoder(resp.Body).Decode(&branches); err != nil {
|
||||
return nil, fmt.Errorf("decode branches: %w", err)
|
||||
}
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
// AuthCloneURL returns a clone URL with embedded bot credentials.
|
||||
func (c *Client) AuthCloneURL(owner, repo string) string {
|
||||
// Strip protocol from base URL
|
||||
base := c.baseURL
|
||||
if len(base) > 8 && base[:8] == "https://" {
|
||||
base = base[8:]
|
||||
} else if len(base) > 7 && base[:7] == "http://" {
|
||||
base = base[7:]
|
||||
}
|
||||
return fmt.Sprintf("https://%s:%s@%s/%s/%s.git", c.botUser, c.token, base, owner, repo)
|
||||
}
|
||||
|
||||
func (c *Client) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
127
internal/provider/claudecode/adapter.go
Normal file
127
internal/provider/claudecode/adapter.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package claudecode
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/agentsphere/agent-mgr/internal/provider"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/remotecommand"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
MaxTurns int `json:"max_turns,omitempty"`
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
client kubernetes.Interface
|
||||
restConfig *rest.Config
|
||||
}
|
||||
|
||||
func New(client kubernetes.Interface, restConfig *rest.Config) *Provider {
|
||||
return &Provider{client: client, restConfig: restConfig}
|
||||
}
|
||||
|
||||
func (p *Provider) Info() provider.Info {
|
||||
return provider.Info{
|
||||
Name: "claude-code",
|
||||
DisplayName: "Claude Code",
|
||||
Description: "Anthropic Claude Code CLI agent — builds full applications from natural language",
|
||||
Capabilities: []provider.Capability{
|
||||
{Name: "create-app", Description: "Create new applications from scratch"},
|
||||
{Name: "edit-code", Description: "Modify existing codebases"},
|
||||
{Name: "interactive", Description: "Supports follow-up messages during a session"},
|
||||
},
|
||||
ConfigSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {"type": "string", "description": "Claude model to use", "default": ""},
|
||||
"max_turns": {"type": "integer", "description": "Maximum agentic turns", "default": 0}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) CreateSession(ctx context.Context, cfg provider.SessionConfig) (*provider.SessionHandle, error) {
|
||||
var opts *Config
|
||||
if len(cfg.Provider) > 0 {
|
||||
opts = &Config{}
|
||||
if err := json.Unmarshal(cfg.Provider, opts); err != nil {
|
||||
return nil, fmt.Errorf("parse claude-code config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
pod := buildPod(cfg, opts)
|
||||
created, err := p.client.CoreV1().Pods(Namespace).Create(ctx, pod, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pod: %w", err)
|
||||
}
|
||||
|
||||
return &provider.SessionHandle{
|
||||
SessionID: cfg.SessionID,
|
||||
PodName: created.Name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) StopSession(ctx context.Context, handle *provider.SessionHandle) error {
|
||||
return p.client.CoreV1().Pods(Namespace).Delete(ctx, handle.PodName, metav1.DeleteOptions{})
|
||||
}
|
||||
|
||||
func (p *Provider) SendMessage(ctx context.Context, handle *provider.SessionHandle, msg string) error {
|
||||
req := p.client.CoreV1().RESTClient().Post().
|
||||
Resource("pods").
|
||||
Name(handle.PodName).
|
||||
Namespace(Namespace).
|
||||
SubResource("attach").
|
||||
VersionedParams(&corev1.PodAttachOptions{
|
||||
Container: "claude",
|
||||
Stdin: true,
|
||||
Stdout: false,
|
||||
Stderr: false,
|
||||
}, scheme.ParameterCodec)
|
||||
|
||||
exec, err := remotecommand.NewSPDYExecutor(p.restConfig, "POST", req.URL())
|
||||
if err != nil {
|
||||
return fmt.Errorf("create attach executor: %w", err)
|
||||
}
|
||||
|
||||
return exec.StreamWithContext(ctx, remotecommand.StreamOptions{
|
||||
Stdin: bytes.NewReader([]byte(msg + "\n")),
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Provider) StreamOutput(ctx context.Context, handle *provider.SessionHandle) (io.ReadCloser, error) {
|
||||
req := p.client.CoreV1().Pods(Namespace).GetLogs(handle.PodName, &corev1.PodLogOptions{
|
||||
Container: "claude",
|
||||
Follow: true,
|
||||
})
|
||||
return req.Stream(ctx)
|
||||
}
|
||||
|
||||
func (p *Provider) GetStatus(ctx context.Context, handle *provider.SessionHandle) (provider.Status, error) {
|
||||
pod, err := p.client.CoreV1().Pods(Namespace).Get(ctx, handle.PodName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return provider.StatusFailed, fmt.Errorf("get pod: %w", err)
|
||||
}
|
||||
|
||||
switch pod.Status.Phase {
|
||||
case corev1.PodPending:
|
||||
return provider.StatusPending, nil
|
||||
case corev1.PodRunning:
|
||||
return provider.StatusRunning, nil
|
||||
case corev1.PodSucceeded:
|
||||
return provider.StatusCompleted, nil
|
||||
case corev1.PodFailed:
|
||||
return provider.StatusFailed, nil
|
||||
default:
|
||||
return provider.StatusFailed, nil
|
||||
}
|
||||
}
|
||||
122
internal/provider/claudecode/pod.go
Normal file
122
internal/provider/claudecode/pod.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package claudecode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/agentsphere/agent-mgr/internal/provider"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
Namespace = "agent-mgr"
|
||||
RunnerImage = "git.asp.now/platform/claude-code-runner:latest"
|
||||
GitImage = "alpine/git:latest"
|
||||
)
|
||||
|
||||
func buildPod(cfg provider.SessionConfig, opts *Config) *corev1.Pod {
|
||||
podName := fmt.Sprintf("agent-%s", cfg.SessionID.String()[:8])
|
||||
branch := cfg.Branch
|
||||
|
||||
cloneScript := fmt.Sprintf(`
|
||||
set -eu
|
||||
git clone %s /workspace
|
||||
cd /workspace
|
||||
git checkout -b %s
|
||||
git config user.name "agent-mgr-bot"
|
||||
git config user.email "bot@asp.now"
|
||||
`, cfg.RepoClone, branch)
|
||||
|
||||
claudeArgs := []string{"--output-format", "stream-json", "--permission-mode", "auto-accept-only"}
|
||||
if opts != nil {
|
||||
if opts.Model != "" {
|
||||
claudeArgs = append(claudeArgs, "--model", opts.Model)
|
||||
}
|
||||
if opts.MaxTurns > 0 {
|
||||
claudeArgs = append(claudeArgs, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
|
||||
}
|
||||
}
|
||||
|
||||
return &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: podName,
|
||||
Namespace: Namespace,
|
||||
Labels: map[string]string{
|
||||
"app": "agent-session",
|
||||
"session-id": cfg.SessionID.String(),
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
RestartPolicy: corev1.RestartPolicyNever,
|
||||
ServiceAccountName: "agent-runner",
|
||||
InitContainers: []corev1.Container{
|
||||
{
|
||||
Name: "git-clone",
|
||||
Image: GitImage,
|
||||
Command: []string{"sh", "-c", cloneScript},
|
||||
VolumeMounts: []corev1.VolumeMount{
|
||||
{Name: "workspace", MountPath: "/workspace"},
|
||||
},
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Requests: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("50m"),
|
||||
corev1.ResourceMemory: resource.MustParse("64Mi"),
|
||||
},
|
||||
Limits: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("200m"),
|
||||
corev1.ResourceMemory: resource.MustParse("128Mi"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "claude",
|
||||
Image: RunnerImage,
|
||||
Args: claudeArgs,
|
||||
Stdin: true,
|
||||
TTY: false,
|
||||
WorkingDir: "/workspace",
|
||||
Env: []corev1.EnvVar{
|
||||
{
|
||||
Name: "ANTHROPIC_API_KEY",
|
||||
ValueFrom: &corev1.EnvVarSource{
|
||||
SecretKeyRef: &corev1.SecretKeySelector{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: "agent-mgr-secrets"},
|
||||
Key: "anthropic-api-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
{Name: "SESSION_ID", Value: cfg.SessionID.String()},
|
||||
},
|
||||
VolumeMounts: []corev1.VolumeMount{
|
||||
{Name: "workspace", MountPath: "/workspace"},
|
||||
},
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Requests: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("200m"),
|
||||
corev1.ResourceMemory: resource.MustParse("256Mi"),
|
||||
},
|
||||
Limits: corev1.ResourceList{
|
||||
corev1.ResourceCPU: resource.MustParse("500m"),
|
||||
corev1.ResourceMemory: resource.MustParse("512Mi"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Volumes: []corev1.Volume{
|
||||
{
|
||||
Name: "workspace",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
EmptyDir: &corev1.EmptyDirVolumeSource{
|
||||
SizeLimit: resourcePtr(resource.MustParse("1Gi")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resourcePtr(q resource.Quantity) *resource.Quantity { return &q }
|
||||
75
internal/provider/claudecode/progress.go
Normal file
75
internal/provider/claudecode/progress.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package claudecode
|
||||
|
||||
import "strings"
|
||||
|
||||
type Milestone struct {
|
||||
Label string `json:"label"`
|
||||
Status string `json:"status"` // "done", "active", "pending"
|
||||
}
|
||||
|
||||
type ProgressTracker struct {
|
||||
filesSeen map[string]bool
|
||||
milestones []Milestone
|
||||
}
|
||||
|
||||
func NewProgressTracker() *ProgressTracker {
|
||||
return &ProgressTracker{
|
||||
filesSeen: make(map[string]bool),
|
||||
milestones: []Milestone{
|
||||
{Label: "Setting up project", Status: "active"},
|
||||
{Label: "Creating data models", Status: "pending"},
|
||||
{Label: "Building UI components", Status: "pending"},
|
||||
{Label: "Adding styling and polish", Status: "pending"},
|
||||
{Label: "Final checks", Status: "pending"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (pt *ProgressTracker) Milestones() []Milestone {
|
||||
cp := make([]Milestone, len(pt.milestones))
|
||||
copy(cp, pt.milestones)
|
||||
return cp
|
||||
}
|
||||
|
||||
func (pt *ProgressTracker) ProcessLine(line string) {
|
||||
lower := strings.ToLower(line)
|
||||
|
||||
if strings.Contains(lower, "\"tool\":\"write\"") || strings.Contains(lower, "\"tool\":\"create\"") {
|
||||
pt.advanceTo(0)
|
||||
}
|
||||
|
||||
if containsAny(lower, "model", "schema", "database", "migration", "struct", "type ") {
|
||||
pt.advanceTo(1)
|
||||
}
|
||||
|
||||
if containsAny(lower, "component", "page", "route", "template", "html", "tsx", "jsx", "vue") {
|
||||
pt.advanceTo(2)
|
||||
}
|
||||
|
||||
if containsAny(lower, "css", "style", "tailwind", "theme", "color", "font", "layout") {
|
||||
pt.advanceTo(3)
|
||||
}
|
||||
|
||||
if containsAny(lower, "test", "readme", "done", "complete", "finish", "final") {
|
||||
pt.advanceTo(4)
|
||||
}
|
||||
}
|
||||
|
||||
func (pt *ProgressTracker) advanceTo(idx int) {
|
||||
for i := range pt.milestones {
|
||||
if i < idx {
|
||||
pt.milestones[i].Status = "done"
|
||||
} else if i == idx {
|
||||
pt.milestones[i].Status = "active"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(s string, substrs ...string) bool {
|
||||
for _, sub := range substrs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
55
internal/provider/provider.go
Normal file
55
internal/provider/provider.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusRunning Status = "running"
|
||||
StatusCompleted Status = "completed"
|
||||
StatusFailed Status = "failed"
|
||||
StatusStopped Status = "stopped"
|
||||
)
|
||||
|
||||
type SessionConfig struct {
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
AppName string `json:"app_name"`
|
||||
RepoClone string `json:"repo_clone_url"`
|
||||
Branch string `json:"branch"`
|
||||
Prompt string `json:"prompt"`
|
||||
Provider json.RawMessage `json:"provider_config,omitempty"`
|
||||
}
|
||||
|
||||
type SessionHandle struct {
|
||||
SessionID uuid.UUID
|
||||
PodName string
|
||||
}
|
||||
|
||||
type Capability struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type Info struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
ConfigSchema json.RawMessage `json:"config_schema,omitempty"`
|
||||
}
|
||||
|
||||
type AgentProvider interface {
|
||||
Info() Info
|
||||
CreateSession(ctx context.Context, cfg SessionConfig) (*SessionHandle, error)
|
||||
StopSession(ctx context.Context, handle *SessionHandle) error
|
||||
SendMessage(ctx context.Context, handle *SessionHandle, msg string) error
|
||||
StreamOutput(ctx context.Context, handle *SessionHandle) (io.ReadCloser, error)
|
||||
GetStatus(ctx context.Context, handle *SessionHandle) (Status, error)
|
||||
}
|
||||
31
internal/provider/registry.go
Normal file
31
internal/provider/registry.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package provider
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Registry struct {
|
||||
providers map[string]AgentProvider
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{providers: make(map[string]AgentProvider)}
|
||||
}
|
||||
|
||||
func (r *Registry) Register(p AgentProvider) {
|
||||
r.providers[p.Info().Name] = p
|
||||
}
|
||||
|
||||
func (r *Registry) Get(name string) (AgentProvider, error) {
|
||||
p, ok := r.providers[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *Registry) List() []Info {
|
||||
infos := make([]Info, 0, len(r.providers))
|
||||
for _, p := range r.providers {
|
||||
infos = append(infos, p.Info())
|
||||
}
|
||||
return infos
|
||||
}
|
||||
29
internal/store/migrations/001_init.sql
Normal file
29
internal/store/migrations/001_init.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS apps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
repo_owner TEXT NOT NULL DEFAULT '',
|
||||
repo_name TEXT NOT NULL DEFAULT '',
|
||||
preview_url TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL DEFAULT 'claude-code',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
branch TEXT NOT NULL DEFAULT '',
|
||||
pod_name TEXT NOT NULL DEFAULT '',
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
cost_json JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_app_id ON sessions(app_id);
|
||||
CREATE INDEX idx_sessions_status ON sessions(status);
|
||||
CREATE INDEX idx_apps_status ON apps(status);
|
||||
202
internal/store/postgres.go
Normal file
202
internal/store/postgres.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(ctx context.Context, dsn string) (*Store, error) {
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to postgres: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
sql, err := migrationsFS.ReadFile("migrations/001_init.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration: %w", err)
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, string(sql))
|
||||
if err != nil {
|
||||
return fmt.Errorf("run migration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() {
|
||||
s.pool.Close()
|
||||
}
|
||||
|
||||
// App
|
||||
|
||||
type App struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
RepoOwner string `json:"repo_owner,omitempty"`
|
||||
RepoName string `json:"repo_name,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateApp(ctx context.Context, name, description string) (*App, error) {
|
||||
app := &App{}
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO apps (name, description) VALUES ($1, $2)
|
||||
RETURNING id, name, description, status, repo_owner, repo_name, preview_url, created_at, updated_at`,
|
||||
name, description,
|
||||
).Scan(&app.ID, &app.Name, &app.Description, &app.Status, &app.RepoOwner, &app.RepoName, &app.PreviewURL, &app.CreatedAt, &app.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create app: %w", err)
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetApp(ctx context.Context, id uuid.UUID) (*App, error) {
|
||||
app := &App{}
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, name, description, status, repo_owner, repo_name, preview_url, created_at, updated_at
|
||||
FROM apps WHERE id = $1`, id,
|
||||
).Scan(&app.ID, &app.Name, &app.Description, &app.Status, &app.RepoOwner, &app.RepoName, &app.PreviewURL, &app.CreatedAt, &app.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get app: %w", err)
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListApps(ctx context.Context) ([]App, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, name, description, status, repo_owner, repo_name, preview_url, created_at, updated_at
|
||||
FROM apps ORDER BY updated_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list apps: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var apps []App
|
||||
for rows.Next() {
|
||||
var a App
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Description, &a.Status, &a.RepoOwner, &a.RepoName, &a.PreviewURL, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan app: %w", err)
|
||||
}
|
||||
apps = append(apps, a)
|
||||
}
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAppStatus(ctx context.Context, id uuid.UUID, status string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE apps SET status = $1, updated_at = now() WHERE id = $2`, status, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAppRepo(ctx context.Context, id uuid.UUID, owner, name string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE apps SET repo_owner = $1, repo_name = $2, updated_at = now() WHERE id = $3`, owner, name, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteApp(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM apps WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Session
|
||||
|
||||
type Session struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AppID uuid.UUID `json:"app_id"`
|
||||
Provider string `json:"provider"`
|
||||
Status string `json:"status"`
|
||||
Prompt string `json:"prompt"`
|
||||
Branch string `json:"branch"`
|
||||
PodName string `json:"pod_name,omitempty"`
|
||||
Config []byte `json:"config,omitempty"`
|
||||
CostJSON []byte `json:"cost,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, appID uuid.UUID, provider, prompt, branch string, config []byte) (*Session, error) {
|
||||
sess := &Session{}
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO sessions (app_id, provider, prompt, branch, config)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, app_id, provider, status, prompt, branch, pod_name, config, cost_json, created_at, updated_at`,
|
||||
appID, provider, prompt, branch, config,
|
||||
).Scan(&sess.ID, &sess.AppID, &sess.Provider, &sess.Status, &sess.Prompt, &sess.Branch, &sess.PodName, &sess.Config, &sess.CostJSON, &sess.CreatedAt, &sess.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSession(ctx context.Context, id uuid.UUID) (*Session, error) {
|
||||
sess := &Session{}
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, app_id, provider, status, prompt, branch, pod_name, config, cost_json, created_at, updated_at
|
||||
FROM sessions WHERE id = $1`, id,
|
||||
).Scan(&sess.ID, &sess.AppID, &sess.Provider, &sess.Status, &sess.Prompt, &sess.Branch, &sess.PodName, &sess.Config, &sess.CostJSON, &sess.CreatedAt, &sess.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListSessionsByApp(ctx context.Context, appID uuid.UUID) ([]Session, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, app_id, provider, status, prompt, branch, pod_name, config, cost_json, created_at, updated_at
|
||||
FROM sessions WHERE app_id = $1 ORDER BY created_at DESC`, appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []Session
|
||||
for rows.Next() {
|
||||
var sess Session
|
||||
if err := rows.Scan(&sess.ID, &sess.AppID, &sess.Provider, &sess.Status, &sess.Prompt, &sess.Branch, &sess.PodName, &sess.Config, &sess.CostJSON, &sess.CreatedAt, &sess.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan session: %w", err)
|
||||
}
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSessionStatus(ctx context.Context, id uuid.UUID, status string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE sessions SET status = $1, updated_at = now() WHERE id = $2`, status, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSessionPod(ctx context.Context, id uuid.UUID, podName string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE sessions SET pod_name = $1, updated_at = now() WHERE id = $2`, podName, id)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user