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>
175 lines
5.0 KiB
Go
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
|
|
}
|