agent-orchestrator/backend/internal/service/session/service.go

308 lines
11 KiB
Go

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
}