feat: implement Visual Verification API layer (Week 2)

Add REST API endpoints for submitting visual verification tasks,
tracking progress via SSE, and retrieving screenshot/video artifacts.

Changes:
- Add ScopeVerifyRead/ScopeVerifyWrite auth scopes
- Create VerifyService for task submission and lifecycle management
- Create VerifyHandler with POST/GET/DELETE/SSE endpoints:
  - POST /verify - Submit capture task
  - GET /verify/{taskId} - Get task status and artifacts
  - GET /verify/{taskId}/stream - SSE progress stream
  - DELETE /verify/{taskId} - Cancel pending task
  - GET /projects/{id}/verify - List verify tasks
- Wire VerifyExecutor in main.go for Playwright pod execution
- Fix work.go validation to include "verify" task type
- Add comprehensive handler tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-03 19:29:40 -07:00
parent 86b372fa08
commit b093a4b26d
9 changed files with 875 additions and 3 deletions

View File

@ -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) |

View File

@ -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 |

View File

@ -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(

View File

@ -22,6 +22,8 @@ const (
ScopeWorkersWrite = domain.ScopeWorkersWrite
ScopeBuildRead = domain.ScopeBuildRead
ScopeBuildWrite = domain.ScopeBuildWrite
ScopeVerifyRead = domain.ScopeVerifyRead
ScopeVerifyWrite = domain.ScopeVerifyWrite
ScopeAdmin = domain.ScopeAdmin
)

View File

@ -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)",
}

364
internal/handlers/verify.go Normal file
View File

@ -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
}

View File

@ -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"])
}
}

View File

@ -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
}

View File

@ -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)
}