// Package service provides business logic / use cases for the application. // Services orchestrate domain operations using port interfaces. package service import ( "context" "encoding/json" "fmt" "log/slog" "sync/atomic" "time" "github.com/google/uuid" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/metrics" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/sanitize" ) // ProjectService handles project-related business logic. type ProjectService struct { projects port.ProjectRepository executor port.CommandExecutor streams port.StreamPublisher auditLogger port.AuditLogger // Optional audit logger queue port.CommandQueue // Optional command queue webhookDispatcher port.WebhookDispatcher // Optional webhook dispatcher logger *slog.Logger cmdID atomic.Uint64 } // NewProjectService creates a new project service. func NewProjectService( projects port.ProjectRepository, executor port.CommandExecutor, streams port.StreamPublisher, ) *ProjectService { return &ProjectService{ projects: projects, executor: executor, streams: streams, logger: slog.Default(), } } // WithLogger sets a custom logger for the service. func (s *ProjectService) WithLogger(logger *slog.Logger) *ProjectService { s.logger = logger return s } // WithAuditLogger sets an audit logger for the service. func (s *ProjectService) WithAuditLogger(auditLogger port.AuditLogger) *ProjectService { s.auditLogger = auditLogger return s } // WithCommandQueue sets a command queue for async execution. func (s *ProjectService) WithCommandQueue(queue port.CommandQueue) *ProjectService { s.queue = queue return s } // WithWebhookDispatcher sets a webhook dispatcher for event notifications. func (s *ProjectService) WithWebhookDispatcher(dispatcher port.WebhookDispatcher) *ProjectService { s.webhookDispatcher = dispatcher return s } // AuditContext contains audit-related information from the request. type AuditContext struct { APIKeyID string ClientIP string UserAgent string } // List returns all available projects with refreshed status. func (s *ProjectService) List(ctx context.Context) ([]domain.Project, error) { // Refresh status from Kubernetes if err := s.projects.RefreshStatus(ctx); err != nil { s.logger.Warn("failed to refresh project status", "error", err) } return s.projects.List(ctx) } // Get returns a specific project by ID. func (s *ProjectService) Get(ctx context.Context, id domain.ProjectID) (*domain.Project, error) { project, err := s.projects.Get(ctx, id) if err != nil { return nil, err } // Refresh status if refreshErr := s.projects.RefreshStatus(ctx); refreshErr != nil { s.logger.Warn("failed to refresh project status", "project", id, "error", refreshErr) } return project, nil } // Exists checks if a project exists. func (s *ProjectService) Exists(ctx context.Context, id domain.ProjectID) (bool, error) { return s.projects.Exists(ctx, id) } // ExecuteClaudeRequest contains parameters for running a Claude command. type ExecuteClaudeRequest struct { ProjectID domain.ProjectID Prompt string StreamID string Audit *AuditContext // Optional audit context } // ExecuteClaudeResult contains the result of queuing a Claude command. type ExecuteClaudeResult struct { CommandID domain.CommandID StreamURL string } // ExecuteClaude runs a Claude command in the project's pod. func (s *ProjectService) ExecuteClaude(ctx context.Context, req ExecuteClaudeRequest) (*ExecuteClaudeResult, error) { // Validate project exists project, err := s.projects.Get(ctx, req.ProjectID) if err != nil { return nil, err } // Validate prompt if req.Prompt == "" { return nil, fmt.Errorf("%w: prompt is required", domain.ErrInvalidCommand) } if err := sanitize.ClaudePrompt(req.Prompt); err != nil { return nil, fmt.Errorf("%w: %v", domain.ErrCommandSanitization, err) } // Validate stream ID if err := sanitize.StreamID(req.StreamID); err != nil { return nil, fmt.Errorf("%w: %v", domain.ErrInvalidCommand, err) } // Generate command ID cmdNum := s.cmdID.Add(1) cmdID := domain.CommandID(fmt.Sprintf("cmd-%s-%03d", req.ProjectID, cmdNum)) if req.StreamID != "" { cmdID = domain.CommandID(req.StreamID) } // Create command cmd := &domain.Command{ ID: cmdID, ProjectID: req.ProjectID, Type: domain.CommandTypeClaude, Args: []string{req.Prompt}, StartedAt: time.Now(), } // Log audit start if audit logger is configured if s.auditLogger != nil && req.Audit != nil { argsJSON, _ := json.Marshal(cmd.Args) auditEntry := &domain.AuditLogEntry{ ID: uuid.New().String(), APIKeyID: req.Audit.APIKeyID, CommandID: string(cmdID), ProjectID: string(req.ProjectID), CommandType: domain.CommandTypeClaude, Args: string(argsJSON), ClientIP: req.Audit.ClientIP, UserAgent: req.Audit.UserAgent, StartedAt: cmd.StartedAt, Status: domain.AuditStatusRunning, } if err := s.auditLogger.LogCommandStart(ctx, auditEntry); err != nil { s.logger.Warn("failed to log audit start", "command_id", cmdID, "error", err) } } // Execute in background go s.executeCommand(project.PodName, cmd) return &ExecuteClaudeResult{ CommandID: cmdID, StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID), }, nil } // ExecuteShellRequest contains parameters for running a shell command. type ExecuteShellRequest struct { ProjectID domain.ProjectID Command string StreamID string Audit *AuditContext // Optional audit context } // ExecuteShellResult contains the result of queuing a shell command. type ExecuteShellResult struct { CommandID domain.CommandID StreamURL string } // ExecuteShell runs a shell command in the project's pod. func (s *ProjectService) ExecuteShell(ctx context.Context, req ExecuteShellRequest) (*ExecuteShellResult, error) { // Validate project exists project, err := s.projects.Get(ctx, req.ProjectID) if err != nil { return nil, err } // Validate command if req.Command == "" { return nil, fmt.Errorf("%w: command is required", domain.ErrInvalidCommand) } if err := sanitize.ShellCommand(req.Command); err != nil { return nil, fmt.Errorf("%w: %v", domain.ErrCommandSanitization, err) } // Validate stream ID if err := sanitize.StreamID(req.StreamID); err != nil { return nil, fmt.Errorf("%w: %v", domain.ErrInvalidCommand, err) } // Generate command ID cmdNum := s.cmdID.Add(1) cmdID := domain.CommandID(fmt.Sprintf("cmd-%s-%03d", req.ProjectID, cmdNum)) if req.StreamID != "" { cmdID = domain.CommandID(req.StreamID) } // Create command cmd := &domain.Command{ ID: cmdID, ProjectID: req.ProjectID, Type: domain.CommandTypeShell, Args: []string{req.Command}, StartedAt: time.Now(), } // Log audit start if audit logger is configured if s.auditLogger != nil && req.Audit != nil { argsJSON, _ := json.Marshal(cmd.Args) auditEntry := &domain.AuditLogEntry{ ID: uuid.New().String(), APIKeyID: req.Audit.APIKeyID, CommandID: string(cmdID), ProjectID: string(req.ProjectID), CommandType: domain.CommandTypeShell, Args: string(argsJSON), ClientIP: req.Audit.ClientIP, UserAgent: req.Audit.UserAgent, StartedAt: cmd.StartedAt, Status: domain.AuditStatusRunning, } if err := s.auditLogger.LogCommandStart(ctx, auditEntry); err != nil { s.logger.Warn("failed to log audit start", "command_id", cmdID, "error", err) } } // Execute in background go s.executeCommand(project.PodName, cmd) return &ExecuteShellResult{ CommandID: cmdID, StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID), }, nil } // ExecuteGitRequest contains parameters for running a git command. type ExecuteGitRequest struct { ProjectID domain.ProjectID Args []string StreamID string Audit *AuditContext // Optional audit context } // ExecuteGitResult contains the result of queuing a git command. type ExecuteGitResult struct { CommandID domain.CommandID StreamURL string } // ExecuteGit runs a git command in the project's pod. func (s *ProjectService) ExecuteGit(ctx context.Context, req ExecuteGitRequest) (*ExecuteGitResult, error) { // Validate project exists project, err := s.projects.Get(ctx, req.ProjectID) if err != nil { return nil, err } // Validate args if len(req.Args) == 0 { return nil, fmt.Errorf("%w: args is required", domain.ErrInvalidCommand) } if err := sanitize.GitArgs(req.Args); err != nil { return nil, fmt.Errorf("%w: %v", domain.ErrCommandSanitization, err) } // Validate stream ID if err := sanitize.StreamID(req.StreamID); err != nil { return nil, fmt.Errorf("%w: %v", domain.ErrInvalidCommand, err) } // Generate command ID cmdNum := s.cmdID.Add(1) cmdID := domain.CommandID(fmt.Sprintf("cmd-%s-%03d", req.ProjectID, cmdNum)) if req.StreamID != "" { cmdID = domain.CommandID(req.StreamID) } // Create command cmd := &domain.Command{ ID: cmdID, ProjectID: req.ProjectID, Type: domain.CommandTypeGit, Args: req.Args, StartedAt: time.Now(), } // Log audit start if audit logger is configured if s.auditLogger != nil && req.Audit != nil { argsJSON, _ := json.Marshal(cmd.Args) auditEntry := &domain.AuditLogEntry{ ID: uuid.New().String(), APIKeyID: req.Audit.APIKeyID, CommandID: string(cmdID), ProjectID: string(req.ProjectID), CommandType: domain.CommandTypeGit, Args: string(argsJSON), ClientIP: req.Audit.ClientIP, UserAgent: req.Audit.UserAgent, StartedAt: cmd.StartedAt, Status: domain.AuditStatusRunning, } if err := s.auditLogger.LogCommandStart(ctx, auditEntry); err != nil { s.logger.Warn("failed to log audit start", "command_id", cmdID, "error", err) } } // Execute in background go s.executeCommand(project.PodName, cmd) return &ExecuteGitResult{ CommandID: cmdID, StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID), }, nil } // executeCommand runs a command and streams output to subscribers. func (s *ProjectService) executeCommand(podName string, cmd *domain.Command) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() streamID := string(cmd.ID) var lastEventID string var outputSizeBytes int64 // Dispatch command.started webhook event s.dispatchWebhookEvent(ctx, string(cmd.ProjectID), domain.WebhookEventCommandStarted, &domain.CommandEventData{ CommandID: string(cmd.ID), CommandType: cmd.Type, ProjectID: string(cmd.ProjectID), StartedAt: cmd.StartedAt, }) result, _ := s.executor.Execute(ctx, cmd, podName, func(line domain.OutputLine) { eventID := s.streams.Publish(streamID, port.StreamEvent{ Type: "output", Data: map[string]any{ "line": line.Line, "stream": line.Stream, }, }) lastEventID = eventID outputSizeBytes += int64(len(line.Line)) }) // Send completion event eventID := s.streams.Publish(streamID, port.StreamEvent{ Type: "complete", Data: map[string]any{ "exit_code": result.ExitCode, "duration_ms": result.DurationMs, }, }) // Record metrics status := "success" if result.ExitCode != 0 { status = "error" } metrics.RecordCommand(string(cmd.ProjectID), string(cmd.Type), status, result.DurationMs) // Log audit completion if audit logger is configured if s.auditLogger != nil { var auditStatus domain.AuditStatus var errorMsg string if result.Error != nil { auditStatus = domain.AuditStatusError errorMsg = result.Error.Error() } else if result.ExitCode != 0 { auditStatus = domain.AuditStatusError } else { auditStatus = domain.AuditStatusSuccess } auditResult := &domain.AuditResult{ ExitCode: result.ExitCode, DurationMs: result.DurationMs, Status: auditStatus, ErrorMessage: errorMsg, OutputSizeBytes: outputSizeBytes, } if err := s.auditLogger.LogCommandEnd(ctx, string(cmd.ID), auditResult); err != nil { s.logger.Warn("failed to log audit end", "command_id", cmd.ID, "error", err) } } // Dispatch command.completed or command.failed webhook event completedAt := time.Now() var webhookEventType domain.WebhookEventType var errorMsg string if result.Error != nil { webhookEventType = domain.WebhookEventCommandFailed errorMsg = result.Error.Error() } else if result.ExitCode != 0 { webhookEventType = domain.WebhookEventCommandFailed } else { webhookEventType = domain.WebhookEventCommandCompleted } s.dispatchWebhookEvent(ctx, string(cmd.ProjectID), webhookEventType, &domain.CommandEventData{ CommandID: string(cmd.ID), CommandType: cmd.Type, ProjectID: string(cmd.ProjectID), StartedAt: cmd.StartedAt, CompletedAt: completedAt, ExitCode: result.ExitCode, DurationMs: result.DurationMs, Error: errorMsg, }) s.logger.Debug("command completed", "command_id", cmd.ID, "exit_code", result.ExitCode, "duration_ms", result.DurationMs, "last_event_id", lastEventID, "complete_event_id", eventID, ) // Clean up stream after a delay go func() { time.Sleep(30 * time.Second) s.streams.Close(streamID) }() } // dispatchWebhookEvent dispatches a webhook event if a dispatcher is configured. func (s *ProjectService) dispatchWebhookEvent(ctx context.Context, projectID string, eventType domain.WebhookEventType, data any) { if s.webhookDispatcher == nil { return } event := &domain.WebhookEvent{ Type: eventType, Timestamp: time.Now(), ProjectID: projectID, Data: data, } if err := s.webhookDispatcher.Dispatch(ctx, projectID, event); err != nil { s.logger.Warn("failed to dispatch webhook event", "project_id", projectID, "event_type", eventType, "error", err, ) } } // Subscribe returns a channel for receiving stream events. func (s *ProjectService) Subscribe(streamID string) (<-chan port.StreamEvent, func()) { return s.streams.Subscribe(streamID) } // SubscribeFromID returns a channel for receiving stream events, starting from a specific event ID. // This is used for SSE reconnection with Last-Event-ID support. func (s *ProjectService) SubscribeFromID(streamID, lastEventID string) (<-chan port.StreamEvent, func()) { return s.streams.SubscribeFromID(streamID, lastEventID) } // EnqueueCommandRequest contains parameters for enqueueing a command. type EnqueueCommandRequest struct { ProjectID domain.ProjectID Command string CommandType domain.CommandType WorkingDir string Priority int Audit *AuditContext } // EnqueueCommandResult contains the result of enqueueing a command. type EnqueueCommandResult struct { CommandID domain.QueuedCommandID StreamURL string Position int } // EnqueueCommand adds a command to the project's queue for async execution. // Returns an error if no queue is configured. func (s *ProjectService) EnqueueCommand(ctx context.Context, req EnqueueCommandRequest) (*EnqueueCommandResult, error) { if s.queue == nil { return nil, fmt.Errorf("command queue not configured") } // Validate project exists exists, err := s.projects.Exists(ctx, req.ProjectID) if err != nil { return nil, err } if !exists { return nil, domain.ErrProjectNotFound } // Create queued command cmd := &domain.QueuedCommand{ ProjectID: string(req.ProjectID), Command: req.Command, CommandType: req.CommandType, WorkingDir: req.WorkingDir, Status: domain.QueueStatusPending, Priority: req.Priority, } if req.Audit != nil { cmd.APIKeyID = req.Audit.APIKeyID } // Enqueue if err := s.queue.Enqueue(ctx, cmd); err != nil { return nil, fmt.Errorf("enqueue command: %w", err) } // Get approximate position pendingStatus := domain.QueueStatusPending pending, _ := s.queue.List(ctx, string(req.ProjectID), &domain.QueueFilters{ Status: &pendingStatus, Limit: 1000, SortOrder: "asc", }) return &EnqueueCommandResult{ CommandID: cmd.ID, StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmd.ID), Position: len(pending), }, nil } // GetQueuedCommand retrieves a queued command by ID. func (s *ProjectService) GetQueuedCommand(ctx context.Context, cmdID domain.QueuedCommandID) (*domain.QueuedCommand, error) { if s.queue == nil { return nil, fmt.Errorf("command queue not configured") } return s.queue.GetByID(ctx, cmdID) } // ListQueuedCommands returns queued commands for a project. func (s *ProjectService) ListQueuedCommands(ctx context.Context, projectID domain.ProjectID, filters *domain.QueueFilters) ([]*domain.QueuedCommand, error) { if s.queue == nil { return nil, fmt.Errorf("command queue not configured") } return s.queue.List(ctx, string(projectID), filters) } // CancelQueuedCommand cancels a pending queued command. func (s *ProjectService) CancelQueuedCommand(ctx context.Context, cmdID domain.QueuedCommandID) error { if s.queue == nil { return fmt.Errorf("command queue not configured") } return s.queue.Cancel(ctx, cmdID) } // GetQueueStats returns queue statistics for a project. func (s *ProjectService) GetQueueStats(ctx context.Context, projectID domain.ProjectID) (*domain.QueueStats, error) { if s.queue == nil { return nil, fmt.Errorf("command queue not configured") } return s.queue.GetStats(ctx, string(projectID)) }