rdev/internal/claudebox/server.go
jordan 3dbde72966
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: add claude_id tracking and session improvements for interactive dev
- Add claude_id field to sessions (migration 026) for tracking Claude
  process IDs across pod restarts
- Extend session repository with UpdateClaudeID and session lookup methods
- Improve kubernetes executor with better error handling and exec streaming
- Add claudebox client/server improvements for session lifecycle
- Expand sessions handler with exec streaming endpoint
- Add comprehensive tests for sessions and kubernetes executor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 00:20:32 -07:00

416 lines
11 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.Post("/git/reset-to-main", s.handleGitResetToMain)
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"`
ResumeSessionID string `json:"resume_session_id,omitempty"` // passed as --resume to claude
}
// 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)
}
// GitResetToMainRequest is the request to reset the workspace to main.
type GitResetToMainRequest struct {
WorkDir string `json:"work_dir,omitempty"` // defaults to /workspace
}
// GitResetToMainResponse is the response from resetting to main.
type GitResetToMainResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// handleGitResetToMain resets the workspace to the main branch.
func (s *Server) handleGitResetToMain(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req GitResetToMainRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
workDir := req.WorkDir
if workDir == "" {
workDir = s.gitOps.workDir
}
err := s.gitOps.ResetToMain(ctx, workDir)
resp := GitResetToMainResponse{
Success: err == nil,
}
if err != nil {
resp.Error = err.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)
}