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>
460 lines
12 KiB
Go
460 lines
12 KiB
Go
// Package handlers provides HTTP handlers for the rdev API.
|
|
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/service"
|
|
"github.com/orchard9/rdev/pkg/api"
|
|
)
|
|
|
|
// WorkHandler handles work queue endpoints.
|
|
type WorkHandler struct {
|
|
workService *service.WorkService
|
|
}
|
|
|
|
// NewWorkHandler creates a new work handler.
|
|
func NewWorkHandler(workService *service.WorkService) *WorkHandler {
|
|
return &WorkHandler{
|
|
workService: workService,
|
|
}
|
|
}
|
|
|
|
// Mount registers the work queue routes.
|
|
func (h *WorkHandler) Mount(r api.Router) {
|
|
r.Route("/work", func(r chi.Router) {
|
|
// Task submission
|
|
r.Post("/enqueue", h.Enqueue)
|
|
|
|
// Worker endpoints (for workers polling for tasks)
|
|
r.Post("/dequeue", h.Dequeue)
|
|
|
|
// Task management
|
|
r.Get("/{taskId}", h.GetTask)
|
|
r.Get("/{taskId}/status", h.GetStatus)
|
|
r.Post("/{taskId}/complete", h.Complete)
|
|
r.Post("/{taskId}/fail", h.Fail)
|
|
r.Post("/{taskId}/cancel", h.Cancel)
|
|
|
|
// Project-scoped list
|
|
r.Get("/projects/{projectId}", h.ListByProject)
|
|
|
|
// Queue stats
|
|
r.Get("/stats", h.Stats)
|
|
})
|
|
}
|
|
|
|
// EnqueueWorkRequest is the request body for POST /work/enqueue.
|
|
type EnqueueWorkRequest struct {
|
|
ProjectID string `json:"project_id"`
|
|
TaskType string `json:"task_type"`
|
|
Spec map[string]any `json:"task_spec"`
|
|
Priority int `json:"priority,omitempty"`
|
|
CallbackURL string `json:"callback_url,omitempty"`
|
|
MaxRetries int `json:"max_retries,omitempty"`
|
|
}
|
|
|
|
// EnqueueWorkResponse is the response for POST /work/enqueue.
|
|
type EnqueueWorkResponse struct {
|
|
TaskID string `json:"task_id"`
|
|
StatusURL string `json:"status_url"`
|
|
}
|
|
|
|
// Enqueue adds a task to the work queue.
|
|
// POST /work/enqueue
|
|
func (h *WorkHandler) Enqueue(w http.ResponseWriter, r *http.Request) {
|
|
var req EnqueueWorkRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
api.WriteBadRequest(w, r, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// Validate required fields
|
|
if req.ProjectID == "" {
|
|
api.WriteBadRequest(w, r, "project_id is required")
|
|
return
|
|
}
|
|
if req.TaskType == "" {
|
|
api.WriteBadRequest(w, r, "task_type is required")
|
|
return
|
|
}
|
|
|
|
// Validate task type
|
|
taskType := port.WorkTaskType(req.TaskType)
|
|
if !taskType.IsValid() {
|
|
api.WriteBadRequest(w, r, "task_type must be one of: build, test, deploy, custom")
|
|
return
|
|
}
|
|
|
|
// Validate callback URL if provided
|
|
if req.CallbackURL != "" {
|
|
parsedURL, err := url.Parse(req.CallbackURL)
|
|
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
|
|
api.WriteBadRequest(w, r, "callback_url must be a valid HTTP/HTTPS URL")
|
|
return
|
|
}
|
|
}
|
|
|
|
result, err := h.workService.EnqueueTask(r.Context(), service.EnqueueTaskRequest{
|
|
ProjectID: req.ProjectID,
|
|
Type: taskType,
|
|
Spec: req.Spec,
|
|
Priority: req.Priority,
|
|
CallbackURL: req.CallbackURL,
|
|
MaxRetries: req.MaxRetries,
|
|
})
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to enqueue task")
|
|
return
|
|
}
|
|
|
|
api.WriteCreated(w, r, EnqueueWorkResponse{
|
|
TaskID: result.TaskID,
|
|
StatusURL: result.StatusURL,
|
|
})
|
|
}
|
|
|
|
// DequeueWorkRequest is the request body for POST /work/dequeue.
|
|
type DequeueWorkRequest struct {
|
|
WorkerID string `json:"worker_id"`
|
|
}
|
|
|
|
// DequeueWorkResponse is the response for POST /work/dequeue.
|
|
type DequeueWorkResponse struct {
|
|
Task *WorkTaskDTO `json:"task,omitempty"`
|
|
}
|
|
|
|
// WorkTaskDTO is the data transfer object for work tasks.
|
|
type WorkTaskDTO struct {
|
|
ID string `json:"id"`
|
|
ProjectID string `json:"project_id"`
|
|
Type string `json:"type"`
|
|
Spec map[string]any `json:"spec"`
|
|
Status string `json:"status"`
|
|
Priority int `json:"priority"`
|
|
WorkerID string `json:"worker_id,omitempty"`
|
|
CallbackURL string `json:"callback_url,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
StartedAt string `json:"started_at,omitempty"`
|
|
CompletedAt string `json:"completed_at,omitempty"`
|
|
Result *WorkResultDTO `json:"result,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
RetryCount int `json:"retry_count"`
|
|
MaxRetries int `json:"max_retries"`
|
|
}
|
|
|
|
// WorkResultDTO is the data transfer object for work results.
|
|
type WorkResultDTO struct {
|
|
Output string `json:"output,omitempty"`
|
|
Artifacts map[string]string `json:"artifacts,omitempty"`
|
|
}
|
|
|
|
// toWorkTaskDTO converts a port.WorkTask to a WorkTaskDTO.
|
|
func toWorkTaskDTO(t *port.WorkTask) *WorkTaskDTO {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
dto := &WorkTaskDTO{
|
|
ID: t.ID,
|
|
ProjectID: t.ProjectID,
|
|
Type: string(t.Type),
|
|
Spec: t.Spec,
|
|
Status: string(t.Status),
|
|
Priority: t.Priority,
|
|
WorkerID: t.WorkerID,
|
|
CallbackURL: t.CallbackURL,
|
|
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
Error: t.Error,
|
|
RetryCount: t.RetryCount,
|
|
MaxRetries: t.MaxRetries,
|
|
}
|
|
if t.StartedAt != nil {
|
|
dto.StartedAt = t.StartedAt.Format("2006-01-02T15:04:05Z07:00")
|
|
}
|
|
if t.CompletedAt != nil {
|
|
dto.CompletedAt = t.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
|
|
}
|
|
if t.Result != nil {
|
|
dto.Result = &WorkResultDTO{
|
|
Output: t.Result.Output,
|
|
Artifacts: t.Result.Artifacts,
|
|
}
|
|
}
|
|
return dto
|
|
}
|
|
|
|
// Dequeue claims the next available task for a worker.
|
|
// POST /work/dequeue
|
|
func (h *WorkHandler) Dequeue(w http.ResponseWriter, r *http.Request) {
|
|
var req DequeueWorkRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
api.WriteBadRequest(w, r, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.WorkerID == "" {
|
|
api.WriteBadRequest(w, r, "worker_id is required")
|
|
return
|
|
}
|
|
|
|
task, err := h.workService.DequeueTask(r.Context(), req.WorkerID)
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to dequeue task")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, DequeueWorkResponse{
|
|
Task: toWorkTaskDTO(task),
|
|
})
|
|
}
|
|
|
|
// GetTask retrieves a task by ID.
|
|
// GET /work/{taskId}
|
|
func (h *WorkHandler) GetTask(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskId")
|
|
|
|
task, err := h.workService.GetTask(r.Context(), taskID)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrWorkTaskNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID))
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to get task")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, toWorkTaskDTO(task))
|
|
}
|
|
|
|
// GetStatus returns the status of a task.
|
|
// GET /work/{taskId}/status
|
|
func (h *WorkHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskId")
|
|
|
|
task, err := h.workService.GetTask(r.Context(), taskID)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrWorkTaskNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID))
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to get task")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"task_id": task.ID,
|
|
"status": string(task.Status),
|
|
"error": task.Error,
|
|
})
|
|
}
|
|
|
|
// CompleteWorkRequest is the request body for POST /work/{taskId}/complete.
|
|
type CompleteWorkRequest struct {
|
|
Output string `json:"output,omitempty"`
|
|
Artifacts map[string]string `json:"artifacts,omitempty"`
|
|
}
|
|
|
|
// Complete marks a task as successfully completed.
|
|
// POST /work/{taskId}/complete
|
|
func (h *WorkHandler) Complete(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskId")
|
|
|
|
var req CompleteWorkRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
api.WriteBadRequest(w, r, "invalid request body")
|
|
return
|
|
}
|
|
|
|
result := &port.WorkResult{
|
|
Output: req.Output,
|
|
Artifacts: req.Artifacts,
|
|
}
|
|
|
|
if err := h.workService.CompleteTask(r.Context(), taskID, result); err != nil {
|
|
if errors.Is(err, domain.ErrWorkTaskNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID))
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to complete task")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"task_id": taskID,
|
|
"status": "completed",
|
|
"message": "task completed successfully",
|
|
})
|
|
}
|
|
|
|
// FailWorkRequest is the request body for POST /work/{taskId}/fail.
|
|
type FailWorkRequest struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// Fail marks a task as failed.
|
|
// POST /work/{taskId}/fail
|
|
func (h *WorkHandler) Fail(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskId")
|
|
|
|
var req FailWorkRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
api.WriteBadRequest(w, r, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Error == "" {
|
|
api.WriteBadRequest(w, r, "error message is required")
|
|
return
|
|
}
|
|
|
|
if err := h.workService.FailTask(r.Context(), taskID, req.Error); err != nil {
|
|
if errors.Is(err, domain.ErrWorkTaskNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID))
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to fail task")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"task_id": taskID,
|
|
"status": "failed",
|
|
"message": "task marked as failed",
|
|
})
|
|
}
|
|
|
|
// Cancel cancels a pending task.
|
|
// POST /work/{taskId}/cancel
|
|
func (h *WorkHandler) Cancel(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "taskId")
|
|
|
|
if err := h.workService.CancelTask(r.Context(), taskID); err != nil {
|
|
if errors.Is(err, domain.ErrWorkTaskNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID))
|
|
return
|
|
}
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"task_id": taskID,
|
|
"status": "cancelled",
|
|
"message": "task cancelled successfully",
|
|
})
|
|
}
|
|
|
|
// ListByProject returns tasks for a project with pagination.
|
|
// GET /work/projects/{projectId}?status=pending&limit=50&offset=0
|
|
func (h *WorkHandler) ListByProject(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "projectId")
|
|
|
|
// Parse and validate optional status filter
|
|
var status *port.WorkTaskStatus
|
|
if s := r.URL.Query().Get("status"); s != "" {
|
|
st := port.WorkTaskStatus(s)
|
|
if !st.IsValid() {
|
|
api.WriteBadRequest(w, r, "invalid status filter: must be pending, running, completed, failed, or cancelled")
|
|
return
|
|
}
|
|
status = &st
|
|
}
|
|
|
|
// Parse pagination options
|
|
opts := port.DefaultWorkListOptions()
|
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
limit, err := strconv.Atoi(limitStr)
|
|
if err != nil {
|
|
api.WriteBadRequest(w, r, "limit must be a valid integer")
|
|
return
|
|
}
|
|
opts.Limit = limit
|
|
}
|
|
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
|
offset, err := strconv.Atoi(offsetStr)
|
|
if err != nil {
|
|
api.WriteBadRequest(w, r, "offset must be a valid integer")
|
|
return
|
|
}
|
|
opts.Offset = offset
|
|
}
|
|
|
|
result, err := h.workService.ListByProject(r.Context(), projectID, status, opts)
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to list tasks")
|
|
return
|
|
}
|
|
|
|
dtos := make([]*WorkTaskDTO, len(result.Tasks))
|
|
for i, t := range result.Tasks {
|
|
dtos[i] = toWorkTaskDTO(t)
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"tasks": dtos,
|
|
"total": result.Total,
|
|
"limit": result.Limit,
|
|
"offset": result.Offset,
|
|
})
|
|
}
|
|
|
|
// WorkStatsResponse is the response for GET /work/stats.
|
|
type WorkStatsResponse struct {
|
|
Pending int64 `json:"pending"`
|
|
Running int64 `json:"running"`
|
|
Completed int64 `json:"completed"`
|
|
Failed int64 `json:"failed"`
|
|
Cancelled int64 `json:"cancelled"`
|
|
OldestPending string `json:"oldest_pending,omitempty"`
|
|
}
|
|
|
|
// Stats returns queue statistics.
|
|
// GET /work/stats
|
|
func (h *WorkHandler) Stats(w http.ResponseWriter, r *http.Request) {
|
|
stats, err := h.workService.GetStats(r.Context())
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to get queue stats")
|
|
return
|
|
}
|
|
|
|
resp := WorkStatsResponse{
|
|
Pending: stats.Pending,
|
|
Running: stats.Running,
|
|
Completed: stats.Completed,
|
|
Failed: stats.Failed,
|
|
Cancelled: stats.Cancelled,
|
|
}
|
|
if stats.OldestPending != nil {
|
|
// Convert to human-readable duration
|
|
resp.OldestPending = formatDuration(*stats.OldestPending)
|
|
}
|
|
|
|
api.WriteSuccess(w, r, resp)
|
|
}
|
|
|
|
// formatDuration formats a duration in a human-readable way.
|
|
func formatDuration(d interface{ Seconds() float64 }) string {
|
|
secs := d.Seconds()
|
|
if secs < 60 {
|
|
return fmt.Sprintf("%.0fs", secs)
|
|
}
|
|
mins := secs / 60
|
|
if mins < 60 {
|
|
return fmt.Sprintf("%.1fm", mins)
|
|
}
|
|
hours := mins / 60
|
|
if hours < 24 {
|
|
return fmt.Sprintf("%.1fh", hours)
|
|
}
|
|
days := hours / 24
|
|
return fmt.Sprintf("%.1fd", days)
|
|
}
|