rdev/internal/service/project_service_commands.go
jordan 39df51defd feat: Add multi-provider code agent interface with Claude Code and OpenCode adapters
Implements weeks 1-4 of the multi-provider architecture:

Week 1 - Foundation:
- Add domain models (AgentProvider, AgentRequest, AgentEvent, AgentResult)
- Define CodeAgent port interface with Execute, Cancel, Capabilities
- Create thread-safe provider registry with first-registered default

Week 2 - Claude Code Adapter:
- Extract kubectl exec logic into CodeAgent implementation
- Parse stream-json output format (init, message, tool_use, result)
- Support session continuation via --resume flag

Week 3 - OpenCode Adapter:
- HTTP/SSE client for opencode serve API
- Session management (create, send message, abort)
- Event streaming with documented buffer rationale

Week 4 - Quality & Polish:
- Fix race condition in OpenCode Cancel method
- Add AgentRequest.Validate() with ErrPromptRequired, ErrInvalidTimeout
- Document DefaultAvailabilityTimeout constants
- Add HTTP error context for debugging

Also includes:
- Work queue system with PostgreSQL adapter
- Credential store for infrastructure secrets
- Project templates with Woodpecker CI integration
- Comprehensive test coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 09:25:51 -07:00

175 lines
5.0 KiB
Go

// Package service provides business logic / use cases for the application.
// This file contains shell and git command execution functionality.
package service
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/sanitize"
)
// 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
}