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>
431 lines
12 KiB
Go
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
|
|
}
|