rdev/internal/handlers/architect.go
jordan 542bc722ab
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(architect): handle missing projects in repo, add cookbook hooks/validation
The architect API returned "failed to start conversation" because
projectRepo.Get() failed — the in-memory K8s repo watches the rdev
namespace but projects deploy to the projects namespace. Made project
lookup non-fatal with fallback to default pod. Added error logging to
all architect handler methods (were silently swallowing errors).

Also adds setup-hooks, commit-after-qa, and pre-merge-validate steps
to the foundary cookbook tree for git hooks and code quality gates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 02:25:40 -07:00

156 lines
5.1 KiB
Go

package handlers
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api"
)
// ArchitectHandler handles architect orchestration endpoints.
type ArchitectHandler struct {
architectService *service.ArchitectService
}
// NewArchitectHandler creates a new architect handler.
func NewArchitectHandler(architectService *service.ArchitectService) *ArchitectHandler {
return &ArchitectHandler{
architectService: architectService,
}
}
// Mount registers architect routes.
func (h *ArchitectHandler) Mount(r api.Router) {
r.Route("/projects/{id}/architect", func(r chi.Router) {
// All architect operations require execute scope
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/start", h.StartConversation)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/continue/{conversationId}", h.ContinueConversation)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/generate-blueprint/{conversationId}", h.GenerateBlueprint)
})
}
// StartConversationRequest is the request body for POST /projects/{id}/architect/start.
type StartConversationRequest struct {
Prompt string `json:"prompt"`
}
// StartConversation begins a new architectural conversation.
// POST /projects/{id}/architect/start
func (h *ArchitectHandler) StartConversation(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
var req StartConversationRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
v := validate.New()
v.Required(req.Prompt, "prompt")
if err := v.Error(); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
conv, err := h.architectService.StartConversation(r.Context(), projectID, req.Prompt)
if err != nil {
log := logging.FromContext(r.Context()).WithHandler("StartConversation")
log.Error("failed to start conversation", logging.FieldError, err, logging.FieldProjectID, projectID)
api.WriteInternalError(w, r, "failed to start conversation")
return
}
api.WriteCreated(w, r, StartConversationResponse{
ConversationWithMessagesDTO: *toConversationWithMessagesDTO(conv),
})
}
// ContinueConversationRequest is the request body for POST /projects/{id}/architect/continue/{conversationId}.
type ContinueConversationRequest struct {
Message string `json:"message"`
}
// ContinueConversation continues an existing architectural conversation.
// POST /projects/{id}/architect/continue/{conversationId}
func (h *ArchitectHandler) ContinueConversation(w http.ResponseWriter, r *http.Request) {
conversationID := domain.ConversationID(chi.URLParam(r, "conversationId"))
var req ContinueConversationRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
v := validate.New()
v.Required(req.Message, "message")
if err := v.Error(); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
msg, err := h.architectService.ContinueConversation(r.Context(), conversationID, req.Message)
if err != nil {
if errors.Is(err, domain.ErrConversationNotFound) {
api.WriteNotFound(w, r, "conversation not found")
return
}
log := logging.FromContext(r.Context()).WithHandler("ContinueConversation")
log.Error("failed to continue conversation", logging.FieldError, err, "conversation_id", conversationID)
api.WriteInternalError(w, r, "failed to continue conversation")
return
}
api.WriteCreated(w, r, ContinueConversationResponse{
Message: toMessageDTO(msg),
})
}
// GenerateBlueprintRequest is the request body for POST /projects/{id}/architect/generate-blueprint/{conversationId}.
type GenerateBlueprintRequest struct {
Name string `json:"name"`
}
// GenerateBlueprint generates a structured blueprint from a conversation.
// POST /projects/{id}/architect/generate-blueprint/{conversationId}
func (h *ArchitectHandler) GenerateBlueprint(w http.ResponseWriter, r *http.Request) {
conversationID := domain.ConversationID(chi.URLParam(r, "conversationId"))
var req GenerateBlueprintRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
v := validate.New()
v.Required(req.Name, "name")
if err := v.Error(); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
blueprint, err := h.architectService.GenerateBlueprint(r.Context(), conversationID, req.Name)
if err != nil {
if errors.Is(err, domain.ErrConversationNotFound) {
api.WriteNotFound(w, r, "conversation not found")
return
}
log := logging.FromContext(r.Context()).WithHandler("GenerateBlueprint")
log.Error("failed to generate blueprint", logging.FieldError, err, "conversation_id", conversationID)
api.WriteInternalError(w, r, "failed to generate blueprint")
return
}
api.WriteCreated(w, r, GenerateBlueprintResponse{
Blueprint: toBlueprintDTO(blueprint),
})
}