rdev/internal/service/session_service.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

431 lines
12 KiB
Go

// 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,
}
}
// WebUIPort is the default port for the Claude Code web UI.
const WebUIPort = 3001
// 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
WebUI bool
}
// 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 {
if req.WebUI {
previewPort = WebUIPort
} else {
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,
WebUI: req.WebUI,
}
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,
"web_ui", req.WebUI,
)
branch := checkoutResult.Checkout.Branch
var instructions string
if req.WebUI {
instructions = fmt.Sprintf(`Session started (Web UI mode).
Web UI: %s
Clone URL: %s
Branch: %s
Pod: %s
Open the Web UI URL in your browser to interact with Claude Code.
End session:
POST /projects/%s/sessions/%s/checkin
Session expires: %s`,
previewURL,
checkoutResult.AuthenticatedCloneURL,
branch,
project.PodName,
req.ProjectID, session.ID,
session.ExpiresAt.Format(time.RFC3339),
)
} else {
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
}