blob: e62947e37b8defd723ef9a4aa0fa54390fbb8839 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001// Package server provides HTTP server functionality for the sketch loop.
2package server
3
4import (
Sean McCulloughbaa2b592025-04-23 10:40:08 -07005 "context"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +00006 "crypto/rand"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "encoding/base64"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +00008 "encoding/hex"
Earl Lee2e463fb2025-04-17 11:22:22 -07009 "encoding/json"
10 "fmt"
11 "html"
12 "io"
13 "io/fs"
14 "log/slog"
15 "net/http"
16 "net/http/pprof"
17 "os"
18 "os/exec"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +000019 "path/filepath"
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -070020 "runtime/debug"
Earl Lee2e463fb2025-04-17 11:22:22 -070021 "strconv"
22 "strings"
23 "sync"
24 "syscall"
25 "time"
26
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000027 "sketch.dev/git_tools"
Philip Zeyliger176de792025-04-21 12:25:18 -070028 "sketch.dev/loop/server/gzhandler"
29
Earl Lee2e463fb2025-04-17 11:22:22 -070030 "github.com/creack/pty"
Philip Zeyliger33d282f2025-05-03 04:01:54 +000031 "sketch.dev/claudetool/browse"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070032 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070033 "sketch.dev/loop"
Philip Zeyliger2032b1c2025-04-23 19:40:42 -070034 "sketch.dev/webui"
Earl Lee2e463fb2025-04-17 11:22:22 -070035)
36
37// terminalSession represents a terminal session with its PTY and the event channel
38type terminalSession struct {
39 pty *os.File
40 eventsClients map[chan []byte]bool
41 lastEventClientID int
42 eventsClientsMutex sync.Mutex
43 cmd *exec.Cmd
44}
45
46// TerminalMessage represents a message sent from the client for terminal resize events
47type TerminalMessage struct {
48 Type string `json:"type"`
49 Cols uint16 `json:"cols"`
50 Rows uint16 `json:"rows"`
51}
52
53// TerminalResponse represents the response for a new terminal creation
54type TerminalResponse struct {
55 SessionID string `json:"sessionId"`
56}
57
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070058// TodoItem represents a single todo item for task management
59type TodoItem struct {
60 ID string `json:"id"`
61 Task string `json:"task"`
62 Status string `json:"status"` // queued, in-progress, completed
63}
64
65// TodoList represents a collection of todo items
66type TodoList struct {
67 Items []TodoItem `json:"items"`
68}
69
Sean McCulloughd9f13372025-04-21 15:08:49 -070070type State struct {
Philip Zeyligerd03318d2025-05-08 13:09:12 -070071 // null or 1: "old"
72 // 2: supports SSE for message updates
73 StateVersion int `json:"state_version"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070074 MessageCount int `json:"message_count"`
75 TotalUsage *conversation.CumulativeUsage `json:"total_usage,omitempty"`
76 InitialCommit string `json:"initial_commit"`
77 Title string `json:"title"`
78 BranchName string `json:"branch_name,omitempty"`
79 Hostname string `json:"hostname"` // deprecated
80 WorkingDir string `json:"working_dir"` // deprecated
81 OS string `json:"os"` // deprecated
82 GitOrigin string `json:"git_origin,omitempty"`
83 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
84 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
85 SessionID string `json:"session_id"`
86 SSHAvailable bool `json:"ssh_available"`
87 SSHError string `json:"ssh_error,omitempty"`
88 InContainer bool `json:"in_container"`
89 FirstMessageIndex int `json:"first_message_index"`
90 AgentState string `json:"agent_state,omitempty"`
91 OutsideHostname string `json:"outside_hostname,omitempty"`
92 InsideHostname string `json:"inside_hostname,omitempty"`
93 OutsideOS string `json:"outside_os,omitempty"`
94 InsideOS string `json:"inside_os,omitempty"`
95 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
96 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070097 TodoContent string `json:"todo_content,omitempty"` // Contains todo list JSON data
Sean McCulloughd9f13372025-04-21 15:08:49 -070098}
99
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700100type InitRequest struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700101 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
102 HostAddr string `json:"host_addr"`
103
104 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +0000105 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
106 SSHServerIdentity []byte `json:"ssh_server_identity"`
107 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
108 SSHHostCertificate []byte `json:"ssh_host_certificate"`
109 SSHAvailable bool `json:"ssh_available"`
110 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700111}
112
Earl Lee2e463fb2025-04-17 11:22:22 -0700113// Server serves sketch HTTP. Server implements http.Handler.
114type Server struct {
115 mux *http.ServeMux
116 agent loop.CodingAgent
117 hostname string
118 logFile *os.File
119 // Mutex to protect terminalSessions
120 ptyMutex sync.Mutex
121 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000122 sshAvailable bool
123 sshError string
Earl Lee2e463fb2025-04-17 11:22:22 -0700124}
125
126func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
127 s.mux.ServeHTTP(w, r)
128}
129
130// New creates a new HTTP server.
131func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
132 s := &Server{
133 mux: http.NewServeMux(),
134 agent: agent,
135 hostname: getHostname(),
136 logFile: logFile,
137 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000138 sshAvailable: false,
139 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700140 }
141
142 webBundle, err := webui.Build()
143 if err != nil {
144 return nil, fmt.Errorf("failed to build web bundle, did you run 'go generate sketch.dev/loop/...'?: %w", err)
145 }
146
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000147 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000148
149 // Git tool endpoints
150 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
151 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700152 s.mux.HandleFunc("/git/cat", s.handleGitCat)
153 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000154 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
155
Earl Lee2e463fb2025-04-17 11:22:22 -0700156 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
157 // Check if a specific commit hash was requested
158 commit := r.URL.Query().Get("commit")
159
160 // Get the diff, optionally for a specific commit
161 var diff string
162 var err error
163 if commit != "" {
164 // Validate the commit hash format
165 if !isValidGitSHA(commit) {
166 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
167 return
168 }
169
170 diff, err = agent.Diff(&commit)
171 } else {
172 diff, err = agent.Diff(nil)
173 }
174
175 if err != nil {
176 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
177 return
178 }
179
180 w.Header().Set("Content-Type", "text/plain")
181 w.Write([]byte(diff))
182 })
183
184 // Handler for initialization called by host sketch binary when inside docker.
185 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
186 defer func() {
187 if err := recover(); err != nil {
188 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
189
190 // Return an error response to the client
191 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
192 }
193 }()
194
195 if r.Method != "POST" {
196 http.Error(w, "POST required", http.StatusBadRequest)
197 return
198 }
199
200 body, err := io.ReadAll(r.Body)
201 r.Body.Close()
202 if err != nil {
203 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
204 return
205 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700206
207 m := &InitRequest{}
208 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700209 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
210 return
211 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700212
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000213 // Store SSH availability info
214 s.sshAvailable = m.SSHAvailable
215 s.sshError = m.SSHError
216
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700217 // Start the SSH server if the init request included ssh keys.
218 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
219 go func() {
220 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000221 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700222 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000223 // Update SSH error if server fails to start
224 s.sshAvailable = false
225 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700226 }
227 }()
228 }
229
Earl Lee2e463fb2025-04-17 11:22:22 -0700230 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700231 InDocker: true,
232 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700233 }
234 if err := agent.Init(ini); err != nil {
235 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
236 return
237 }
238 w.Header().Set("Content-Type", "application/json")
239 io.WriteString(w, "{}\n")
240 })
241
242 // Handler for /messages?start=N&end=M (start/end are optional)
243 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
244 w.Header().Set("Content-Type", "application/json")
245
246 // Extract query parameters for range
247 var start, end int
248 var err error
249
250 currentCount := agent.MessageCount()
251
252 startParam := r.URL.Query().Get("start")
253 if startParam != "" {
254 start, err = strconv.Atoi(startParam)
255 if err != nil {
256 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
257 return
258 }
259 }
260
261 endParam := r.URL.Query().Get("end")
262 if endParam != "" {
263 end, err = strconv.Atoi(endParam)
264 if err != nil {
265 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
266 return
267 }
268 } else {
269 end = currentCount
270 }
271
272 if start < 0 || start > end || end > currentCount {
273 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
274 return
275 }
276
277 start = max(0, start)
278 end = min(agent.MessageCount(), end)
279 messages := agent.Messages(start, end)
280
281 // Create a JSON encoder with indentation for pretty-printing
282 encoder := json.NewEncoder(w)
283 encoder.SetIndent("", " ") // Two spaces for each indentation level
284
285 err = encoder.Encode(messages)
286 if err != nil {
287 http.Error(w, err.Error(), http.StatusInternalServerError)
288 }
289 })
290
291 // Handler for /logs - displays the contents of the log file
292 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
293 if s.logFile == nil {
294 http.Error(w, "log file not set", http.StatusNotFound)
295 return
296 }
297 logContents, err := os.ReadFile(s.logFile.Name())
298 if err != nil {
299 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
300 return
301 }
302 w.Header().Set("Content-Type", "text/html; charset=utf-8")
303 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
304 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
305 fmt.Fprintf(w, "</body>\n</html>")
306 })
307
308 // Handler for /download - downloads both messages and status as a JSON file
309 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
310 // Set headers for file download
311 w.Header().Set("Content-Type", "application/octet-stream")
312
313 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
314 timestamp := time.Now().Format("20060102-150405")
315 filename := fmt.Sprintf("sketch-%s.json", timestamp)
316
317 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
318
319 // Get all messages
320 messageCount := agent.MessageCount()
321 messages := agent.Messages(0, messageCount)
322
323 // Get status information (usage and other metadata)
324 totalUsage := agent.TotalUsage()
325 hostname := getHostname()
326 workingDir := getWorkingDir()
327
328 // Create a combined structure with all information
329 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700330 Messages []loop.AgentMessage `json:"messages"`
331 MessageCount int `json:"message_count"`
332 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
333 Hostname string `json:"hostname"`
334 WorkingDir string `json:"working_dir"`
335 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700336 }{
337 Messages: messages,
338 MessageCount: messageCount,
339 TotalUsage: totalUsage,
340 Hostname: hostname,
341 WorkingDir: workingDir,
342 DownloadTime: time.Now().Format(time.RFC3339),
343 }
344
345 // Marshal the JSON with indentation for better readability
346 jsonData, err := json.MarshalIndent(downloadData, "", " ")
347 if err != nil {
348 http.Error(w, err.Error(), http.StatusInternalServerError)
349 return
350 }
351 w.Write(jsonData)
352 })
353
354 // The latter doesn't return until the number of messages has changed (from seen
355 // or from when this was called.)
356 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
357 pollParam := r.URL.Query().Get("poll")
358 seenParam := r.URL.Query().Get("seen")
359
360 // Get the client's current message count (if provided)
361 clientMessageCount := -1
362 var err error
363 if seenParam != "" {
364 clientMessageCount, err = strconv.Atoi(seenParam)
365 if err != nil {
366 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
367 return
368 }
369 }
370
371 serverMessageCount := agent.MessageCount()
372
373 // Let lazy clients not have to specify this.
374 if clientMessageCount == -1 {
375 clientMessageCount = serverMessageCount
376 }
377
378 if pollParam == "true" {
379 ch := make(chan string)
380 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700381 it := agent.NewIterator(r.Context(), clientMessageCount)
382 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700383 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700384 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700385 }()
386 select {
387 case <-r.Context().Done():
388 slog.DebugContext(r.Context(), "abandoned poll request")
389 return
390 case <-time.After(90 * time.Second):
391 // Let the user call /state again to get the latest to limit how long our long polls hang out.
392 slog.DebugContext(r.Context(), "longish poll request")
393 break
394 case <-ch:
395 break
396 }
397 }
398
Earl Lee2e463fb2025-04-17 11:22:22 -0700399 w.Header().Set("Content-Type", "application/json")
400
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000401 // Use the shared getState function
402 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700403
404 // Create a JSON encoder with indentation for pretty-printing
405 encoder := json.NewEncoder(w)
406 encoder.SetIndent("", " ") // Two spaces for each indentation level
407
408 err = encoder.Encode(state)
409 if err != nil {
410 http.Error(w, err.Error(), http.StatusInternalServerError)
411 }
412 })
413
Philip Zeyliger176de792025-04-21 12:25:18 -0700414 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(webBundle)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700415
416 // Terminal WebSocket handler
417 // Terminal endpoints - predefined terminals 1-9
418 // TODO: The UI doesn't actually know how to use terminals 2-9!
419 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
420 if r.Method != http.MethodGet {
421 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
422 return
423 }
424 pathParts := strings.Split(r.URL.Path, "/")
425 if len(pathParts) < 4 {
426 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
427 return
428 }
429
430 sessionID := pathParts[3]
431 // Validate that the terminal ID is between 1-9
432 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
433 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
434 return
435 }
436
437 s.handleTerminalEvents(w, r, sessionID)
438 })
439
440 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
441 if r.Method != http.MethodPost {
442 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
443 return
444 }
445 pathParts := strings.Split(r.URL.Path, "/")
446 if len(pathParts) < 4 {
447 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
448 return
449 }
450 sessionID := pathParts[3]
451 s.handleTerminalInput(w, r, sessionID)
452 })
453
454 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Sean McCullough86b56862025-04-18 13:04:03 -0700455 // Serve the sketch-app-shell.html file directly from the embedded filesystem
456 data, err := fs.ReadFile(webBundle, "sketch-app-shell.html")
Earl Lee2e463fb2025-04-17 11:22:22 -0700457 if err != nil {
458 http.Error(w, "File not found", http.StatusNotFound)
459 return
460 }
461 w.Header().Set("Content-Type", "text/html")
462 w.Write(data)
463 })
464
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700465 // Handler for /commit-description - returns the description of a git commit
466 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
467 if r.Method != http.MethodGet {
468 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
469 return
470 }
471
472 // Get the revision parameter
473 revision := r.URL.Query().Get("revision")
474 if revision == "" {
475 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
476 return
477 }
478
479 // Run git command to get commit description
480 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
481 // Use the working directory from the agent
482 cmd.Dir = s.agent.WorkingDir()
483
484 output, err := cmd.CombinedOutput()
485 if err != nil {
486 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
487 return
488 }
489
490 // Prepare the response
491 resp := map[string]string{
492 "description": strings.TrimSpace(string(output)),
493 }
494
495 w.Header().Set("Content-Type", "application/json")
496 if err := json.NewEncoder(w).Encode(resp); err != nil {
497 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
498 }
499 })
500
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000501 // Handler for /screenshot/{id} - serves screenshot images
502 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
503 if r.Method != http.MethodGet {
504 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
505 return
506 }
507
508 // Extract the screenshot ID from the path
509 pathParts := strings.Split(r.URL.Path, "/")
510 if len(pathParts) < 3 {
511 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
512 return
513 }
514
515 screenshotID := pathParts[2]
516
517 // Validate the ID format (prevent directory traversal)
518 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
519 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
520 return
521 }
522
523 // Get the screenshot file path
524 filePath := browse.GetScreenshotPath(screenshotID)
525
526 // Check if the file exists
527 if _, err := os.Stat(filePath); os.IsNotExist(err) {
528 http.Error(w, "Screenshot not found", http.StatusNotFound)
529 return
530 }
531
532 // Serve the file
533 w.Header().Set("Content-Type", "image/png")
534 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
535 http.ServeFile(w, r, filePath)
536 })
537
Earl Lee2e463fb2025-04-17 11:22:22 -0700538 // Handler for POST /chat
539 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
540 if r.Method != http.MethodPost {
541 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
542 return
543 }
544
545 // Parse the request body
546 var requestBody struct {
547 Message string `json:"message"`
548 }
549
550 decoder := json.NewDecoder(r.Body)
551 if err := decoder.Decode(&requestBody); err != nil {
552 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
553 return
554 }
555 defer r.Body.Close()
556
557 if requestBody.Message == "" {
558 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
559 return
560 }
561
562 agent.UserMessage(r.Context(), requestBody.Message)
563
564 w.WriteHeader(http.StatusOK)
565 })
566
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000567 // Handler for POST /upload - uploads a file to /tmp
568 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
569 if r.Method != http.MethodPost {
570 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
571 return
572 }
573
574 // Limit to 10MB file size
575 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
576
577 // Parse the multipart form
578 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
579 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
580 return
581 }
582
583 // Get the file from the multipart form
584 file, handler, err := r.FormFile("file")
585 if err != nil {
586 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
587 return
588 }
589 defer file.Close()
590
591 // Generate a unique ID (8 random bytes converted to 16 hex chars)
592 randBytes := make([]byte, 8)
593 if _, err := rand.Read(randBytes); err != nil {
594 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
595 return
596 }
597
598 // Get file extension from the original filename
599 ext := filepath.Ext(handler.Filename)
600
601 // Create a unique filename in the /tmp directory
602 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
603
604 // Create the destination file
605 destFile, err := os.Create(filename)
606 if err != nil {
607 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
608 return
609 }
610 defer destFile.Close()
611
612 // Copy the file contents to the destination file
613 if _, err := io.Copy(destFile, file); err != nil {
614 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
615 return
616 }
617
618 // Return the path to the saved file
619 w.Header().Set("Content-Type", "application/json")
620 json.NewEncoder(w).Encode(map[string]string{"path": filename})
621 })
622
Earl Lee2e463fb2025-04-17 11:22:22 -0700623 // Handler for /cancel - cancels the current inner loop in progress
624 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
625 if r.Method != http.MethodPost {
626 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
627 return
628 }
629
630 // Parse the request body (optional)
631 var requestBody struct {
632 Reason string `json:"reason"`
633 ToolCallID string `json:"tool_call_id"`
634 }
635
636 decoder := json.NewDecoder(r.Body)
637 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
638 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
639 return
640 }
641 defer r.Body.Close()
642
643 cancelReason := "user requested cancellation"
644 if requestBody.Reason != "" {
645 cancelReason = requestBody.Reason
646 }
647
648 if requestBody.ToolCallID != "" {
649 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
650 if err != nil {
651 http.Error(w, err.Error(), http.StatusBadRequest)
652 return
653 }
654 // Return a success response
655 w.Header().Set("Content-Type", "application/json")
656 json.NewEncoder(w).Encode(map[string]string{
657 "status": "cancelled",
658 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700659 "reason": cancelReason,
660 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700661 return
662 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000663 // Call the CancelTurn method
664 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700665 // Return a success response
666 w.Header().Set("Content-Type", "application/json")
667 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
668 })
669
Pokey Rule397871d2025-05-19 15:02:45 +0100670 // Handler for /end - shuts down the inner sketch process
671 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
672 if r.Method != http.MethodPost {
673 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
674 return
675 }
676
677 // Parse the request body (optional)
678 var requestBody struct {
679 Reason string `json:"reason"`
680 }
681
682 decoder := json.NewDecoder(r.Body)
683 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
684 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
685 return
686 }
687 defer r.Body.Close()
688
689 endReason := "user requested end of session"
690 if requestBody.Reason != "" {
691 endReason = requestBody.Reason
692 }
693
694 // Send success response before exiting
695 w.Header().Set("Content-Type", "application/json")
696 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
697 if f, ok := w.(http.Flusher); ok {
698 f.Flush()
699 }
700
701 // Log that we're shutting down
702 slog.Info("Ending session", "reason", endReason)
703
704 // Exit the process after a short delay to allow response to be sent
705 go func() {
706 time.Sleep(100 * time.Millisecond)
707 os.Exit(0)
708 }()
709 })
710
Earl Lee2e463fb2025-04-17 11:22:22 -0700711 debugMux := initDebugMux()
712 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
713 debugMux.ServeHTTP(w, r)
714 })
715
716 return s, nil
717}
718
719// Utility functions
720func getHostname() string {
721 hostname, err := os.Hostname()
722 if err != nil {
723 return "unknown"
724 }
725 return hostname
726}
727
728func getWorkingDir() string {
729 wd, err := os.Getwd()
730 if err != nil {
731 return "unknown"
732 }
733 return wd
734}
735
736// createTerminalSession creates a new terminal session with the given ID
737func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
738 // Start a new shell process
739 shellPath := getShellPath()
740 cmd := exec.Command(shellPath)
741
742 // Get working directory from the agent if possible
743 workDir := getWorkingDir()
744 cmd.Dir = workDir
745
746 // Set up environment
747 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
748
749 // Start the command with a pty
750 ptmx, err := pty.Start(cmd)
751 if err != nil {
752 slog.Error("Failed to start pty", "error", err)
753 return nil, err
754 }
755
756 // Create the terminal session
757 session := &terminalSession{
758 pty: ptmx,
759 eventsClients: make(map[chan []byte]bool),
760 cmd: cmd,
761 }
762
763 // Start goroutine to read from pty and broadcast to all connected SSE clients
764 go s.readFromPtyAndBroadcast(sessionID, session)
765
766 return session, nil
767} // handleTerminalEvents handles SSE connections for terminal output
768func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
769 // Check if the session exists, if not, create it
770 s.ptyMutex.Lock()
771 session, exists := s.terminalSessions[sessionID]
772
773 if !exists {
774 // Create a new terminal session
775 var err error
776 session, err = s.createTerminalSession(sessionID)
777 if err != nil {
778 s.ptyMutex.Unlock()
779 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
780 return
781 }
782
783 // Store the new session
784 s.terminalSessions[sessionID] = session
785 }
786 s.ptyMutex.Unlock()
787
788 // Set headers for SSE
789 w.Header().Set("Content-Type", "text/event-stream")
790 w.Header().Set("Cache-Control", "no-cache")
791 w.Header().Set("Connection", "keep-alive")
792 w.Header().Set("Access-Control-Allow-Origin", "*")
793
794 // Create a channel for this client
795 events := make(chan []byte, 4096) // Buffer to prevent blocking
796
797 // Register this client's channel
798 session.eventsClientsMutex.Lock()
799 clientID := session.lastEventClientID + 1
800 session.lastEventClientID = clientID
801 session.eventsClients[events] = true
802 session.eventsClientsMutex.Unlock()
803
804 // When the client disconnects, remove their channel
805 defer func() {
806 session.eventsClientsMutex.Lock()
807 delete(session.eventsClients, events)
808 close(events)
809 session.eventsClientsMutex.Unlock()
810 }()
811
812 // Flush to send headers to client immediately
813 if f, ok := w.(http.Flusher); ok {
814 f.Flush()
815 }
816
817 // Send events to the client as they arrive
818 for {
819 select {
820 case <-r.Context().Done():
821 return
822 case data := <-events:
823 // Format as SSE with base64 encoding
824 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
825
826 // Flush the data immediately
827 if f, ok := w.(http.Flusher); ok {
828 f.Flush()
829 }
830 }
831 }
832}
833
834// handleTerminalInput processes input to the terminal
835func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
836 // Check if the session exists
837 s.ptyMutex.Lock()
838 session, exists := s.terminalSessions[sessionID]
839 s.ptyMutex.Unlock()
840
841 if !exists {
842 http.Error(w, "Terminal session not found", http.StatusNotFound)
843 return
844 }
845
846 // Read the request body (terminal input or resize command)
847 body, err := io.ReadAll(r.Body)
848 if err != nil {
849 http.Error(w, "Failed to read request body", http.StatusBadRequest)
850 return
851 }
852
853 // Check if it's a resize message
854 if len(body) > 0 && body[0] == '{' {
855 var msg TerminalMessage
856 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
857 if msg.Cols > 0 && msg.Rows > 0 {
858 pty.Setsize(session.pty, &pty.Winsize{
859 Cols: msg.Cols,
860 Rows: msg.Rows,
861 })
862
863 // Respond with success
864 w.WriteHeader(http.StatusOK)
865 return
866 }
867 }
868 }
869
870 // Regular terminal input
871 _, err = session.pty.Write(body)
872 if err != nil {
873 slog.Error("Failed to write to pty", "error", err)
874 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
875 return
876 }
877
878 // Respond with success
879 w.WriteHeader(http.StatusOK)
880}
881
882// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
883func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
884 buf := make([]byte, 4096)
885 defer func() {
886 // Clean up when done
887 s.ptyMutex.Lock()
888 delete(s.terminalSessions, sessionID)
889 s.ptyMutex.Unlock()
890
891 // Close the PTY
892 session.pty.Close()
893
894 // Ensure process is terminated
895 if session.cmd.Process != nil {
896 session.cmd.Process.Signal(syscall.SIGTERM)
897 time.Sleep(100 * time.Millisecond)
898 session.cmd.Process.Kill()
899 }
900
901 // Close all client channels
902 session.eventsClientsMutex.Lock()
903 for ch := range session.eventsClients {
904 delete(session.eventsClients, ch)
905 close(ch)
906 }
907 session.eventsClientsMutex.Unlock()
908 }()
909
910 for {
911 n, err := session.pty.Read(buf)
912 if err != nil {
913 if err != io.EOF {
914 slog.Error("Failed to read from pty", "error", err)
915 }
916 break
917 }
918
919 // Make a copy of the data for each client
920 data := make([]byte, n)
921 copy(data, buf[:n])
922
923 // Broadcast to all connected clients
924 session.eventsClientsMutex.Lock()
925 for ch := range session.eventsClients {
926 // Try to send, but don't block if channel is full
927 select {
928 case ch <- data:
929 default:
930 // Channel is full, drop the message for this client
931 }
932 }
933 session.eventsClientsMutex.Unlock()
934 }
935}
936
937// getShellPath returns the path to the shell to use
938func getShellPath() string {
939 // Try to use the user's preferred shell
940 shell := os.Getenv("SHELL")
941 if shell != "" {
942 return shell
943 }
944
945 // Default to bash on Unix-like systems
946 if _, err := os.Stat("/bin/bash"); err == nil {
947 return "/bin/bash"
948 }
949
950 // Fall back to sh
951 return "/bin/sh"
952}
953
954func initDebugMux() *http.ServeMux {
955 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -0700956 build := "unknown build"
957 bi, ok := debug.ReadBuildInfo()
958 if ok {
959 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
960 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700961 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
962 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700963 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -0700964 fmt.Fprintf(w, `<!doctype html>
965 <html><head><title>sketch debug</title></head><body>
966 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -0700967 pid %d<br>
968 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -0700969 <ul>
970 <li><a href="/debug/pprof/cmdline">pprof/cmdline</a></li>
971 <li><a href="/debug/pprof/profile">pprof/profile</a></li>
972 <li><a href="/debug/pprof/symbol">pprof/symbol</a></li>
973 <li><a href="/debug/pprof/trace">pprof/trace</a></li>
974 <li><a href="/debug/pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
975 <li><a href="/debug/metrics">metrics</a></li>
976 </ul>
977 </body>
978 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -0700979 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -0700980 })
981 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
982 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
983 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
984 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
985 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
986 return mux
987}
988
989// isValidGitSHA validates if a string looks like a valid git SHA hash.
990// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
991func isValidGitSHA(sha string) bool {
992 // Git SHA must be a hexadecimal string with at least 4 characters
993 if len(sha) < 4 || len(sha) > 40 {
994 return false
995 }
996
997 // Check if the string only contains hexadecimal characters
998 for _, char := range sha {
999 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1000 return false
1001 }
1002 }
1003
1004 return true
1005}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001006
1007// /stream?from=N endpoint for Server-Sent Events
1008func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1009 w.Header().Set("Content-Type", "text/event-stream")
1010 w.Header().Set("Cache-Control", "no-cache")
1011 w.Header().Set("Connection", "keep-alive")
1012 w.Header().Set("Access-Control-Allow-Origin", "*")
1013
1014 // Extract the 'from' parameter
1015 fromParam := r.URL.Query().Get("from")
1016 var fromIndex int
1017 var err error
1018 if fromParam != "" {
1019 fromIndex, err = strconv.Atoi(fromParam)
1020 if err != nil {
1021 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1022 return
1023 }
1024 }
1025
1026 // Ensure 'from' is valid
1027 currentCount := s.agent.MessageCount()
1028 if fromIndex < 0 {
1029 fromIndex = 0
1030 } else if fromIndex > currentCount {
1031 fromIndex = currentCount
1032 }
1033
1034 // Send the current state immediately
1035 state := s.getState()
1036
1037 // Create JSON encoder
1038 encoder := json.NewEncoder(w)
1039
1040 // Send state as an event
1041 fmt.Fprintf(w, "event: state\n")
1042 fmt.Fprintf(w, "data: ")
1043 encoder.Encode(state)
1044 fmt.Fprintf(w, "\n\n")
1045
1046 if f, ok := w.(http.Flusher); ok {
1047 f.Flush()
1048 }
1049
1050 // Create a context for the SSE stream
1051 ctx := r.Context()
1052
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001053 // Setup heartbeat timer
1054 heartbeatTicker := time.NewTicker(45 * time.Second)
1055 defer heartbeatTicker.Stop()
1056
1057 // Create a channel for messages
1058 messageChan := make(chan *loop.AgentMessage, 10)
1059
Philip Zeyligereab12de2025-05-14 02:35:53 +00001060 // Create a channel for state transitions
1061 stateChan := make(chan *loop.StateTransition, 10)
1062
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001063 // Start a goroutine to read messages without blocking the heartbeat
1064 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001065 // Create an iterator to receive new messages as they arrive
1066 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1067 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001068 defer close(messageChan)
1069 for {
1070 // This can block, but it's in its own goroutine
1071 newMessage := iterator.Next()
1072 if newMessage == nil {
1073 // No message available (likely due to context cancellation)
1074 slog.InfoContext(ctx, "No more messages available, ending message stream")
1075 return
1076 }
1077
1078 select {
1079 case messageChan <- newMessage:
1080 // Message sent to channel
1081 case <-ctx.Done():
1082 // Context cancelled
1083 return
1084 }
1085 }
1086 }()
1087
Philip Zeyligereab12de2025-05-14 02:35:53 +00001088 // Start a goroutine to read state transitions
1089 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001090 // Create an iterator to receive state transitions
1091 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1092 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001093 defer close(stateChan)
1094 for {
1095 // This can block, but it's in its own goroutine
1096 newTransition := stateIterator.Next()
1097 if newTransition == nil {
1098 // No transition available (likely due to context cancellation)
1099 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1100 return
1101 }
1102
1103 select {
1104 case stateChan <- newTransition:
1105 // Transition sent to channel
1106 case <-ctx.Done():
1107 // Context cancelled
1108 return
1109 }
1110 }
1111 }()
1112
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001113 // Stay connected and stream real-time updates
1114 for {
1115 select {
1116 case <-heartbeatTicker.C:
1117 // Send heartbeat event
1118 fmt.Fprintf(w, "event: heartbeat\n")
1119 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1120
1121 // Flush to send the heartbeat immediately
1122 if f, ok := w.(http.Flusher); ok {
1123 f.Flush()
1124 }
1125
1126 case <-ctx.Done():
1127 // Client disconnected
1128 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1129 return
1130
Philip Zeyligereab12de2025-05-14 02:35:53 +00001131 case _, ok := <-stateChan:
1132 if !ok {
1133 // Channel closed
1134 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1135 return
1136 }
1137
1138 // Get updated state
1139 state = s.getState()
1140
1141 // Send updated state after the state transition
1142 fmt.Fprintf(w, "event: state\n")
1143 fmt.Fprintf(w, "data: ")
1144 encoder.Encode(state)
1145 fmt.Fprintf(w, "\n\n")
1146
1147 // Flush to send the state immediately
1148 if f, ok := w.(http.Flusher); ok {
1149 f.Flush()
1150 }
1151
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001152 case newMessage, ok := <-messageChan:
1153 if !ok {
1154 // Channel closed
1155 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1156 return
1157 }
1158
1159 // Send the new message as an event
1160 fmt.Fprintf(w, "event: message\n")
1161 fmt.Fprintf(w, "data: ")
1162 encoder.Encode(newMessage)
1163 fmt.Fprintf(w, "\n\n")
1164
1165 // Get updated state
1166 state = s.getState()
1167
1168 // Send updated state after the message
1169 fmt.Fprintf(w, "event: state\n")
1170 fmt.Fprintf(w, "data: ")
1171 encoder.Encode(state)
1172 fmt.Fprintf(w, "\n\n")
1173
1174 // Flush to send the message and state immediately
1175 if f, ok := w.(http.Flusher); ok {
1176 f.Flush()
1177 }
1178 }
1179 }
1180}
1181
1182// Helper function to get the current state
1183func (s *Server) getState() State {
1184 serverMessageCount := s.agent.MessageCount()
1185 totalUsage := s.agent.TotalUsage()
1186
1187 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001188 StateVersion: 2,
1189 MessageCount: serverMessageCount,
1190 TotalUsage: &totalUsage,
1191 Hostname: s.hostname,
1192 WorkingDir: getWorkingDir(),
1193 // TODO: Rename this field to sketch-base?
1194 InitialCommit: s.agent.SketchGitBase(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001195 Title: s.agent.Title(),
1196 BranchName: s.agent.BranchName(),
1197 OS: s.agent.OS(),
1198 OutsideHostname: s.agent.OutsideHostname(),
1199 InsideHostname: s.hostname,
1200 OutsideOS: s.agent.OutsideOS(),
1201 InsideOS: s.agent.OS(),
1202 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1203 InsideWorkingDir: getWorkingDir(),
1204 GitOrigin: s.agent.GitOrigin(),
1205 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1206 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1207 SessionID: s.agent.SessionID(),
1208 SSHAvailable: s.sshAvailable,
1209 SSHError: s.sshError,
1210 InContainer: s.agent.IsInContainer(),
1211 FirstMessageIndex: s.agent.FirstMessageIndex(),
1212 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001213 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001214 }
1215}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001216
1217func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1218 if r.Method != "GET" {
1219 w.WriteHeader(http.StatusMethodNotAllowed)
1220 return
1221 }
1222
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001223 // Get the git repository root directory from agent
1224 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001225
1226 // Parse query parameters
1227 query := r.URL.Query()
1228 commit := query.Get("commit")
1229 from := query.Get("from")
1230 to := query.Get("to")
1231
1232 // If commit is specified, use commit^ and commit as from and to
1233 if commit != "" {
1234 from = commit + "^"
1235 to = commit
1236 }
1237
1238 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001239 if from == "" {
1240 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001241 return
1242 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001243 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001244
1245 // Call the git_tools function
1246 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1247 if err != nil {
1248 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1249 return
1250 }
1251
1252 // Return the result as JSON
1253 w.Header().Set("Content-Type", "application/json")
1254 if err := json.NewEncoder(w).Encode(diff); err != nil {
1255 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1256 return
1257 }
1258}
1259
1260func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1261 if r.Method != "GET" {
1262 w.WriteHeader(http.StatusMethodNotAllowed)
1263 return
1264 }
1265
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001266 // Get the git repository root directory from agent
1267 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001268
1269 // Parse query parameters
1270 hash := r.URL.Query().Get("hash")
1271 if hash == "" {
1272 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1273 return
1274 }
1275
1276 // Call the git_tools function
1277 show, err := git_tools.GitShow(repoDir, hash)
1278 if err != nil {
1279 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1280 return
1281 }
1282
1283 // Create a JSON response
1284 response := map[string]string{
1285 "hash": hash,
1286 "output": show,
1287 }
1288
1289 // Return the result as JSON
1290 w.Header().Set("Content-Type", "application/json")
1291 if err := json.NewEncoder(w).Encode(response); err != nil {
1292 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1293 return
1294 }
1295}
1296
1297func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1298 if r.Method != "GET" {
1299 w.WriteHeader(http.StatusMethodNotAllowed)
1300 return
1301 }
1302
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001303 // Get the git repository root directory and initial commit from agent
1304 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001305 initialCommit := s.agent.SketchGitBaseRef()
1306
1307 // Call the git_tools function
1308 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1309 if err != nil {
1310 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1311 return
1312 }
1313
1314 // Return the result as JSON
1315 w.Header().Set("Content-Type", "application/json")
1316 if err := json.NewEncoder(w).Encode(log); err != nil {
1317 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1318 return
1319 }
1320}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001321
1322func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1323 if r.Method != "GET" {
1324 w.WriteHeader(http.StatusMethodNotAllowed)
1325 return
1326 }
1327
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001328 // Get the git repository root directory from agent
1329 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001330
1331 // Parse query parameters
1332 query := r.URL.Query()
1333 path := query.Get("path")
1334
1335 // Check if path is provided
1336 if path == "" {
1337 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1338 return
1339 }
1340
1341 // Get file content using GitCat
1342 content, err := git_tools.GitCat(repoDir, path)
1343 if err != nil {
1344 http.Error(w, fmt.Sprintf("Error reading file: %v", err), http.StatusInternalServerError)
1345 return
1346 }
1347
1348 // Return the content as JSON for consistency with other endpoints
1349 w.Header().Set("Content-Type", "application/json")
1350 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1351 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1352 return
1353 }
1354}
1355
1356func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1357 if r.Method != "POST" {
1358 w.WriteHeader(http.StatusMethodNotAllowed)
1359 return
1360 }
1361
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001362 // Get the git repository root directory from agent
1363 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001364
1365 // Parse request body
1366 var requestBody struct {
1367 Path string `json:"path"`
1368 Content string `json:"content"`
1369 }
1370
1371 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1372 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1373 return
1374 }
1375 defer r.Body.Close()
1376
1377 // Check if path is provided
1378 if requestBody.Path == "" {
1379 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1380 return
1381 }
1382
1383 // Save file content using GitSaveFile
1384 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1385 if err != nil {
1386 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1387 return
1388 }
1389
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001390 // Auto-commit the changes
1391 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1392 if err != nil {
1393 http.Error(w, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
1394 return
1395 }
1396
1397 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001398 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
1399 http.Error(w, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
1400 return
1401 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001402
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001403 // Return simple success response
1404 w.WriteHeader(http.StatusOK)
1405 w.Write([]byte("ok"))
1406}