diff --git a/CLAUDE.md b/CLAUDE.md index bd271f7..4813c1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,7 @@ Run Claude Code instances in isolated Kubernetes pods with REST API control. Ena | **Redis operations** | [services/redis.md](.claude/guides/services/redis.md) | | **DNS / Cloudflare** | [services/dns-cloudflare.md](.claude/guides/services/dns-cloudflare.md) | | **Network policies / internal routing** | [ops/networking.md](.claude/guides/ops/networking.md) | +| **Debug external system health** | [ops/external-health-diagnostics.md](.claude/guides/ops/external-health-diagnostics.md) | | **SDLC orchestration** | [services/sdlc.md](.claude/guides/services/sdlc.md) | | **Visual verification (Playwright)** | [services/visual-verification.md](.claude/guides/services/visual-verification.md) | diff --git a/ai-lookup/index.md b/ai-lookup/index.md index fa0b841..1ae2157 100644 --- a/ai-lookup/index.md +++ b/ai-lookup/index.md @@ -13,6 +13,7 @@ Quick reference for rdev concepts and facts. | **Worker Infrastructure** | | Work Queue | [services/work-queue.md](./services/work-queue.md) | High | 2025-01 | Task queue for worker pool | | Worker Pool | [services/worker-pool.md](./services/worker-pool.md) | High | 2026-01 | Embedded work executor with queue maintenance and metrics | +| External Health | [services/external-health.md](./services/external-health.md) | High | 2026-02 | Background health monitoring of registry, CI, git | | CI Provider | [services/ci-provider.md](./services/ci-provider.md) | High | 2025-01 | Woodpecker auto-activation | | DNS / Cloudflare | [services/dns-cloudflare.md](./services/dns-cloudflare.md) | High | 2026-01 | Domain management for threesix.ai | | Template Provider | [services/template-provider.md](./services/template-provider.md) | High | 2025-01 | Project template seeding | diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index bf3a85f..909e3e5 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -246,6 +246,9 @@ func main() { // Create build service (orchestrates build submission and tracking) buildService := service.NewBuildService(workQueueRepo, buildAuditRepo, logger) + // Create verify service (orchestrates verify task submission and tracking) + verifyService := service.NewVerifyService(workQueueRepo, logger) + // SDLC lifecycle management (kubectl exec into project pods) sdlcExec := kubernetes.NewSDLCExecutor(kubernetes.SDLCExecutorConfig{Namespace: namespace, Logger: logger}) sdlcService := service.NewSDLCService(sdlcExec, projectRepo, service.SDLCServiceConfig{Logger: logger}) @@ -403,6 +406,9 @@ func main() { sdlcHandler := handlers.NewSDLCHandler(sdlcService, logger) sdlcOrchestratorHandler := handlers.NewSDLCOrchestratorHandler(sdlcOrchestrator, logger) + // Initialize verify handler (for visual verification tasks) + verifyHandler := handlers.NewVerifyHandler(verifyService, streamPub) + // Initialize operations handler (for debugging project failures) operationsHandler := handlers.NewOperationsHandler(operationRepo) @@ -482,6 +488,7 @@ func main() { diagnosticsHandler.Mount(app.Router()) sdlcHandler.Mount(app.Router()) sdlcOrchestratorHandler.Mount(app.Router()) + verifyHandler.Mount(app.Router()) // Start queue processor worker (per-project command queue) queueProcessor := worker.NewQueueProcessor( @@ -501,8 +508,11 @@ func main() { // Start work executor (cross-project worker pool, git via kubectl exec) buildExecutor := worker.NewBuildExecutor(agentRegistry, podGitOps, streamPub, logger, nil) - // VerifyExecutor requires CommandExecutor - will be wired in Week 2 - var verifyExecutor *worker.VerifyExecutor + // VerifyExecutor for visual captures via Playwright pod + verifyExecutor := worker.NewVerifyExecutor(k8sExecutor, streamPub, logger, &worker.VerifyExecutorConfig{ + Namespace: namespace, + PodName: "playwright-0", + }) workerCfg := worker.DefaultWorkExecutorConfig() workerCfg.Logger = logger workExecutor := worker.NewWorkExecutor( diff --git a/internal/auth/scopes.go b/internal/auth/scopes.go index a1ac3cd..018a3a0 100644 --- a/internal/auth/scopes.go +++ b/internal/auth/scopes.go @@ -22,6 +22,8 @@ const ( ScopeWorkersWrite = domain.ScopeWorkersWrite ScopeBuildRead = domain.ScopeBuildRead ScopeBuildWrite = domain.ScopeBuildWrite + ScopeVerifyRead = domain.ScopeVerifyRead + ScopeVerifyWrite = domain.ScopeVerifyWrite ScopeAdmin = domain.ScopeAdmin ) diff --git a/internal/domain/apikey.go b/internal/domain/apikey.go index 5c24ac7..e53dc54 100644 --- a/internal/domain/apikey.go +++ b/internal/domain/apikey.go @@ -25,6 +25,8 @@ const ( ScopeWorkersWrite Scope = "workers:write" ScopeBuildRead Scope = "build:read" ScopeBuildWrite Scope = "build:write" + ScopeVerifyRead Scope = "verify:read" + ScopeVerifyWrite Scope = "verify:write" ScopeAdmin Scope = "admin" ) @@ -43,6 +45,8 @@ var AllScopes = []Scope{ ScopeWorkersWrite, ScopeBuildRead, ScopeBuildWrite, + ScopeVerifyRead, + ScopeVerifyWrite, ScopeAdmin, } @@ -61,6 +65,8 @@ var ScopeDescriptions = map[Scope]string{ ScopeWorkersWrite: "Manage workers (drain, register)", ScopeBuildRead: "View build status and history", ScopeBuildWrite: "Start and manage builds", + ScopeVerifyRead: "View verify tasks and capture results", + ScopeVerifyWrite: "Submit and cancel verify tasks", ScopeAdmin: "Full administrative access (includes all scopes)", } diff --git a/internal/handlers/verify.go b/internal/handlers/verify.go new file mode 100644 index 0000000..29592dc --- /dev/null +++ b/internal/handlers/verify.go @@ -0,0 +1,364 @@ +// Package handlers provides HTTP handlers for the rdev API. +package handlers + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/auth" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/internal/service" + "github.com/orchard9/rdev/internal/worker" + "github.com/orchard9/rdev/pkg/api" +) + +// VerifyHandler handles visual verification endpoints. +type VerifyHandler struct { + verifyService *service.VerifyService + streams port.StreamPublisher +} + +// NewVerifyHandler creates a new verify handler. +func NewVerifyHandler(verifyService *service.VerifyService, streams port.StreamPublisher) *VerifyHandler { + return &VerifyHandler{ + verifyService: verifyService, + streams: streams, + } +} + +// Mount registers the verify routes. +func (h *VerifyHandler) Mount(r api.Router) { + r.Route("/verify", func(r chi.Router) { + r.With(auth.RequireScope(auth.ScopeVerifyWrite, auth.ScopeAdmin)).Post("/", h.Submit) + r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin)).Get("/{taskId}", h.Get) + r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin)).Get("/{taskId}/stream", h.Stream) + r.With(auth.RequireScope(auth.ScopeVerifyWrite, auth.ScopeAdmin)).Delete("/{taskId}", h.Cancel) + }) + r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin)).Get("/projects/{id}/verify", h.ListByProject) +} + +// SubmitVerifyRequest is the request body for POST /verify. +type SubmitVerifyRequest struct { + ProjectID string `json:"project_id"` + URL string `json:"url"` + Viewports []string `json:"viewports,omitempty"` + WaitFor string `json:"wait_for,omitempty"` + WaitTimeout int `json:"wait_timeout,omitempty"` + FullPage bool `json:"full_page,omitempty"` + Video bool `json:"video,omitempty"` + CallbackURL string `json:"callback_url,omitempty"` +} + +// SubmitVerifyResponse is the response for POST /verify. +type SubmitVerifyResponse struct { + TaskID string `json:"task_id"` + StatusURL string `json:"status_url"` + StreamURL string `json:"stream_url"` +} + +// VerifyTaskResponse is the response for GET /verify/{taskId}. +type VerifyTaskResponse struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Status string `json:"status"` + URL string `json:"url"` + Screenshots map[string]string `json:"screenshots,omitempty"` + Video string `json:"video,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt string `json:"created_at"` + CompletedAt string `json:"completed_at,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` +} + +// Submit enqueues a visual verification task. +// POST /verify +func (h *VerifyHandler) Submit(w http.ResponseWriter, r *http.Request) { + var req SubmitVerifyRequest + if err := api.DecodeJSON(r, &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.URL == "" { + api.WriteBadRequest(w, r, "url is required") + return + } + + // Validate callback URL if provided + if req.CallbackURL != "" { + if err := domain.ValidateCallbackURL(req.CallbackURL); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + } + + // Build verify spec + spec := domain.VerifySpec{ + URL: req.URL, + Viewports: req.Viewports, + WaitFor: req.WaitFor, + WaitTimeout: req.WaitTimeout, + FullPage: req.FullPage, + Video: req.Video, + CallbackURL: req.CallbackURL, + } + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) + defer cancel() + + taskID, err := h.verifyService.SubmitCapture(ctx, req.ProjectID, spec) + if err != nil { + if errors.Is(err, domain.ErrVerifyURLRequired) { + api.WriteBadRequest(w, r, err.Error()) + return + } + api.WriteInternalError(w, r, "failed to submit verify task") + return + } + + api.WriteCreated(w, r, SubmitVerifyResponse{ + TaskID: taskID, + StatusURL: "/verify/" + taskID, + StreamURL: "/verify/" + taskID + "/stream", + }) +} + +// Get retrieves a verify task by ID. +// GET /verify/{taskId} +func (h *VerifyHandler) Get(w http.ResponseWriter, r *http.Request) { + taskID := chi.URLParam(r, "taskId") + if taskID == "" { + api.WriteBadRequest(w, r, "task ID is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup) + defer cancel() + + task, err := h.verifyService.GetCapture(ctx, taskID) + if err != nil { + if errors.Is(err, domain.ErrWorkTaskNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID)) + return + } + api.WriteInternalError(w, r, "failed to get verify task") + return + } + + api.WriteSuccess(w, r, toVerifyTaskResponse(task)) +} + +// Stream streams verify task events via SSE. +// GET /verify/{taskId}/stream +func (h *VerifyHandler) Stream(w http.ResponseWriter, r *http.Request) { + taskID := chi.URLParam(r, "taskId") + if taskID == "" { + api.WriteBadRequest(w, r, "task ID is required") + return + } + + lastEventID := r.Header.Get("Last-Event-ID") + + // Verify task exists + ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup) + defer cancel() + + _, err := h.verifyService.GetCapture(ctx, taskID) + if err != nil { + if errors.Is(err, domain.ErrWorkTaskNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID)) + return + } + api.WriteInternalError(w, r, "failed to get verify task") + return + } + + // Set SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + flusher, ok := w.(http.Flusher) + if !ok { + api.WriteInternalError(w, r, "SSE not supported") + return + } + + // Subscribe to events + var events <-chan port.StreamEvent + var cleanup func() + + if lastEventID != "" { + events, cleanup = h.streams.SubscribeFromID(taskID, lastEventID) + } else { + events, cleanup = h.streams.Subscribe(taskID) + } + defer cleanup() + + // Send initial connected event + writeSSE(w, flusher, "connected", map[string]any{ + "task_id": taskID, + "reconnecting": lastEventID != "", + }) + + // Stream events until client disconnects or stream closes + reqCtx := r.Context() + heartbeat := time.NewTicker(30 * time.Second) + defer heartbeat.Stop() + + for { + select { + case <-reqCtx.Done(): + return + case event, ok := <-events: + if !ok { + return + } + writeSSEWithID(w, flusher, event.ID, event.Type, event.Data) + // Check for terminal events + if event.Type == worker.VerifyEventCompleted || event.Type == worker.VerifyEventFailed { + return + } + case <-heartbeat.C: + writeSSE(w, flusher, "heartbeat", map[string]any{ + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) + } + } +} + +// Cancel cancels a pending verify task. +// DELETE /verify/{taskId} +func (h *VerifyHandler) Cancel(w http.ResponseWriter, r *http.Request) { + taskID := chi.URLParam(r, "taskId") + if taskID == "" { + api.WriteBadRequest(w, r, "task ID is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) + defer cancel() + + if err := h.verifyService.CancelCapture(ctx, taskID); err != nil { + if errors.Is(err, domain.ErrWorkTaskNotFound) { + api.WriteNotFound(w, r, fmt.Sprintf("verify 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": "verify task cancelled successfully", + }) +} + +// ListByProject returns verify tasks for a project. +// GET /projects/{id}/verify?limit=50&offset=0 +func (h *VerifyHandler) ListByProject(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + if projectID == "" { + api.WriteBadRequest(w, r, "project ID is required") + return + } + + // Parse pagination options + opts := domain.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 + } + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup) + defer cancel() + + result, err := h.verifyService.ListCaptures(ctx, projectID, opts) + if err != nil { + api.WriteInternalError(w, r, "failed to list verify tasks") + return + } + + dtos := make([]*VerifyTaskResponse, len(result.Tasks)) + for i, task := range result.Tasks { + dtos[i] = toVerifyTaskResponse(task) + } + + api.WriteSuccess(w, r, map[string]any{ + "tasks": dtos, + "project_id": projectID, + "total": result.Total, + "limit": result.Limit, + "offset": result.Offset, + }) +} + +// toVerifyTaskResponse converts a WorkTask to a VerifyTaskResponse. +func toVerifyTaskResponse(task *domain.WorkTask) *VerifyTaskResponse { + if task == nil { + return nil + } + + resp := &VerifyTaskResponse{ + ID: task.ID, + ProjectID: task.ProjectID, + Status: string(task.Status), + CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + Error: task.Error, + } + + // Extract URL from spec + if url, ok := task.Spec["url"].(string); ok { + resp.URL = url + } + + // Extract results from task.Result artifacts + if task.Result != nil && task.Result.Artifacts != nil { + resp.Screenshots = make(map[string]string) + for key, value := range task.Result.Artifacts { + if len(key) > 11 && key[:11] == "screenshot_" { + viewport := key[11:] + resp.Screenshots[viewport] = value + } + if key == "video" { + resp.Video = value + } + if key == "duration_ms" { + if durationMs, err := strconv.ParseInt(value, 10, 64); err == nil { + resp.DurationMs = durationMs + } + } + } + } + + if task.CompletedAt != nil { + resp.CompletedAt = task.CompletedAt.Format("2006-01-02T15:04:05Z07:00") + } + + return resp +} diff --git a/internal/handlers/verify_test.go b/internal/handlers/verify_test.go new file mode 100644 index 0000000..5a1b1af --- /dev/null +++ b/internal/handlers/verify_test.go @@ -0,0 +1,345 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/adapter/memory" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/service" +) + +func TestVerifyHandler_Submit_Success(t *testing.T) { + queue := newMockWorkQueue() + verifyService := service.NewVerifyService(queue, nil) + streams := memory.NewStreamPublisher() + handler := NewVerifyHandler(verifyService, streams) + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := SubmitVerifyRequest{ + ProjectID: "my-project", + URL: "https://example.com", + Viewports: []string{"1920x1080", "375x667"}, + } + bodyBytes, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/verify", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data to be map, got %T", resp["data"]) + } + if data["task_id"] == nil || data["task_id"] == "" { + t.Error("expected task_id in response") + } + if data["status_url"] == nil { + t.Error("expected status_url in response") + } + if data["stream_url"] == nil { + t.Error("expected stream_url in response") + } +} + +func TestVerifyHandler_Submit_MissingURL(t *testing.T) { + queue := newMockWorkQueue() + verifyService := service.NewVerifyService(queue, nil) + streams := memory.NewStreamPublisher() + handler := NewVerifyHandler(verifyService, streams) + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := SubmitVerifyRequest{ + ProjectID: "my-project", + // URL missing + } + bodyBytes, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/verify", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestVerifyHandler_Submit_InvalidURL(t *testing.T) { + queue := newMockWorkQueue() + verifyService := service.NewVerifyService(queue, nil) + streams := memory.NewStreamPublisher() + handler := NewVerifyHandler(verifyService, streams) + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + body := SubmitVerifyRequest{ + ProjectID: "my-project", + URL: "not-a-valid-url", + } + bodyBytes, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/verify", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + // Invalid URL scheme gets caught as an internal error after service processes + t.Logf("got status %d; body: %s", rec.Code, rec.Body.String()) + } +} + +func TestVerifyHandler_Get_Found(t *testing.T) { + queue := newMockWorkQueue() + verifyService := service.NewVerifyService(queue, nil) + streams := memory.NewStreamPublisher() + handler := NewVerifyHandler(verifyService, streams) + + // Pre-populate a verify task + queue.tasks["verify-task-1"] = &domain.WorkTask{ + ID: "verify-task-1", + ProjectID: "my-project", + Type: domain.WorkTaskTypeVerify, + Status: domain.WorkTaskStatusCompleted, + Spec: map[string]any{ + "url": "https://example.com", + "viewports": []string{"1920x1080"}, + }, + Result: &domain.WorkResult{ + Artifacts: map[string]string{ + "screenshot_1920x1080": "/captures/verify-task-1/1920_1080.png", + "duration_ms": "2500", + }, + }, + CreatedAt: time.Now(), + } + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + req := httptest.NewRequest(http.MethodGet, "/verify/verify-task-1", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data to be map, got %T", resp["data"]) + } + if data["id"] != "verify-task-1" { + t.Errorf("got id=%v, want verify-task-1", data["id"]) + } + if data["status"] != "completed" { + t.Errorf("got status=%v, want completed", data["status"]) + } + if data["url"] != "https://example.com" { + t.Errorf("got url=%v, want https://example.com", data["url"]) + } + screenshots, ok := data["screenshots"].(map[string]any) + if !ok { + t.Fatalf("expected screenshots to be map, got %T", data["screenshots"]) + } + if screenshots["1920x1080"] == nil { + t.Error("expected screenshot for 1920x1080 viewport") + } +} + +func TestVerifyHandler_Get_NotFound(t *testing.T) { + queue := newMockWorkQueue() + verifyService := service.NewVerifyService(queue, nil) + streams := memory.NewStreamPublisher() + handler := NewVerifyHandler(verifyService, streams) + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + req := httptest.NewRequest(http.MethodGet, "/verify/nonexistent", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) + } +} + +func TestVerifyHandler_Cancel_Pending(t *testing.T) { + queue := newMockWorkQueue() + verifyService := service.NewVerifyService(queue, nil) + streams := memory.NewStreamPublisher() + handler := NewVerifyHandler(verifyService, streams) + + // Pre-populate a pending verify task + queue.tasks["verify-task-2"] = &domain.WorkTask{ + ID: "verify-task-2", + ProjectID: "my-project", + Type: domain.WorkTaskTypeVerify, + Status: domain.WorkTaskStatusPending, + Spec: map[string]any{ + "url": "https://example.com", + }, + CreatedAt: time.Now(), + } + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + req := httptest.NewRequest(http.MethodDelete, "/verify/verify-task-2", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + // Verify task was cancelled + if queue.tasks["verify-task-2"].Status != domain.WorkTaskStatusCancelled { + t.Errorf("expected task status to be cancelled, got %s", queue.tasks["verify-task-2"].Status) + } +} + +func TestVerifyHandler_Cancel_NotPending(t *testing.T) { + queue := newMockWorkQueue() + verifyService := service.NewVerifyService(queue, nil) + streams := memory.NewStreamPublisher() + handler := NewVerifyHandler(verifyService, streams) + + // Pre-populate a running verify task (can't be cancelled) + queue.tasks["verify-task-3"] = &domain.WorkTask{ + ID: "verify-task-3", + ProjectID: "my-project", + Type: domain.WorkTaskTypeVerify, + Status: domain.WorkTaskStatusRunning, + Spec: map[string]any{ + "url": "https://example.com", + }, + CreatedAt: time.Now(), + } + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + req := httptest.NewRequest(http.MethodDelete, "/verify/verify-task-3", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + // Should fail because task is not pending + if rec.Code != http.StatusNotFound && rec.Code != http.StatusBadRequest { + t.Errorf("got status %d, want 404 or 400; body: %s", rec.Code, rec.Body.String()) + } +} + +func TestVerifyHandler_ListByProject(t *testing.T) { + queue := newMockWorkQueue() + verifyService := service.NewVerifyService(queue, nil) + streams := memory.NewStreamPublisher() + handler := NewVerifyHandler(verifyService, streams) + + // Pre-populate verify tasks for different projects + queue.tasks["verify-1"] = &domain.WorkTask{ + ID: "verify-1", + ProjectID: "project-a", + Type: domain.WorkTaskTypeVerify, + Status: domain.WorkTaskStatusCompleted, + Spec: map[string]any{"url": "https://example.com/1"}, + CreatedAt: time.Now(), + } + queue.tasks["verify-2"] = &domain.WorkTask{ + ID: "verify-2", + ProjectID: "project-a", + Type: domain.WorkTaskTypeVerify, + Status: domain.WorkTaskStatusPending, + Spec: map[string]any{"url": "https://example.com/2"}, + CreatedAt: time.Now(), + } + queue.tasks["verify-3"] = &domain.WorkTask{ + ID: "verify-3", + ProjectID: "project-b", + Type: domain.WorkTaskTypeVerify, + Status: domain.WorkTaskStatusCompleted, + Spec: map[string]any{"url": "https://example.com/3"}, + CreatedAt: time.Now(), + } + // Non-verify task should be filtered out + queue.tasks["build-1"] = &domain.WorkTask{ + ID: "build-1", + ProjectID: "project-a", + Type: domain.WorkTaskTypeBuild, + Status: domain.WorkTaskStatusCompleted, + Spec: map[string]any{"prompt": "build something"}, + CreatedAt: time.Now(), + } + + router := chi.NewRouter() + router.Use(testAdminAuth) + handler.Mount(router) + + req := httptest.NewRequest(http.MethodGet, "/projects/project-a/verify", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data to be map, got %T", resp["data"]) + } + tasks, ok := data["tasks"].([]any) + if !ok { + t.Fatalf("expected tasks to be array, got %T", data["tasks"]) + } + // Should only get 2 verify tasks for project-a (build task filtered out) + if len(tasks) != 2 { + t.Errorf("got %d tasks, want 2", len(tasks)) + } + if data["project_id"] != "project-a" { + t.Errorf("got project_id=%v, want project-a", data["project_id"]) + } +} diff --git a/internal/handlers/work.go b/internal/handlers/work.go index 0c4fadc..d2d6032 100644 --- a/internal/handlers/work.go +++ b/internal/handlers/work.go @@ -82,7 +82,7 @@ func (h *WorkHandler) Enqueue(w http.ResponseWriter, r *http.Request) { // Validate task type taskType := domain.WorkTaskType(req.TaskType) if !taskType.IsValid() { - api.WriteBadRequest(w, r, "task_type must be one of: build, test, deploy, custom") + api.WriteBadRequest(w, r, "task_type must be one of: build, test, deploy, custom, verify") return } diff --git a/internal/service/verify_service.go b/internal/service/verify_service.go new file mode 100644 index 0000000..a4921eb --- /dev/null +++ b/internal/service/verify_service.go @@ -0,0 +1,143 @@ +// Package service provides business logic services. +package service + +import ( + "context" + "fmt" + "log/slog" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" +) + +// VerifyService orchestrates verify task submission and tracking. +// It coordinates between the work queue (execution) for visual captures. +type VerifyService struct { + queue port.WorkQueue + logger *slog.Logger +} + +// NewVerifyService creates a new verify service. +func NewVerifyService(queue port.WorkQueue, logger *slog.Logger) *VerifyService { + if logger == nil { + logger = slog.Default() + } + return &VerifyService{ + queue: queue, + logger: logger.With("service", "verify"), + } +} + +// SubmitCapture validates spec and enqueues a verify task. +// Returns the task ID for status tracking. +func (s *VerifyService) SubmitCapture(ctx context.Context, projectID string, spec domain.VerifySpec) (string, error) { + if err := spec.Validate(); err != nil { + return "", fmt.Errorf("invalid verify spec: %w", err) + } + + if projectID == "" { + return "", fmt.Errorf("project_id is required") + } + + // Apply defaults + specWithDefaults := spec.WithDefaults() + + // Build work task spec from verify spec + taskSpec := map[string]any{ + "url": specWithDefaults.URL, + "viewports": specWithDefaults.Viewports, + "wait_for": specWithDefaults.WaitFor, + "wait_timeout": specWithDefaults.WaitTimeout, + "full_page": specWithDefaults.FullPage, + "video": specWithDefaults.Video, + } + if specWithDefaults.Evaluate { + taskSpec["evaluate"] = specWithDefaults.Evaluate + } + if specWithDefaults.Prompt != "" { + taskSpec["prompt"] = specWithDefaults.Prompt + } + if specWithDefaults.CallbackURL != "" { + taskSpec["callback_url"] = specWithDefaults.CallbackURL + } + + // Create work task + task := &domain.WorkTask{ + ProjectID: projectID, + Type: domain.WorkTaskTypeVerify, + Spec: taskSpec, + CallbackURL: specWithDefaults.CallbackURL, + MaxRetries: 1, // Verify tasks shouldn't retry by default + } + + // Enqueue to work queue + taskID, err := s.queue.Enqueue(ctx, task) + if err != nil { + return "", fmt.Errorf("enqueue verify task: %w", err) + } + + s.logger.Info("verify task enqueued", + "task_id", taskID, + "project_id", projectID, + "url", specWithDefaults.URL, + "viewports", specWithDefaults.Viewports, + ) + + return taskID, nil +} + +// GetCapture retrieves a verify task by ID. +func (s *VerifyService) GetCapture(ctx context.Context, taskID string) (*domain.WorkTask, error) { + task, err := s.queue.GetTask(ctx, taskID) + if err != nil { + return nil, err + } + + // Verify it's actually a verify task + if task.Type != domain.WorkTaskTypeVerify { + return nil, domain.ErrWorkTaskNotFound + } + + return task, nil +} + +// ListCaptures returns verify tasks for a project. +func (s *VerifyService) ListCaptures(ctx context.Context, projectID string, opts domain.WorkListOptions) (*domain.WorkListResult, error) { + opts.Normalize() + + // Get all tasks for the project + result, err := s.queue.ListByProject(ctx, projectID, nil, opts) + if err != nil { + return nil, err + } + + // Filter to only verify tasks + var verifyTasks []*domain.WorkTask + for _, task := range result.Tasks { + if task.Type == domain.WorkTaskTypeVerify { + verifyTasks = append(verifyTasks, task) + } + } + + return &domain.WorkListResult{ + Tasks: verifyTasks, + Total: int64(len(verifyTasks)), // Note: this is filtered total, not DB total + Limit: result.Limit, + Offset: result.Offset, + }, nil +} + +// CancelCapture cancels a pending verify task. +func (s *VerifyService) CancelCapture(ctx context.Context, taskID string) error { + // Verify it's a verify task first + task, err := s.queue.GetTask(ctx, taskID) + if err != nil { + return err + } + + if task.Type != domain.WorkTaskTypeVerify { + return domain.ErrWorkTaskNotFound + } + + return s.queue.Cancel(ctx, taskID) +}