Files
agent-mgr/internal/app/service.go
Steven Hooker e5b07cc1d8 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)
2026-02-18 15:56:32 +01:00

171 lines
5.0 KiB
Go

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
}