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)
171 lines
5.0 KiB
Go
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
|
|
}
|