package service import ( "context" "database/sql" "fmt" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/logging" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/sdlc" ) // GitCommitResult describes the outcome of a git commit+push operation. type GitCommitResult struct { HasChanges bool CommitSHA string FilesChanged []string Pushed bool Error error } // PodGitCommitter abstracts git commit/push operations in pods. // This avoids importing internal/worker directly, preventing import cycles. type PodGitCommitter interface { CommitAndPush(ctx context.Context, podName, workDir, message string, push bool) *GitCommitResult } // SDLCOrchestratorService orchestrates SDLC actions: execute, resolve, commit. type SDLCOrchestratorService struct { sdlcService *SDLCService agentRegistry port.CodeAgentRegistry gitCommitter PodGitCommitter projectRepo port.ProjectRepository buildService *BuildService // For async dispatch of heavy actions db *sql.DB // For git URL lookup } // NewSDLCOrchestratorService creates a new orchestrator service. func NewSDLCOrchestratorService( sdlcService *SDLCService, agentRegistry port.CodeAgentRegistry, gitCommitter PodGitCommitter, projectRepo port.ProjectRepository, buildService *BuildService, db *sql.DB, ) *SDLCOrchestratorService { return &SDLCOrchestratorService{ sdlcService: sdlcService, agentRegistry: agentRegistry, gitCommitter: gitCommitter, projectRepo: projectRepo, buildService: buildService, db: db, } } // ExecuteRequest describes what action to execute. type ExecuteRequest struct { Feature string `json:"feature"` Provider string `json:"provider,omitempty"` } // ExecutionResult is the result of executing an SDLC action. type ExecutionResult struct { Action sdlc.ActionType `json:"action"` Success bool `json:"success"` Output string `json:"output,omitempty"` Next *sdlc.Classification `json:"next,omitempty"` Error string `json:"error,omitempty"` TaskID string `json:"task_id,omitempty"` // Set when dispatched async Async bool `json:"async,omitempty"` // True when enqueued to work queue } // ResolveRequest describes a blocker resolution. type ResolveRequest struct { Feature string `json:"feature"` } // CommitRequest describes a commit operation. type CommitRequest struct { Feature string `json:"feature"` Message string `json:"message"` Push bool `json:"push"` } // CommitResult is the result of a commit operation. type CommitResult struct { CommitSHA string `json:"commit_sha,omitempty"` FilesChanged []string `json:"files_changed,omitempty"` Pushed bool `json:"pushed"` } // ExecuteAction gets the next classifier action and executes it. func (s *SDLCOrchestratorService) ExecuteAction(ctx context.Context, projectID string, req *ExecuteRequest) (*ExecutionResult, error) { cl, err := s.sdlcService.GetNext(ctx, projectID, req.Feature) if err != nil { return nil, err } result := &ExecutionResult{ Action: cl.Action, } switch { case cl.Action == sdlc.ActionTransition: err = s.executeTransition(ctx, projectID, cl) case cl.Action == sdlc.ActionIdle || cl.Action == sdlc.ActionBlocked || cl.Action == sdlc.ActionAwaitApproval: result.Output = cl.Message result.Success = true case sdlc.IsHeavyAction(cl.Action) && s.buildService != nil: err = s.executeAgentActionAsync(ctx, projectID, cl, req, result) default: err = s.executeAgentAction(ctx, projectID, cl, req, result) } if err != nil { result.Error = err.Error() return result, nil } if result.Error == "" { result.Success = true } next, nextErr := s.sdlcService.GetNext(ctx, projectID, req.Feature) if nextErr == nil { result.Next = next } return result, nil } func (s *SDLCOrchestratorService) executeTransition(ctx context.Context, projectID string, cl *sdlc.Classification) error { log := logging.FromContext(ctx).WithService("sdlc_orchestrator") log.Info("executing transition", logging.FieldProjectID, projectID, "feature", cl.Feature, "to_phase", string(cl.TransitionTo), ) return s.sdlcService.TransitionFeature(ctx, projectID, cl.Feature, cl.TransitionTo) } func (s *SDLCOrchestratorService) executeAgentAction(ctx context.Context, projectID string, cl *sdlc.Classification, req *ExecuteRequest, result *ExecutionResult) error { var agent port.CodeAgent if req.Provider != "" { provider, err := domain.ParseAgentProvider(req.Provider) if err != nil { return fmt.Errorf("invalid agent provider: %w", err) } agent = s.agentRegistry.Get(provider) } else { agent = s.agentRegistry.Default() } if agent == nil { return fmt.Errorf("no agent available") } project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID)) if err != nil { return fmt.Errorf("resolve project: %w", err) } prompt := cl.NextCommand if prompt == "" { prompt = fmt.Sprintf("Execute SDLC action: %s for feature %s", cl.Action, cl.Feature) } agentReq := &domain.AgentRequest{ Prompt: prompt, ProjectID: project.ID, Timeout: sdlc.ActionTimeout(cl.Action), Metadata: map[string]string{ "pod_name": project.PodName, "namespace": "rdev", "action": string(cl.Action), "feature": cl.Feature, }, } log := logging.FromContext(ctx).WithService("sdlc_orchestrator") log.Info("dispatching agent action", logging.FieldProjectID, projectID, "feature", cl.Feature, "action", string(cl.Action), "agent", agent.Name(), ) var output string _, err = agent.Execute(ctx, agentReq, func(event domain.AgentEvent) { if event.Type == domain.AgentEventOutput { output += event.Content } }) if err != nil { return fmt.Errorf("agent execution failed: %w", err) } result.Output = output return nil } // executeAgentActionAsync dispatches a heavy SDLC action through the work queue. // Returns immediately with task_id and async=true. func (s *SDLCOrchestratorService) executeAgentActionAsync(ctx context.Context, projectID string, cl *sdlc.Classification, req *ExecuteRequest, result *ExecutionResult) error { log := logging.FromContext(ctx).WithService("sdlc_orchestrator") gitCloneURL, err := s.getProjectGitURL(ctx, projectID) if err != nil { return fmt.Errorf("resolve git URL for async dispatch: %w", err) } timeout := sdlc.ActionTimeout(cl.Action) buildSpec := domain.BuildSpec{ Prompt: cl.NextCommand, AutoCommit: true, AutoPush: true, GitCloneURL: gitCloneURL, TimeoutSeconds: int(timeout.Seconds()), } sdlcCtx := map[string]any{ "feature": cl.Feature, "action": string(cl.Action), } taskID, err := s.buildService.StartBuildWithSDLCContext(ctx, projectID, buildSpec, sdlcCtx) if err != nil { return fmt.Errorf("enqueue async action: %w", err) } log.Info("dispatched heavy action async", logging.FieldProjectID, projectID, "feature", cl.Feature, "action", string(cl.Action), "task_id", taskID, "timeout_seconds", int(timeout.Seconds()), ) result.TaskID = taskID result.Async = true result.Success = true result.Output = fmt.Sprintf("Action %s dispatched asynchronously (task_id: %s)", cl.Action, taskID) return nil } // getProjectGitURL retrieves the git clone URL for a project from the database. func (s *SDLCOrchestratorService) getProjectGitURL(ctx context.Context, projectID string) (string, error) { if s.db == nil { return "", fmt.Errorf("database not configured") } var gitCloneHTTP sql.NullString err := s.db.QueryRowContext(ctx, `SELECT git_clone_http FROM projects WHERE id = $1`, projectID, ).Scan(&gitCloneHTTP) if err != nil { if err == sql.ErrNoRows { return "", domain.ErrProjectNotFound } return "", fmt.Errorf("query project: %w", err) } if !gitCloneHTTP.Valid || gitCloneHTTP.String == "" { return "", fmt.Errorf("project %s has no git URL configured", projectID) } return gitCloneHTTP.String, nil } // ResolveBlocker unblocks a feature and re-classifies. func (s *SDLCOrchestratorService) ResolveBlocker(ctx context.Context, projectID string, req *ResolveRequest) (*ExecutionResult, error) { if err := s.sdlcService.UnblockFeature(ctx, projectID, req.Feature); err != nil { return nil, err } log := logging.FromContext(ctx).WithService("sdlc_orchestrator") log.Info("blocker resolved", logging.FieldProjectID, projectID, "feature", req.Feature) cl, err := s.sdlcService.GetNext(ctx, projectID, req.Feature) if err != nil { return &ExecutionResult{ Action: sdlc.ActionTransition, Success: true, Output: "Feature unblocked", }, nil } return &ExecutionResult{ Action: sdlc.ActionTransition, Success: true, Output: "Feature unblocked", Next: cl, }, nil } // CommitChanges commits and optionally pushes changes in the project pod. func (s *SDLCOrchestratorService) CommitChanges(ctx context.Context, projectID string, req *CommitRequest) (*CommitResult, error) { if s.gitCommitter == nil { return nil, fmt.Errorf("git operations not configured") } project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID)) if err != nil { return nil, domain.ErrProjectNotFound } workDir := "/workspace" if project.Workspace != "" { workDir = project.Workspace } gitResult := s.gitCommitter.CommitAndPush(ctx, project.PodName, workDir, req.Message, req.Push) if gitResult.Error != nil { return nil, gitResult.Error } log := logging.FromContext(ctx).WithService("sdlc_orchestrator") log.Info("changes committed", logging.FieldProjectID, projectID, "sha", gitResult.CommitSHA, "files", len(gitResult.FilesChanged), "pushed", gitResult.Pushed, ) return &CommitResult{ CommitSHA: gitResult.CommitSHA, FilesChanged: gitResult.FilesChanged, Pushed: gitResult.Pushed, }, nil }