package session import ( "context" "errors" "fmt" "strings" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" "github.com/aoagents/agent-orchestrator/backend/internal/ports" sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" ) // Store is the read-only persistence surface needed to assemble controller-facing session read models. type Store interface { GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) RenameSession(ctx context.Context, id domain.SessionID, displayName string, updatedAt time.Time) (bool, error) GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) ListPRsBySession(ctx context.Context, sessionID domain.SessionID) ([]domain.PullRequest, error) ListPRComments(ctx context.Context, prURL string) ([]domain.PullRequestComment, error) GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) } // ListFilter captures API-facing session list query filters. type ListFilter struct { ProjectID domain.ProjectID Active *bool OrchestratorOnly bool Fresh bool } // commander is the command-side surface Service delegates to: the // *sessionmanager.Manager in production, a fake in tests. type commander interface { Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) Restore(ctx context.Context, id domain.SessionID) (domain.SessionRecord, error) Kill(ctx context.Context, id domain.SessionID) (bool, error) Send(ctx context.Context, id domain.SessionID, message string) error Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) RollbackSpawn(ctx context.Context, id domain.SessionID) (deleted, killed bool, err error) } // RollbackOutcome reports what happened in a rollback: either the seed row was // deleted, or the partially-spawned session was killed (runtime+workspace torn // down, row marked terminated). type RollbackOutcome struct { Deleted bool `json:"deleted"` Killed bool `json:"killed"` } type scmProvider interface { ParseRepository(remote string) (ports.SCMRepo, bool) FetchPullRequests(ctx context.Context, refs []ports.SCMPRRef) ([]ports.SCMObservation, error) FetchReviewThreads(ctx context.Context, ref ports.SCMPRRef) (ports.SCMReviewObservation, error) } // Service is the controller-facing session service. It delegates command-side // session operations to the internal sessionmanager.Manager and owns read-model // assembly, including user-facing display status derivation. type Service struct { manager commander store Store prClaimer ports.PRClaimer scm scmProvider clock func() time.Time } // New wires a controller-facing session service over an internal session Manager. func New(manager *sessionmanager.Manager, store Store) *Service { return NewWithDeps(Deps{Manager: manager, Store: store}) } // Deps are optional collaborators for the session service. The default New // path keeps existing tests and callers small; daemon wiring uses NewWithDeps // to supply SCM observation for PR claiming. type Deps struct { Manager commander Store Store PRClaimer ports.PRClaimer SCM scmProvider Clock func() time.Time } // NewWithDeps wires a session service with optional PR-claim dependencies. func NewWithDeps(d Deps) *Service { s := &Service{manager: d.Manager, store: d.Store, prClaimer: d.PRClaimer, scm: d.SCM, clock: d.Clock} if s.prClaimer == nil { if w, ok := d.Store.(ports.PRClaimer); ok { s.prClaimer = w } } if s.clock == nil { s.clock = time.Now } return s } // Spawn creates a session and returns the API-facing read model. func (s *Service) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { if err := s.requireProject(ctx, cfg.ProjectID); err != nil { return domain.Session{}, err } rec, err := s.manager.Spawn(ctx, cfg) if err != nil { return domain.Session{}, toAPIError(err) } return s.toSession(ctx, rec) } // requireProject verifies the project is registered before any spawn write // touches the session store, so an unknown projectId surfaces as a typed 404 // rather than an opaque 500 with an orphan terminated row left behind. func (s *Service) requireProject(ctx context.Context, id domain.ProjectID) error { if id == "" { return apierr.Invalid("PROJECT_ID_REQUIRED", "projectId is required", nil) } if s.store == nil { return nil } _, ok, err := s.store.GetProject(ctx, string(id)) if err != nil { return fmt.Errorf("get project %s: %w", id, err) } if !ok { return apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project — register it with `ao project add`") } return nil } // SpawnOrchestrator spawns an orchestrator session for a project. When clean is // true it first tears down any active orchestrator(s) for that project so the new // one is the only live coordinator — a business rule that belongs here, not in the // HTTP controller. func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) { if err := s.requireProject(ctx, projectID); err != nil { return domain.Session{}, err } if clean { active := true existing, err := s.List(ctx, ListFilter{ProjectID: projectID, Active: &active, OrchestratorOnly: true}) if err != nil { return domain.Session{}, err } for _, orch := range existing { if _, err := s.Kill(ctx, orch.ID); err != nil { return domain.Session{}, err } } } return s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) } // Restore relaunches a terminated session and returns the API-facing read model. func (s *Service) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { rec, err := s.manager.Restore(ctx, id) if err != nil { return domain.Session{}, toAPIError(err) } return s.toSession(ctx, rec) } // Kill delegates terminal intent and teardown to the internal manager. func (s *Service) Kill(ctx context.Context, id domain.SessionID) (bool, error) { freed, err := s.manager.Kill(ctx, id) return freed, toAPIError(err) } // RollbackSpawn deletes a seed-state session row, or falls back to a Kill if // the session has spawn output. Used by the CLI to undo a `spawn --claim-pr` // when the claim step fails, avoiding the orphan terminated row that a plain // Kill would leave behind. func (s *Service) RollbackSpawn(ctx context.Context, id domain.SessionID) (RollbackOutcome, error) { deleted, killed, err := s.manager.RollbackSpawn(ctx, id) if err != nil { return RollbackOutcome{}, toAPIError(err) } return RollbackOutcome{Deleted: deleted, Killed: killed}, nil } // Send delegates agent messaging to the internal manager. func (s *Service) Send(ctx context.Context, id domain.SessionID, message string) error { return toAPIError(s.manager.Send(ctx, id, message)) } // Rename updates the user-facing session display name. func (s *Service) Rename(ctx context.Context, id domain.SessionID, displayName string) error { displayName = strings.TrimSpace(displayName) if displayName == "" { return apierr.Invalid("DISPLAY_NAME_REQUIRED", "Display name is required", nil) } renamed, err := s.store.RenameSession(ctx, id, displayName, time.Now().UTC()) if err != nil { return fmt.Errorf("rename %s: %w", id, err) } if !renamed { return apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } return nil } // Cleanup delegates terminal workspace cleanup to the internal manager. func (s *Service) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) { return s.manager.Cleanup(ctx, project) } // List returns sessions as enriched display models after applying API filters. func (s *Service) List(ctx context.Context, filter ListFilter) ([]domain.Session, error) { recs, err := s.listRecords(ctx, filter.ProjectID) if err != nil { return nil, err } out := make([]domain.Session, 0, len(recs)) for _, rec := range recs { if !matchesSessionFilter(rec, filter) { continue } sess, err := s.toSession(ctx, rec) if err != nil { return nil, err } out = append(out, sess) } return out, nil } func (s *Service) listRecords(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { if project == "" { recs, err := s.store.ListAllSessions(ctx) if err != nil { return nil, fmt.Errorf("list all sessions: %w", err) } return recs, nil } recs, err := s.store.ListSessions(ctx, project) if err != nil { return nil, fmt.Errorf("list %s: %w", project, err) } return recs, nil } func matchesSessionFilter(rec domain.SessionRecord, filter ListFilter) bool { if filter.Active != nil && rec.IsTerminated == *filter.Active { return false } if filter.OrchestratorOnly && rec.Kind != domain.KindOrchestrator { return false } if filter.Fresh && rec.IsTerminated { return false } return true } // Get returns one session as an enriched display model, or an apierr.NotFound // (SESSION_NOT_FOUND) if it is absent. func (s *Service) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) { rec, ok, err := s.store.GetSession(ctx, id) if err != nil { return domain.Session{}, fmt.Errorf("get %s: %w", id, err) } if !ok { return domain.Session{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } return s.toSession(ctx, rec) } // toAPIError maps the session engine's sentinel errors to their REST API // equivalents; an unrecognized error passes through and surfaces as a 500. func toAPIError(err error) error { switch { case err == nil: return nil case errors.Is(err, sessionmanager.ErrNotFound): return apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") case errors.Is(err, sessionmanager.ErrNotRestorable): return apierr.Conflict("SESSION_NOT_RESTORABLE", "Session is not restorable", nil) case errors.Is(err, sessionmanager.ErrTerminated): return apierr.Conflict("SESSION_TERMINATED", "Session is terminated", nil) case errors.Is(err, sessionmanager.ErrIncompleteHandle): return apierr.Conflict("SESSION_INCOMPLETE_HANDLE", "Session is missing runtime or workspace handles", nil) case errors.Is(err, sessionmanager.ErrProjectNotResolvable): return apierr.Invalid("PROJECT_NOT_RESOLVABLE", "Project is not registered or has no repo — register it with `ao project add`", nil) case errors.Is(err, ports.ErrWorkspaceBranchCheckedOutElsewhere): return apierr.Conflict("BRANCH_CHECKED_OUT_ELSEWHERE", err.Error(), nil) case errors.Is(err, ports.ErrWorkspaceBranchNotFetched): return apierr.Invalid("BRANCH_NOT_FETCHED", err.Error(), nil) case errors.Is(err, ports.ErrAgentBinaryNotFound): return apierr.Invalid("AGENT_BINARY_NOT_FOUND", err.Error(), nil) default: return err } } func (s *Service) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) { pr, ok, err := s.store.GetDisplayPRFactsForSession(ctx, rec.ID) if err != nil { return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err) } if !ok { return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, nil), TerminalHandleID: rec.Metadata.RuntimeHandleID}, nil } return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, &pr), TerminalHandleID: rec.Metadata.RuntimeHandleID}, nil }