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 }