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>
467 lines
14 KiB
Go
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
|
|
}
|