Major refactoring to hexagonal (ports & adapters) architecture: - Add service layer (apikey_service, project_service) for business logic - Add webhook system with dispatcher and delivery tracking - Add command queue with priority-based processing - Add rate limiting with sliding window algorithm - Add audit logging for command execution - Add OpenTelemetry integration (traces, metrics, spans) - Add circuit breaker for fault tolerance - Add cached repository wrapper for performance - Add comprehensive validation package - Add Kubernetes client integration for pod management - Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks) - Add network policy and PodDisruptionBudget for k8s - Remove legacy executor and projects/registry packages - Untrack secrets.yaml (now managed via envault) - Add coverage.out to .gitignore - Add e2e test infrastructure with docker-compose - Add comprehensive documentation (API, architecture, operations, plans) - Add golangci-lint config and pre-commit hook Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
585 lines
17 KiB
Go
585 lines
17 KiB
Go
// 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))
|
|
}
|