All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 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>
416 lines
11 KiB
Go
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)
|
|
}
|