// Package service provides business logic services. package service import ( "context" "crypto/rand" "encoding/hex" "fmt" "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/logging" "github.com/orchard9/rdev/internal/port" ) // SessionService orchestrates interactive remote development sessions. // A session composes checkout (git token) + pod binding + ephemeral preview URL. type SessionService struct { sessionRepo port.SessionRepository checkoutSvc *CheckoutService projectRepo port.ProjectRepository previewMgr port.PreviewManager previewDomain string defaultExpiry time.Duration } // SessionServiceConfig holds configuration for the session service. type SessionServiceConfig struct { // PreviewDomain is the base domain for preview URLs (e.g., "preview.threesix.ai"). PreviewDomain string // DefaultExpiry is the default session duration (default: 24h). DefaultExpiry time.Duration } // NewSessionService creates a new session service. func NewSessionService( sessionRepo port.SessionRepository, checkoutSvc *CheckoutService, projectRepo port.ProjectRepository, previewMgr port.PreviewManager, cfg SessionServiceConfig, ) *SessionService { expiry := cfg.DefaultExpiry if expiry == 0 { expiry = 24 * time.Hour } previewDomain := cfg.PreviewDomain if previewDomain == "" { previewDomain = "preview.threesix.ai" } return &SessionService{ sessionRepo: sessionRepo, checkoutSvc: checkoutSvc, projectRepo: projectRepo, previewMgr: previewMgr, previewDomain: previewDomain, defaultExpiry: expiry, } } // CreateSessionRequest contains parameters for creating a session. type CreateSessionRequest struct { ProjectID domain.ProjectID Branch string NewBranch string FromRef string FeatureSlug string ExpiresIn time.Duration PreviewPort int CreatedBy string } // SessionResult contains the result of a session creation. type SessionResult struct { Session *domain.Session AuthenticatedCloneURL string Branch string Instructions string } // CreateSession creates a new interactive development session. func (s *SessionService) CreateSession(ctx context.Context, req CreateSessionRequest) (*SessionResult, error) { log := logging.FromContext(ctx).WithService("SessionService") // Verify project exists and get pod info. project, err := s.projectRepo.Get(ctx, req.ProjectID) if err != nil { return nil, fmt.Errorf("get project: %w", err) } if project.PodName == "" { return nil, domain.ErrProjectNotRunning } // Check no active session exists for this project. existing, err := s.sessionRepo.GetActiveByProject(ctx, req.ProjectID) if err == nil && existing != nil { if existing.IsExpired() { // Cleanup the expired session inline. _ = s.teardownSession(ctx, existing) } else { return nil, domain.ErrSessionExists } } // Calculate expiry. expiry := s.defaultExpiry if req.ExpiresIn > 0 { expiry = req.ExpiresIn } // Create checkout (git token + branch). checkoutResult, err := s.checkoutSvc.Checkout(ctx, CheckoutRequest{ ProjectID: req.ProjectID, Branch: req.Branch, NewBranch: req.NewBranch, FromRef: req.FromRef, FeatureSlug: req.FeatureSlug, ExpiresIn: expiry, CheckedOutBy: req.CreatedBy, }) if err != nil { return nil, fmt.Errorf("create checkout: %w", err) } // Generate preview slug (short random hex). slug, err := generatePreviewSlug() if err != nil { return nil, fmt.Errorf("generate preview slug: %w", err) } previewHost := slug + "." + s.previewDomain previewURL := "https://" + previewHost // Determine preview port. previewPort := req.PreviewPort if previewPort == 0 { previewPort = 8080 } // Create session record first to get the ID. now := time.Now() session := &domain.Session{ ProjectID: req.ProjectID, CheckoutID: checkoutResult.Checkout.ID, PodName: project.PodName, PreviewURL: previewURL, PreviewHost: previewHost, CreatedBy: req.CreatedBy, CreatedAt: now, ExpiresAt: now.Add(expiry), LastActivityAt: now, Status: domain.SessionStatusActive, } if err := s.sessionRepo.Create(ctx, session); err != nil { // Rollback: revoke checkout token. _ = s.checkoutSvc.Revoke(ctx, checkoutResult.Checkout.ID) return nil, fmt.Errorf("create session record: %w", err) } // Create K8s preview (Service + Ingress). if err := s.previewMgr.CreatePreview(ctx, port.PreviewOptions{ SessionID: string(session.ID), PodName: project.PodName, Host: previewHost, Port: previewPort, }); err != nil { // Rollback: mark session ended and revoke checkout. _ = s.sessionRepo.SetEnded(ctx, session.ID) _ = s.checkoutSvc.Revoke(ctx, checkoutResult.Checkout.ID) return nil, fmt.Errorf("create preview: %w", err) } log.Info("session created", logging.FieldProjectID, req.ProjectID, "session_id", session.ID, "preview_url", previewURL, "pod", project.PodName, "branch", checkoutResult.Checkout.Branch, ) branch := checkoutResult.Checkout.Branch instructions := fmt.Sprintf(`Session started. Preview URL: %s Clone URL: %s Branch: %s Pod: %s Run commands via session: POST /projects/%s/sessions/%s/exec (execute commands) GET /projects/%s/sessions/%s/events (SSE output) End session: POST /projects/%s/sessions/%s/checkin Session expires: %s`, previewURL, checkoutResult.AuthenticatedCloneURL, branch, project.PodName, req.ProjectID, session.ID, req.ProjectID, session.ID, req.ProjectID, session.ID, session.ExpiresAt.Format(time.RFC3339), ) return &SessionResult{ Session: session, AuthenticatedCloneURL: checkoutResult.AuthenticatedCloneURL, Branch: branch, Instructions: instructions, }, nil } // EndSessionRequest contains parameters for ending a session. type EndSessionRequest struct { SessionID domain.SessionID SkipReview bool AutoMerge bool } // EndSessionResult contains the result of ending a session. type EndSessionResult struct { SessionID domain.SessionID ReviewTaskID string Status domain.SessionStatus } // EndSession ends an active session, tearing down preview and completing checkout. func (s *SessionService) EndSession(ctx context.Context, req EndSessionRequest) (*EndSessionResult, error) { log := logging.FromContext(ctx).WithService("SessionService") session, err := s.sessionRepo.Get(ctx, req.SessionID) if err != nil { return nil, err } if session.Status != domain.SessionStatusActive { return nil, domain.ErrSessionNotActive } // Delete preview (Service + Ingress). if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil { log.Warn("failed to delete preview", "session_id", session.ID, logging.FieldError, err, ) // Continue — preview cleanup failure shouldn't block checkin. } // Complete checkout (revoke token, queue review). var reviewTaskID string checkinResult, err := s.checkoutSvc.Checkin(ctx, CheckinRequest{ CheckoutID: session.CheckoutID, SkipReview: req.SkipReview, AutoMerge: req.AutoMerge, }) if err != nil { log.Warn("failed to checkin checkout", "session_id", session.ID, "checkout_id", session.CheckoutID, logging.FieldError, err, ) // Continue — we still want to mark the session ended. } else { reviewTaskID = checkinResult.ReviewTaskID } // Mark session ended. if err := s.sessionRepo.SetEnded(ctx, session.ID); err != nil { return nil, fmt.Errorf("set session ended: %w", err) } log.Info("session ended", logging.FieldProjectID, session.ProjectID, "session_id", session.ID, "review_queued", reviewTaskID != "", ) return &EndSessionResult{ SessionID: session.ID, ReviewTaskID: reviewTaskID, Status: domain.SessionStatusEnded, }, nil } // Get retrieves a session by ID. func (s *SessionService) Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) { return s.sessionRepo.Get(ctx, id) } // ListByProject returns all sessions for a project. func (s *SessionService) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Session, error) { return s.sessionRepo.ListByProject(ctx, projectID) } // TouchActivity updates the last activity timestamp for an active session. func (s *SessionService) TouchActivity(ctx context.Context, id domain.SessionID) error { return s.sessionRepo.TouchActivity(ctx, id) } // SetClaudeSessionID stores the Claude Code session ID and conversation record ID on a session. func (s *SessionService) SetClaudeSessionID(ctx context.Context, id domain.SessionID, claudeSessionID, conversationRecordID string) error { return s.sessionRepo.SetClaudeSessionID(ctx, id, claudeSessionID, conversationRecordID) } // ForceEnd forcefully ends a session without checkout checkin (admin use). func (s *SessionService) ForceEnd(ctx context.Context, id domain.SessionID) error { log := logging.FromContext(ctx).WithService("SessionService") session, err := s.sessionRepo.Get(ctx, id) if err != nil { return err } if session.Status != domain.SessionStatusActive { return domain.ErrSessionNotActive } // Delete preview. if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil { log.Warn("failed to delete preview on force-end", "session_id", session.ID, logging.FieldError, err, ) } // Revoke checkout token. if err := s.checkoutSvc.Revoke(ctx, session.CheckoutID); err != nil { log.Warn("failed to revoke checkout on force-end", "session_id", session.ID, "checkout_id", session.CheckoutID, logging.FieldError, err, ) } // Mark session ended. if err := s.sessionRepo.SetEnded(ctx, session.ID); err != nil { return fmt.Errorf("set session ended: %w", err) } log.Info("session force-ended", logging.FieldProjectID, session.ProjectID, "session_id", session.ID, ) return nil } // CleanupExpired finds and tears down expired sessions. // Returns the number of sessions cleaned up. func (s *SessionService) CleanupExpired(ctx context.Context) (int, error) { log := logging.FromContext(ctx).WithService("SessionService") sessions, err := s.sessionRepo.CleanupExpired(ctx) if err != nil { return 0, fmt.Errorf("cleanup expired sessions: %w", err) } for _, session := range sessions { // Delete preview resources. if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil { log.Warn("failed to delete preview for expired session", "session_id", session.ID, logging.FieldError, err, ) } // Revoke checkout token (the checkout cleanup may also do this). _ = s.checkoutSvc.Revoke(ctx, session.CheckoutID) } if len(sessions) > 0 { log.Info("cleaned up expired sessions", "count", len(sessions)) } return len(sessions), nil } // teardownSession handles cleanup of an expired session found inline. func (s *SessionService) teardownSession(ctx context.Context, session *domain.Session) error { if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil { return err } _ = s.checkoutSvc.Revoke(ctx, session.CheckoutID) return s.sessionRepo.SetEnded(ctx, session.ID) } // generatePreviewSlug generates a short random hex string for preview URLs. func generatePreviewSlug() (string, error) { b := make([]byte, 6) // 12 hex chars if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("generate random bytes: %w", err) } return hex.EncodeToString(b), nil }