package handlers import ( "context" "errors" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/pkg/api" ) // SessionsHandler handles interactive remote development session endpoints. type SessionsHandler struct { sessionService *service.SessionService executor port.CommandExecutor streams port.StreamPublisher } // NewSessionsHandler creates a new sessions handler. func NewSessionsHandler( sessionService *service.SessionService, executor port.CommandExecutor, streams port.StreamPublisher, ) *SessionsHandler { return &SessionsHandler{ sessionService: sessionService, executor: executor, streams: streams, } } // Mount registers the session routes. func (h *SessionsHandler) Mount(r api.Router) { r.Route("/projects/{id}/sessions", func(r chi.Router) { // List sessions (read access) r.With(auth.RequireScope(auth.ScopeSessionsRead, auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/", h.List) // Create session (execute access) r.With(auth.RequireScope(auth.ScopeSessionsExecute, auth.ScopeProjectsExecute, auth.ScopeAdmin)). Post("/", h.Create) // Get session (read access) r.With(auth.RequireScope(auth.ScopeSessionsRead, auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/{sid}", h.Get) // End session via checkin (execute access) r.With(auth.RequireScope(auth.ScopeSessionsExecute, auth.ScopeProjectsExecute, auth.ScopeAdmin)). Post("/{sid}/checkin", h.Checkin) // Execute command in session context r.With(auth.RequireScope(auth.ScopeSessionsExecute, auth.ScopeProjectsExecute, auth.ScopeAdmin)). Post("/{sid}/exec", h.Exec) // Stream session command output via SSE r.With(auth.RequireScope(auth.ScopeSessionsRead, auth.ScopeProjectsRead, auth.ScopeAdmin)). Get("/{sid}/events", h.Events) // Force-terminate session (admin only) r.With(auth.RequireScope(auth.ScopeAdmin)). Delete("/{sid}", h.Delete) }) } // CreateSessionRequest is the JSON body for creating a session. type CreateSessionRequest struct { Branch string `json:"branch,omitempty"` NewBranch string `json:"new_branch,omitempty"` FromRef string `json:"from_ref,omitempty"` FeatureSlug string `json:"feature_slug,omitempty"` ExpiresIn string `json:"expires_in,omitempty"` // Duration string (e.g., "24h", "7d") PreviewPort int `json:"preview_port,omitempty"` // Default: 8080 } // SessionResponse is the JSON response for a session. type SessionResponse struct { ID string `json:"id"` ProjectID string `json:"project_id"` CheckoutID string `json:"checkout_id"` PodName string `json:"pod_name"` PreviewURL string `json:"preview_url"` Status string `json:"status"` CreatedBy string `json:"created_by"` CreatedAt string `json:"created_at"` ExpiresAt string `json:"expires_at"` EndedAt *string `json:"ended_at,omitempty"` AuthCloneURL string `json:"auth_clone_url,omitempty"` // Only at creation Branch string `json:"branch,omitempty"` // Only at creation Instructions string `json:"instructions,omitempty"` // Only at creation } // SessionCheckinRequest is the JSON body for ending a session. type SessionCheckinRequest struct { SkipReview bool `json:"skip_review,omitempty"` AutoMerge bool `json:"auto_merge,omitempty"` } // SessionCheckinResponse is the JSON response for ending a session. type SessionCheckinResponse struct { SessionID string `json:"session_id"` Status string `json:"status"` ReviewTaskID string `json:"review_task_id,omitempty"` Message string `json:"message"` } // List returns all sessions for a project. // GET /projects/{id}/sessions func (h *SessionsHandler) List(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") if err := domain.ValidateProjectID(projectID); err != nil { api.WriteBadRequest(w, r, "invalid project id") return } ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup) defer cancel() sessions, err := h.sessionService.ListByProject(ctx, domain.ProjectID(projectID)) if err != nil { api.WriteInternalError(w, r, "Failed to list sessions") return } resp := make([]SessionResponse, len(sessions)) for i, s := range sessions { resp[i] = sessionToResponse(s) } api.WriteSuccess(w, r, map[string]any{ "sessions": resp, }) } // Create creates a new interactive development session. // POST /projects/{id}/sessions func (h *SessionsHandler) Create(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") if err := domain.ValidateProjectID(projectID); err != nil { api.WriteBadRequest(w, r, "invalid project id") return } var req CreateSessionRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } // Validate: need either branch or new_branch. if req.Branch == "" && req.NewBranch == "" { api.WriteBadRequest(w, r, "branch or new_branch is required") return } if req.Branch != "" && req.NewBranch != "" { api.WriteBadRequest(w, r, "specify either branch or new_branch, not both") return } // Validate branch name if provided. if req.NewBranch != "" { if err := validate.Name(req.NewBranch, "new_branch"); err != nil { api.WriteBadRequest(w, r, err.Error()) return } } // Parse expiry duration. var expiresIn time.Duration if req.ExpiresIn != "" { var err error expiresIn, err = time.ParseDuration(req.ExpiresIn) if err != nil { // Try parsing as days (e.g., "7d"). if len(req.ExpiresIn) > 1 && req.ExpiresIn[len(req.ExpiresIn)-1] == 'd' { days, parseErr := strconv.Atoi(req.ExpiresIn[:len(req.ExpiresIn)-1]) if parseErr == nil && days > 0 && days <= 30 { expiresIn = time.Duration(days) * 24 * time.Hour } else { api.WriteBadRequest(w, r, "expires_in must be a valid duration (e.g., 24h, 7d)") return } } else { api.WriteBadRequest(w, r, "expires_in must be a valid duration (e.g., 24h, 7d)") return } } if expiresIn > 30*24*time.Hour { api.WriteBadRequest(w, r, "expires_in cannot exceed 30 days") return } } // Get user from API key. createdBy := "unknown" if apiKey := auth.GetAPIKey(r.Context()); apiKey != nil { createdBy = string(apiKey.ID) } ctx, cancel := context.WithTimeout(r.Context(), TimeoutOrchestration) defer cancel() result, err := h.sessionService.CreateSession(ctx, service.CreateSessionRequest{ ProjectID: domain.ProjectID(projectID), Branch: req.Branch, NewBranch: req.NewBranch, FromRef: req.FromRef, FeatureSlug: req.FeatureSlug, ExpiresIn: expiresIn, PreviewPort: req.PreviewPort, CreatedBy: createdBy, }) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, "project not found") return } if errors.Is(err, domain.ErrProjectNotRunning) { api.WriteBadRequest(w, r, "project pod is not running") return } if errors.Is(err, domain.ErrSessionExists) { api.WriteError(w, r, http.StatusConflict, "SESSION_EXISTS", "active session already exists for this project") return } if errors.Is(err, domain.ErrBranchNotFound) { api.WriteNotFound(w, r, "branch not found") return } if errors.Is(err, domain.ErrBranchProtected) { api.WriteBadRequest(w, r, "cannot checkout protected branch") return } if errors.Is(err, domain.ErrCheckoutAlreadyExists) { api.WriteError(w, r, http.StatusConflict, "CHECKOUT_EXISTS", "active checkout already exists for this branch") return } api.WriteInternalError(w, r, "Failed to create session") return } resp := sessionToResponse(result.Session) resp.AuthCloneURL = result.AuthenticatedCloneURL resp.Branch = result.Branch resp.Instructions = result.Instructions api.WriteCreated(w, r, resp) } // Get retrieves a session by ID. // GET /projects/{id}/sessions/{sid} func (h *SessionsHandler) Get(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") if err := domain.ValidateProjectID(projectID); err != nil { api.WriteBadRequest(w, r, "invalid project id") return } sid := chi.URLParam(r, "sid") if sid == "" { api.WriteBadRequest(w, r, "session id is required") return } ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup) defer cancel() session, err := h.sessionService.Get(ctx, domain.SessionID(sid)) if err != nil { if errors.Is(err, domain.ErrSessionNotFound) { api.WriteNotFound(w, r, "session not found") return } api.WriteInternalError(w, r, "Failed to get session") return } // Verify session belongs to project. if string(session.ProjectID) != projectID { api.WriteNotFound(w, r, "session not found") return } api.WriteSuccess(w, r, sessionToResponse(session)) } // Checkin ends a session and optionally queues a review. // POST /projects/{id}/sessions/{sid}/checkin func (h *SessionsHandler) Checkin(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") if err := domain.ValidateProjectID(projectID); err != nil { api.WriteBadRequest(w, r, "invalid project id") return } sid := chi.URLParam(r, "sid") if sid == "" { api.WriteBadRequest(w, r, "session id is required") return } var req SessionCheckinRequest if err := api.DecodeJSON(r, &req); err != nil { req = SessionCheckinRequest{} } ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() // Verify session exists and belongs to project. session, err := h.sessionService.Get(ctx, domain.SessionID(sid)) if err != nil { if errors.Is(err, domain.ErrSessionNotFound) { api.WriteNotFound(w, r, "session not found") return } api.WriteInternalError(w, r, "Failed to get session") return } if string(session.ProjectID) != projectID { api.WriteNotFound(w, r, "session not found") return } result, err := h.sessionService.EndSession(ctx, service.EndSessionRequest{ SessionID: domain.SessionID(sid), SkipReview: req.SkipReview, AutoMerge: req.AutoMerge, }) if err != nil { if errors.Is(err, domain.ErrSessionNotFound) { api.WriteNotFound(w, r, "session not found") return } if errors.Is(err, domain.ErrSessionNotActive) { api.WriteBadRequest(w, r, "session is not active") return } api.WriteInternalError(w, r, "Failed to end session") return } message := "Session ended. Preview removed, token revoked." if result.ReviewTaskID != "" { message = "Session ended. Review task queued." } api.WriteSuccess(w, r, SessionCheckinResponse{ SessionID: string(result.SessionID), Status: string(result.Status), ReviewTaskID: result.ReviewTaskID, Message: message, }) } // Delete force-terminates a session (admin only). // DELETE /projects/{id}/sessions/{sid} func (h *SessionsHandler) Delete(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") if err := domain.ValidateProjectID(projectID); err != nil { api.WriteBadRequest(w, r, "invalid project id") return } sid := chi.URLParam(r, "sid") if sid == "" { api.WriteBadRequest(w, r, "session id is required") return } ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() // Verify session exists and belongs to project. session, err := h.sessionService.Get(ctx, domain.SessionID(sid)) if err != nil { if errors.Is(err, domain.ErrSessionNotFound) { api.WriteNotFound(w, r, "session not found") return } api.WriteInternalError(w, r, "Failed to get session") return } if string(session.ProjectID) != projectID { api.WriteNotFound(w, r, "session not found") return } if err := h.sessionService.ForceEnd(ctx, domain.SessionID(sid)); err != nil { if errors.Is(err, domain.ErrSessionNotFound) { api.WriteNotFound(w, r, "session not found") return } if errors.Is(err, domain.ErrSessionNotActive) { api.WriteBadRequest(w, r, "session is not active") return } api.WriteInternalError(w, r, "Failed to terminate session") return } api.WriteSuccess(w, r, map[string]string{ "status": "terminated", "id": sid, "message": "Session force-terminated. Preview removed, token revoked.", }) } // sessionToResponse converts a domain session to a response. func sessionToResponse(s *domain.Session) SessionResponse { resp := SessionResponse{ ID: string(s.ID), ProjectID: string(s.ProjectID), CheckoutID: string(s.CheckoutID), PodName: s.PodName, PreviewURL: s.PreviewURL, Status: string(s.Status), CreatedBy: s.CreatedBy, CreatedAt: s.CreatedAt.Format(time.RFC3339), ExpiresAt: s.ExpiresAt.Format(time.RFC3339), } if s.EndedAt != nil { t := s.EndedAt.Format(time.RFC3339) resp.EndedAt = &t } return resp }