rdev/internal/claudebox/server.go
jordan b41e0dfbf9
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix: use raw JSON responses in claudebox server
The claudebox sidecar was using api.WriteJSON which wraps responses in
{data: ..., meta: ...} format. The claudebox HTTP client expects raw
JSON responses without wrapping.

This caused git clone to appear to fail - the HTTP request succeeded
and returned {data: {success: true, cloned: true}, meta: {...}}, but
the client decoded success=false because it couldn't find the fields
at the top level.

Added writeRawJSON helper and replaced all api.WriteJSON calls with it
for actual responses. Error responses still use api.WriteBadRequest
which returns proper error format.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 16:41:21 -07:00

377 lines
10 KiB
Go

// Package claudebox provides HTTP server and handlers for the claudebox sidecar.
// This package enables HTTP-based execution of Claude Code, git, and SDLC operations
// instead of kubectl exec.
package claudebox
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/pkg/api"
)
// writeRawJSON writes a raw JSON response without the rdev-api wrapper.
// The claudebox sidecar uses direct JSON responses, not the {data, meta} format.
func writeRawJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
// Server handles HTTP requests for claudebox operations.
type Server struct {
executor *Executor
gitOps *GitOperations
sdlcRunner *SDLCRunner
logger *slog.Logger
}
// ServerConfig holds configuration for the claudebox server.
type ServerConfig struct {
Executor *Executor
GitOps *GitOperations
SDLCRunner *SDLCRunner
Logger *slog.Logger
}
// NewServer creates a new claudebox HTTP server.
func NewServer(cfg ServerConfig) *Server {
return &Server{
executor: cfg.Executor,
gitOps: cfg.GitOps,
sdlcRunner: cfg.SDLCRunner,
logger: cfg.Logger,
}
}
// Mount registers server routes on the router.
func (s *Server) Mount(r chi.Router) {
r.Get("/health", s.handleHealth)
r.Post("/execute", s.handleExecute)
r.Post("/execute/stream", s.handleExecuteStream)
r.Post("/git/clone", s.handleGitClone)
r.Post("/git/commit-and-push", s.handleGitCommitAndPush)
r.Get("/git/status", s.handleGitStatus)
r.Post("/sdlc", s.handleSDLC)
}
// HealthResponse is the health check response.
type HealthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
WorkDir string `json:"work_dir"`
}
// handleHealth returns server health status.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := HealthResponse{
Status: "healthy",
Timestamp: time.Now().UTC().Format(time.RFC3339),
WorkDir: s.executor.workDir,
}
writeRawJSON(w, http.StatusOK, resp)
}
// ExecuteRequest is the request to execute Claude Code.
type ExecuteRequest struct {
Prompt string `json:"prompt"`
AllowedTools []string `json:"allowed_tools,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Timeout int `json:"timeout_seconds,omitempty"` // seconds
Metadata map[string]string `json:"metadata,omitempty"`
}
// ExecuteResponse is the response from executing Claude Code.
type ExecuteResponse struct {
Success bool `json:"success"`
Output string `json:"output"`
ExitCode int `json:"exit_code"`
DurationMs int64 `json:"duration_ms"`
Error string `json:"error,omitempty"`
SessionID string `json:"session_id,omitempty"`
FinalOutput string `json:"final_output,omitempty"`
Artifacts map[string]string `json:"artifacts,omitempty"`
}
// handleExecute runs Claude Code and returns the complete result.
func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log := logging.FromContext(ctx)
var req ExecuteRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Prompt == "" {
api.WriteBadRequest(w, r, "prompt is required")
return
}
log.Info("executing Claude Code", "prompt_len", len(req.Prompt))
result := s.executor.Execute(ctx, &req)
resp := ExecuteResponse{
Success: result.Success,
Output: result.Output,
ExitCode: result.ExitCode,
DurationMs: result.DurationMs,
SessionID: result.SessionID,
FinalOutput: result.FinalOutput,
}
if result.Error != nil {
resp.Error = result.Error.Error()
}
writeRawJSON(w, http.StatusOK, resp)
}
// StreamEvent is an SSE event for streaming execution.
type StreamEvent struct {
Type string `json:"type"`
Content string `json:"content,omitempty"`
Stream string `json:"stream,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Data map[string]any `json:"data,omitempty"`
Timestamp string `json:"timestamp"`
}
// handleExecuteStream runs Claude Code and streams events via SSE.
func (s *Server) handleExecuteStream(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log := logging.FromContext(ctx)
var req ExecuteRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Prompt == "" {
api.WriteBadRequest(w, r, "prompt is required")
return
}
// Set up 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("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
api.WriteInternalError(w, r, "streaming not supported")
return
}
log.Info("starting streaming execution", "prompt_len", len(req.Prompt))
// Stream events via callback
eventCh := make(chan StreamEvent, 100)
go func() {
defer close(eventCh)
s.executor.ExecuteStream(ctx, &req, func(evt StreamEvent) {
select {
case eventCh <- evt:
case <-ctx.Done():
}
})
}()
// Write events to client
for evt := range eventCh {
data, err := json.Marshal(evt)
if err != nil {
log.Warn("failed to marshal event", logging.FieldError, err)
continue
}
_, writeErr := w.Write([]byte("data: " + string(data) + "\n\n"))
if writeErr != nil {
log.Debug("client disconnected during stream")
return
}
flusher.Flush()
}
}
// GitCloneRequest is the request to clone a repository.
type GitCloneRequest struct {
CloneURL string `json:"clone_url"`
WorkDir string `json:"work_dir,omitempty"` // defaults to /workspace
}
// GitCloneResponse is the response from cloning.
type GitCloneResponse struct {
Success bool `json:"success"`
Cloned bool `json:"cloned"` // true if cloned, false if already existed
Error string `json:"error,omitempty"`
}
// handleGitClone clones or updates a git repository.
func (s *Server) handleGitClone(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req GitCloneRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.CloneURL == "" {
api.WriteBadRequest(w, r, "clone_url is required")
return
}
workDir := req.WorkDir
if workDir == "" {
workDir = s.gitOps.workDir
}
result := s.gitOps.CloneRepo(ctx, workDir, req.CloneURL)
resp := GitCloneResponse{
Success: result.Error == nil,
Cloned: result.Cloned,
}
if result.Error != nil {
resp.Error = result.Error.Error()
}
writeRawJSON(w, http.StatusOK, resp)
}
// GitCommitAndPushRequest is the request to commit and push changes.
type GitCommitAndPushRequest struct {
Message string `json:"message"`
Push bool `json:"push"`
WorkDir string `json:"work_dir,omitempty"` // defaults to /workspace
}
// GitCommitAndPushResponse is the response from commit and push.
type GitCommitAndPushResponse struct {
Success bool `json:"success"`
HasChanges bool `json:"has_changes"`
CommitSHA string `json:"commit_sha,omitempty"`
FilesChanged []string `json:"files_changed,omitempty"`
Pushed bool `json:"pushed"`
Error string `json:"error,omitempty"`
}
// handleGitCommitAndPush commits and optionally pushes changes.
func (s *Server) handleGitCommitAndPush(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req GitCommitAndPushRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Message == "" {
api.WriteBadRequest(w, r, "message is required")
return
}
workDir := req.WorkDir
if workDir == "" {
workDir = s.gitOps.workDir
}
result := s.gitOps.CommitAndPush(ctx, workDir, req.Message, req.Push)
resp := GitCommitAndPushResponse{
Success: result.Error == nil,
HasChanges: result.HasChanges,
CommitSHA: result.CommitSHA,
FilesChanged: result.FilesChanged,
Pushed: result.Pushed,
}
if result.Error != nil {
resp.Error = result.Error.Error()
}
writeRawJSON(w, http.StatusOK, resp)
}
// GitStatusResponse is the response from git status.
type GitStatusResponse struct {
IsRepo bool `json:"is_repo"`
HasChanges bool `json:"has_changes"`
ChangedFiles []string `json:"changed_files,omitempty"`
Branch string `json:"branch,omitempty"`
Error string `json:"error,omitempty"`
}
// handleGitStatus returns the git status of the workspace.
func (s *Server) handleGitStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workDir := r.URL.Query().Get("work_dir")
if workDir == "" {
workDir = s.gitOps.workDir
}
status, err := s.gitOps.Status(ctx, workDir)
if err != nil {
writeRawJSON(w, http.StatusOK, GitStatusResponse{
IsRepo: false,
Error: err.Error(),
})
return
}
writeRawJSON(w, http.StatusOK, status)
}
// SDLCRequest is the request to run an SDLC command.
type SDLCRequest struct {
Command string `json:"command"`
Args []string `json:"args,omitempty"`
WorkDir string `json:"work_dir,omitempty"` // defaults to /workspace
}
// SDLCResponse is the response from running an SDLC command.
type SDLCResponse struct {
Success bool `json:"success"`
Output string `json:"output"`
Data json.RawMessage `json:"data,omitempty"` // Parsed JSON from sdlc --json output
Error string `json:"error,omitempty"`
}
// handleSDLC runs an SDLC CLI command.
func (s *Server) handleSDLC(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req SDLCRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Command == "" {
api.WriteBadRequest(w, r, "command is required")
return
}
workDir := req.WorkDir
if workDir == "" {
workDir = s.sdlcRunner.workDir
}
result := s.sdlcRunner.Run(ctx, workDir, req.Command, req.Args)
resp := SDLCResponse{
Success: result.Success,
Output: result.Output,
Data: result.Data,
}
if result.Error != nil {
resp.Error = result.Error.Error()
}
writeRawJSON(w, http.StatusOK, resp)
}