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