All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Add TimeoutAgentExecution (22m) to handlers for synchronous SDLC
execution, and TimeoutAgent{Default,Medium,Heavy} (12/22/47m) to
workers for tiered agent task execution. Aligns with SDLC action
complexity tiers and prevents inline duration literals.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
334 lines
9.7 KiB
Go
334 lines
9.7 KiB
Go
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
|
|
}
|