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>
224 lines
6.0 KiB
Go
224 lines
6.0 KiB
Go
// Package service provides business logic / use cases for the application.
|
|
// This file contains CodeAgent integration for multi-provider support.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/metrics"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
)
|
|
|
|
// resolveAgent returns the appropriate CodeAgent for a project.
|
|
// Returns nil if no agent registry is configured or no agent is available.
|
|
func (s *ProjectService) resolveAgent(project *domain.Project) port.CodeAgent {
|
|
if s.agentRegistry == nil {
|
|
return nil
|
|
}
|
|
|
|
// Try project-specific agent first
|
|
if project.AgentProvider != "" {
|
|
if agent := s.agentRegistry.Get(project.AgentProvider); agent != nil {
|
|
return agent
|
|
}
|
|
}
|
|
|
|
// Fall back to default
|
|
return s.agentRegistry.Default()
|
|
}
|
|
|
|
// executeAgentCommand runs a command via CodeAgent and streams output.
|
|
func (s *ProjectService) executeAgentCommand(agent port.CodeAgent, req *domain.AgentRequest, 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,
|
|
})
|
|
|
|
// Execute via agent with event handler
|
|
result, _ := agent.Execute(ctx, req, func(event domain.AgentEvent) {
|
|
// Convert agent event to stream event
|
|
var eventType string
|
|
var data map[string]any
|
|
|
|
switch event.Type {
|
|
case domain.AgentEventOutput:
|
|
eventType = "output"
|
|
data = map[string]any{
|
|
"line": event.Content,
|
|
"stream": event.Stream,
|
|
}
|
|
case domain.AgentEventToolUse:
|
|
eventType = "tool_use"
|
|
data = map[string]any{
|
|
"tool": event.ToolName,
|
|
"input": event.ToolInput,
|
|
}
|
|
case domain.AgentEventToolResult:
|
|
eventType = "tool_result"
|
|
data = map[string]any{
|
|
"output": event.Content,
|
|
}
|
|
case domain.AgentEventError:
|
|
eventType = "error"
|
|
data = map[string]any{
|
|
"error": event.Content,
|
|
}
|
|
case domain.AgentEventComplete:
|
|
eventType = "agent_complete"
|
|
data = event.Metadata
|
|
default:
|
|
eventType = "output"
|
|
data = map[string]any{
|
|
"line": event.Content,
|
|
"stream": "stdout",
|
|
}
|
|
}
|
|
|
|
eventID := s.streams.Publish(streamID, port.StreamEvent{
|
|
Type: eventType,
|
|
Data: data,
|
|
})
|
|
lastEventID = eventID
|
|
outputSizeBytes += int64(len(event.Content))
|
|
})
|
|
|
|
// Send completion event
|
|
eventID := s.streams.Publish(streamID, port.StreamEvent{
|
|
Type: "complete",
|
|
Data: map[string]any{
|
|
"exit_code": result.ExitCode,
|
|
"duration_ms": result.DurationMs,
|
|
"session_id": result.SessionID,
|
|
"provider": agent.Provider(),
|
|
},
|
|
})
|
|
|
|
// 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("agent command completed",
|
|
"command_id", cmd.ID,
|
|
"provider", agent.Provider(),
|
|
"session_id", result.SessionID,
|
|
"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)
|
|
}()
|
|
}
|
|
|
|
// GetAgentCapabilities returns the capabilities for a specific agent provider.
|
|
// Returns nil if no agent registry is configured or the provider is not found.
|
|
func (s *ProjectService) GetAgentCapabilities(provider domain.AgentProvider) *domain.AgentCapabilities {
|
|
if s.agentRegistry == nil {
|
|
return nil
|
|
}
|
|
|
|
agent := s.agentRegistry.Get(provider)
|
|
if agent == nil {
|
|
return nil
|
|
}
|
|
|
|
caps := agent.Capabilities()
|
|
return &caps
|
|
}
|
|
|
|
// ListAvailableAgents returns all available agent providers.
|
|
func (s *ProjectService) ListAvailableAgents() []domain.AgentProvider {
|
|
if s.agentRegistry == nil {
|
|
return nil
|
|
}
|
|
return s.agentRegistry.Available()
|
|
}
|
|
|
|
// GetDefaultAgent returns the default agent provider.
|
|
func (s *ProjectService) GetDefaultAgent() domain.AgentProvider {
|
|
if s.agentRegistry == nil {
|
|
return ""
|
|
}
|
|
agent := s.agentRegistry.Default()
|
|
if agent == nil {
|
|
return ""
|
|
}
|
|
return agent.Provider()
|
|
}
|
|
|
|
// SetDefaultAgent sets the default agent provider.
|
|
func (s *ProjectService) SetDefaultAgent(provider domain.AgentProvider) error {
|
|
if s.agentRegistry == nil {
|
|
return domain.ErrInvalidAgentProvider
|
|
}
|
|
return s.agentRegistry.SetDefault(provider)
|
|
}
|