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