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")
}

170
internal/app/service.go Normal file
View 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
View 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
View 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")
}

View 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
}
}

View 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 }

View 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
}

View 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)
}

View 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
}

View 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
View 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
}