rdev/internal/handlers/sessions.go
jordan e42c18a9a3 feat: add session web UI mode + aeries-daeya cookbook tree
Session WebUI:
- Add `web_ui` flag to session create — launches claude-code-ui in pod on port 3001
- Install @siteboon/claude-code-ui in claudebox Dockerfile, expose port 3001
- Migration 027: add web_ui column to sessions table
- startWebUI/stopWebUI fire-and-forget helpers in SessionsHandler
- Service selects preview port 3001 (web UI) vs 8080 (sidecar) based on flag

Aeries Daeya cookbook:
- Add cookbooks/trees/aeries-daeya.yaml: privacy-first avatar social platform
  (infra → avatar data model → AI generation pipeline → studio UI)
- Add cookbooks/scripts/aeries-daeya-test.sh: run/status/diagnose/teardown harness
- Fix race condition in common.sh wait_for_pipeline: detect already-running pipelines
  at startup and track directly instead of waiting for a newer one

Docs/tooling:
- Add SDK Update Workflow section to CLAUDE.md
- Add `make sdk` and `make sdk-check` targets for OpenAPI spec management

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 23:14:08 -07:00

467 lines
14 KiB
Go

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
conversationService *service.ConversationService
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,
}
}
// WithConversationService attaches a conversation service for message persistence.
func (h *SessionsHandler) WithConversationService(svc *service.ConversationService) *SessionsHandler {
h.conversationService = svc
return h
}
// Mount registers the session routes.
func (h *SessionsHandler) Mount(r api.Router) {
r.Route("/projects/{id}/sessions", func(r chi.Router) {
r.Use(auth.RequireProjectAccess("id"))
// 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
WebUI bool `json:"web_ui,omitempty"` // Start Claude Code web UI in the pod
}
// 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
ClaudeSessionID string `json:"claude_session_id,omitempty"` // Set after first claude exec
ConversationRecordID string `json:"conversation_record_id,omitempty"` // Linked conversation
WebUI bool `json:"web_ui,omitempty"` // Web UI mode active
}
// 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,
WebUI: req.WebUI,
})
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
}
// Start Claude Code web UI in the pod if requested.
if req.WebUI {
h.startWebUI(r.Context(), result.Session)
}
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
}
// Kill web UI process before tearing down the session.
if session.WebUI {
h.stopWebUI(r.Context(), session)
}
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
}
// Kill web UI process before force-ending the session.
if session.WebUI {
h.stopWebUI(r.Context(), session)
}
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),
ClaudeSessionID: s.ClaudeSessionID,
ConversationRecordID: s.ConversationRecordID,
WebUI: s.WebUI,
}
if s.EndedAt != nil {
t := s.EndedAt.Format(time.RFC3339)
resp.EndedAt = &t
}
return resp
}