blob: cc183d386b9b9ec97cc286923ce0a60f768aa9d8 [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"
Josh Bleecher Snyder5c29b3e2025-07-08 18:07:28 +000010 "errors"
Earl Lee2e463fb2025-04-17 11:22:22 -070011 "fmt"
12 "html"
13 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -070014 "log/slog"
15 "net/http"
Philip Zeyligera9710d72025-07-02 02:50:14 +000016 "net/http/httputil"
Earl Lee2e463fb2025-04-17 11:22:22 -070017 "net/http/pprof"
Philip Zeyligera9710d72025-07-02 02:50:14 +000018 "net/url"
Earl Lee2e463fb2025-04-17 11:22:22 -070019 "os"
20 "os/exec"
Philip Zeyligerf84e88c2025-05-14 23:19:01 +000021 "path/filepath"
Philip Zeyliger254c49f2025-07-17 17:26:24 -070022 "regexp"
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -070023 "runtime/debug"
Earl Lee2e463fb2025-04-17 11:22:22 -070024 "strconv"
25 "strings"
26 "sync"
Earl Lee2e463fb2025-04-17 11:22:22 -070027 "time"
28
29 "github.com/creack/pty"
Philip Zeyliger33d282f2025-05-03 04:01:54 +000030 "sketch.dev/claudetool/browse"
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -070031 "sketch.dev/embedded"
32 "sketch.dev/git_tools"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070033 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070034 "sketch.dev/loop"
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -070035 "sketch.dev/loop/server/gzhandler"
Earl Lee2e463fb2025-04-17 11:22:22 -070036)
37
Philip Zeyliger254c49f2025-07-17 17:26:24 -070038// Remote represents a git remote with display information.
39type Remote struct {
40 Name string `json:"name"`
41 URL string `json:"url"`
42 DisplayName string `json:"display_name"`
43 IsGitHub bool `json:"is_github"`
44}
45
46// GitPushInfoResponse represents the response from /git/pushinfo
47type GitPushInfoResponse struct {
48 Hash string `json:"hash"`
49 Subject string `json:"subject"`
50 Remotes []Remote `json:"remotes"`
51}
52
53// GitPushRequest represents the request body for /git/push
54type GitPushRequest struct {
55 Remote string `json:"remote"`
56 Branch string `json:"branch"`
57 Commit string `json:"commit"`
58 DryRun bool `json:"dry_run"`
59 Force bool `json:"force"`
60}
61
62// GitPushResponse represents the response from /git/push
63type GitPushResponse struct {
64 Success bool `json:"success"`
65 Output string `json:"output"`
66 DryRun bool `json:"dry_run"`
67 Error string `json:"error,omitempty"`
68}
69
70// isGitHubURL checks if a URL is a GitHub URL
71func isGitHubURL(url string) bool {
72 return strings.Contains(url, "github.com")
73}
74
75// simplifyGitHubURL simplifies GitHub URLs to "owner/repo" format
76// and also returns whether it's a github url
77func simplifyGitHubURL(url string) (string, bool) {
78 // Handle GitHub URLs in various formats
79 if strings.Contains(url, "github.com") {
80 // Extract owner/repo from URLs like:
81 // https://github.com/owner/repo.git
82 // git@github.com:owner/repo.git
83 // https://github.com/owner/repo
84 re := regexp.MustCompile(`github\.com[:/]([^/]+/[^/]+?)(?:\.git)?/?$`)
85 if matches := re.FindStringSubmatch(url); len(matches) > 1 {
86 return matches[1], true
87 }
88 }
89 return url, false
90}
91
Earl Lee2e463fb2025-04-17 11:22:22 -070092// terminalSession represents a terminal session with its PTY and the event channel
93type terminalSession struct {
94 pty *os.File
95 eventsClients map[chan []byte]bool
96 lastEventClientID int
97 eventsClientsMutex sync.Mutex
98 cmd *exec.Cmd
99}
100
101// TerminalMessage represents a message sent from the client for terminal resize events
102type TerminalMessage struct {
103 Type string `json:"type"`
104 Cols uint16 `json:"cols"`
105 Rows uint16 `json:"rows"`
106}
107
108// TerminalResponse represents the response for a new terminal creation
109type TerminalResponse struct {
110 SessionID string `json:"sessionId"`
111}
112
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700113// TodoItem represents a single todo item for task management
114type TodoItem struct {
115 ID string `json:"id"`
116 Task string `json:"task"`
117 Status string `json:"status"` // queued, in-progress, completed
118}
119
120// TodoList represents a collection of todo items
121type TodoList struct {
122 Items []TodoItem `json:"items"`
123}
124
Sean McCulloughd9f13372025-04-21 15:08:49 -0700125type State struct {
Philip Zeyligerd03318d2025-05-08 13:09:12 -0700126 // null or 1: "old"
127 // 2: supports SSE for message updates
128 StateVersion int `json:"state_version"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700129 MessageCount int `json:"message_count"`
130 TotalUsage *conversation.CumulativeUsage `json:"total_usage,omitempty"`
131 InitialCommit string `json:"initial_commit"`
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700132 Slug string `json:"slug,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700133 BranchName string `json:"branch_name,omitempty"`
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000134 BranchPrefix string `json:"branch_prefix,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700135 Hostname string `json:"hostname"` // deprecated
136 WorkingDir string `json:"working_dir"` // deprecated
137 OS string `json:"os"` // deprecated
138 GitOrigin string `json:"git_origin,omitempty"`
bankseancad67b02025-06-27 21:57:05 +0000139 GitUsername string `json:"git_username,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700140 OutstandingLLMCalls int `json:"outstanding_llm_calls"`
141 OutstandingToolCalls []string `json:"outstanding_tool_calls"`
142 SessionID string `json:"session_id"`
143 SSHAvailable bool `json:"ssh_available"`
144 SSHError string `json:"ssh_error,omitempty"`
145 InContainer bool `json:"in_container"`
146 FirstMessageIndex int `json:"first_message_index"`
147 AgentState string `json:"agent_state,omitempty"`
148 OutsideHostname string `json:"outside_hostname,omitempty"`
149 InsideHostname string `json:"inside_hostname,omitempty"`
150 OutsideOS string `json:"outside_os,omitempty"`
151 InsideOS string `json:"inside_os,omitempty"`
152 OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
153 InsideWorkingDir string `json:"inside_working_dir,omitempty"`
philip.zeyliger8773e682025-06-11 21:36:21 -0700154 TodoContent string `json:"todo_content,omitempty"` // Contains todo list JSON data
155 SkabandAddr string `json:"skaband_addr,omitempty"` // URL of the skaband server
156 LinkToGitHub bool `json:"link_to_github,omitempty"` // Enable GitHub branch linking in UI
157 SSHConnectionString string `json:"ssh_connection_string,omitempty"` // SSH connection string for container
Philip Zeyliger64f60462025-06-16 13:57:10 -0700158 DiffLinesAdded int `json:"diff_lines_added"` // Lines added from sketch-base to HEAD
159 DiffLinesRemoved int `json:"diff_lines_removed"` // Lines removed from sketch-base to HEAD
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000160 OpenPorts []Port `json:"open_ports,omitempty"` // Currently open TCP ports
banksean5ab8fb82025-07-09 12:34:55 -0700161 TokenContextWindow int `json:"token_context_window,omitempty"`
bankseanc67d7bc2025-07-23 10:59:02 -0700162 SessionEnded bool `json:"session_ended,omitempty"`
163 CanSendMessages bool `json:"can_send_messages,omitempty"`
164 EndedAt time.Time `json:"ended_at,omitempty"`
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000165}
166
167// Port represents an open TCP port
168type Port struct {
169 Proto string `json:"proto"` // "tcp" or "udp"
170 Port uint16 `json:"port"` // port number
171 Process string `json:"process"` // optional process name
172 Pid int `json:"pid"` // process ID
Sean McCulloughd9f13372025-04-21 15:08:49 -0700173}
174
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700175type InitRequest struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700176 // Passed to agent so that the URL it prints in the termui prompt is correct (when skaband is not used)
177 HostAddr string `json:"host_addr"`
178
179 // POST /init will start the SSH server with these configs
Sean McCullough7013e9e2025-05-14 02:03:58 +0000180 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
181 SSHServerIdentity []byte `json:"ssh_server_identity"`
182 SSHContainerCAKey []byte `json:"ssh_container_ca_key"`
183 SSHHostCertificate []byte `json:"ssh_host_certificate"`
184 SSHAvailable bool `json:"ssh_available"`
185 SSHError string `json:"ssh_error,omitempty"`
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700186}
187
Earl Lee2e463fb2025-04-17 11:22:22 -0700188// Server serves sketch HTTP. Server implements http.Handler.
189type Server struct {
190 mux *http.ServeMux
191 agent loop.CodingAgent
192 hostname string
193 logFile *os.File
194 // Mutex to protect terminalSessions
195 ptyMutex sync.Mutex
196 terminalSessions map[string]*terminalSession
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000197 sshAvailable bool
198 sshError string
Earl Lee2e463fb2025-04-17 11:22:22 -0700199}
200
201func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Philip Zeyligera9710d72025-07-02 02:50:14 +0000202 // Check if Host header matches "p<port>.localhost" pattern and proxy to that port
203 if port := s.ParsePortProxyHost(r.Host); port != "" {
204 s.proxyToPort(w, r, port)
205 return
206 }
207
Earl Lee2e463fb2025-04-17 11:22:22 -0700208 s.mux.ServeHTTP(w, r)
209}
210
Philip Zeyligera9710d72025-07-02 02:50:14 +0000211// ParsePortProxyHost checks if host matches "p<port>.localhost" pattern and returns the port
212func (s *Server) ParsePortProxyHost(host string) string {
213 // Remove port suffix if present (e.g., "p8000.localhost:8080" -> "p8000.localhost")
214 hostname := host
215 if idx := strings.LastIndex(host, ":"); idx > 0 {
216 hostname = host[:idx]
217 }
218
219 // Check if hostname matches p<port>.localhost pattern
220 if strings.HasSuffix(hostname, ".localhost") {
221 prefix := strings.TrimSuffix(hostname, ".localhost")
222 if strings.HasPrefix(prefix, "p") && len(prefix) > 1 {
223 port := prefix[1:] // Remove 'p' prefix
224 // Basic validation - port should be numeric and in valid range
225 if portNum, err := strconv.Atoi(port); err == nil && portNum > 0 && portNum <= 65535 {
226 return port
227 }
228 }
229 }
230
231 return ""
232}
233
234// proxyToPort proxies the request to localhost:<port>
235func (s *Server) proxyToPort(w http.ResponseWriter, r *http.Request, port string) {
236 // Create a reverse proxy to localhost:<port>
237 target, err := url.Parse(fmt.Sprintf("http://localhost:%s", port))
238 if err != nil {
239 http.Error(w, "Failed to parse proxy target", http.StatusInternalServerError)
240 return
241 }
242
243 proxy := httputil.NewSingleHostReverseProxy(target)
244
245 // Customize the Director to modify the request
246 originalDirector := proxy.Director
247 proxy.Director = func(req *http.Request) {
248 originalDirector(req)
249 // Set the target host
250 req.URL.Host = target.Host
251 req.URL.Scheme = target.Scheme
252 req.Host = target.Host
253 }
254
255 // Handle proxy errors
256 proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
257 slog.Error("Proxy error", "error", err, "target", target.String(), "port", port)
258 http.Error(w, "Proxy error: "+err.Error(), http.StatusBadGateway)
259 }
260
261 proxy.ServeHTTP(w, r)
262}
263
Earl Lee2e463fb2025-04-17 11:22:22 -0700264// New creates a new HTTP server.
265func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
266 s := &Server{
267 mux: http.NewServeMux(),
268 agent: agent,
269 hostname: getHostname(),
270 logFile: logFile,
271 terminalSessions: make(map[string]*terminalSession),
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000272 sshAvailable: false,
273 sshError: "",
Earl Lee2e463fb2025-04-17 11:22:22 -0700274 }
275
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000276 s.mux.HandleFunc("/stream", s.handleSSEStream)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000277
278 // Git tool endpoints
279 s.mux.HandleFunc("/git/rawdiff", s.handleGitRawDiff)
280 s.mux.HandleFunc("/git/show", s.handleGitShow)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700281 s.mux.HandleFunc("/git/cat", s.handleGitCat)
282 s.mux.HandleFunc("/git/save", s.handleGitSave)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000283 s.mux.HandleFunc("/git/recentlog", s.handleGitRecentLog)
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +0000284 s.mux.HandleFunc("/git/untracked", s.handleGitUntracked)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000285
Earl Lee2e463fb2025-04-17 11:22:22 -0700286 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
287 // Check if a specific commit hash was requested
288 commit := r.URL.Query().Get("commit")
289
290 // Get the diff, optionally for a specific commit
291 var diff string
292 var err error
293 if commit != "" {
294 // Validate the commit hash format
295 if !isValidGitSHA(commit) {
296 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
297 return
298 }
299
300 diff, err = agent.Diff(&commit)
301 } else {
302 diff, err = agent.Diff(nil)
303 }
304
305 if err != nil {
306 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
307 return
308 }
309
310 w.Header().Set("Content-Type", "text/plain")
311 w.Write([]byte(diff))
312 })
313
314 // Handler for initialization called by host sketch binary when inside docker.
315 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
316 defer func() {
317 if err := recover(); err != nil {
318 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
319
320 // Return an error response to the client
321 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
322 }
323 }()
324
325 if r.Method != "POST" {
326 http.Error(w, "POST required", http.StatusBadRequest)
327 return
328 }
329
330 body, err := io.ReadAll(r.Body)
331 r.Body.Close()
332 if err != nil {
333 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
334 return
335 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700336
337 m := &InitRequest{}
338 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700339 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
340 return
341 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700342
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000343 // Store SSH availability info
344 s.sshAvailable = m.SSHAvailable
345 s.sshError = m.SSHError
346
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700347 // Start the SSH server if the init request included ssh keys.
348 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
349 go func() {
350 ctx := context.Background()
Sean McCullough7013e9e2025-05-14 02:03:58 +0000351 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700352 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000353 // Update SSH error if server fails to start
354 s.sshAvailable = false
355 s.sshError = err.Error()
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700356 }
357 }()
358 }
359
Earl Lee2e463fb2025-04-17 11:22:22 -0700360 ini := loop.AgentInit{
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700361 InDocker: true,
362 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700363 }
364 if err := agent.Init(ini); err != nil {
365 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
366 return
367 }
368 w.Header().Set("Content-Type", "application/json")
369 io.WriteString(w, "{}\n")
370 })
371
372 // Handler for /messages?start=N&end=M (start/end are optional)
373 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
374 w.Header().Set("Content-Type", "application/json")
375
376 // Extract query parameters for range
377 var start, end int
378 var err error
379
380 currentCount := agent.MessageCount()
381
382 startParam := r.URL.Query().Get("start")
383 if startParam != "" {
384 start, err = strconv.Atoi(startParam)
385 if err != nil {
386 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
387 return
388 }
389 }
390
391 endParam := r.URL.Query().Get("end")
392 if endParam != "" {
393 end, err = strconv.Atoi(endParam)
394 if err != nil {
395 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
396 return
397 }
398 } else {
399 end = currentCount
400 }
401
402 if start < 0 || start > end || end > currentCount {
403 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
404 return
405 }
406
407 start = max(0, start)
408 end = min(agent.MessageCount(), end)
409 messages := agent.Messages(start, end)
410
411 // Create a JSON encoder with indentation for pretty-printing
412 encoder := json.NewEncoder(w)
413 encoder.SetIndent("", " ") // Two spaces for each indentation level
414
415 err = encoder.Encode(messages)
416 if err != nil {
417 http.Error(w, err.Error(), http.StatusInternalServerError)
418 }
419 })
420
421 // Handler for /logs - displays the contents of the log file
422 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
423 if s.logFile == nil {
424 http.Error(w, "log file not set", http.StatusNotFound)
425 return
426 }
427 logContents, err := os.ReadFile(s.logFile.Name())
428 if err != nil {
429 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
430 return
431 }
432 w.Header().Set("Content-Type", "text/html; charset=utf-8")
433 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
434 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
435 fmt.Fprintf(w, "</body>\n</html>")
436 })
437
438 // Handler for /download - downloads both messages and status as a JSON file
439 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
440 // Set headers for file download
441 w.Header().Set("Content-Type", "application/octet-stream")
442
443 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
444 timestamp := time.Now().Format("20060102-150405")
445 filename := fmt.Sprintf("sketch-%s.json", timestamp)
446
447 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
448
449 // Get all messages
450 messageCount := agent.MessageCount()
451 messages := agent.Messages(0, messageCount)
452
453 // Get status information (usage and other metadata)
454 totalUsage := agent.TotalUsage()
455 hostname := getHostname()
456 workingDir := getWorkingDir()
457
458 // Create a combined structure with all information
459 downloadData := struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700460 Messages []loop.AgentMessage `json:"messages"`
461 MessageCount int `json:"message_count"`
462 TotalUsage conversation.CumulativeUsage `json:"total_usage"`
463 Hostname string `json:"hostname"`
464 WorkingDir string `json:"working_dir"`
465 DownloadTime string `json:"download_time"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700466 }{
467 Messages: messages,
468 MessageCount: messageCount,
469 TotalUsage: totalUsage,
470 Hostname: hostname,
471 WorkingDir: workingDir,
472 DownloadTime: time.Now().Format(time.RFC3339),
473 }
474
475 // Marshal the JSON with indentation for better readability
476 jsonData, err := json.MarshalIndent(downloadData, "", " ")
477 if err != nil {
478 http.Error(w, err.Error(), http.StatusInternalServerError)
479 return
480 }
481 w.Write(jsonData)
482 })
483
484 // The latter doesn't return until the number of messages has changed (from seen
485 // or from when this was called.)
486 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
487 pollParam := r.URL.Query().Get("poll")
488 seenParam := r.URL.Query().Get("seen")
489
490 // Get the client's current message count (if provided)
491 clientMessageCount := -1
492 var err error
493 if seenParam != "" {
494 clientMessageCount, err = strconv.Atoi(seenParam)
495 if err != nil {
496 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
497 return
498 }
499 }
500
501 serverMessageCount := agent.MessageCount()
502
503 // Let lazy clients not have to specify this.
504 if clientMessageCount == -1 {
505 clientMessageCount = serverMessageCount
506 }
507
508 if pollParam == "true" {
509 ch := make(chan string)
510 go func() {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700511 it := agent.NewIterator(r.Context(), clientMessageCount)
512 it.Next()
Earl Lee2e463fb2025-04-17 11:22:22 -0700513 close(ch)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700514 it.Close()
Earl Lee2e463fb2025-04-17 11:22:22 -0700515 }()
516 select {
517 case <-r.Context().Done():
518 slog.DebugContext(r.Context(), "abandoned poll request")
519 return
520 case <-time.After(90 * time.Second):
521 // Let the user call /state again to get the latest to limit how long our long polls hang out.
522 slog.DebugContext(r.Context(), "longish poll request")
523 break
524 case <-ch:
525 break
526 }
527 }
528
Earl Lee2e463fb2025-04-17 11:22:22 -0700529 w.Header().Set("Content-Type", "application/json")
530
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000531 // Use the shared getState function
532 state := s.getState()
Earl Lee2e463fb2025-04-17 11:22:22 -0700533
534 // Create a JSON encoder with indentation for pretty-printing
535 encoder := json.NewEncoder(w)
536 encoder.SetIndent("", " ") // Two spaces for each indentation level
537
538 err = encoder.Encode(state)
539 if err != nil {
540 http.Error(w, err.Error(), http.StatusInternalServerError)
541 }
542 })
543
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700544 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(embedded.WebUIFS())))
Earl Lee2e463fb2025-04-17 11:22:22 -0700545
546 // Terminal WebSocket handler
547 // Terminal endpoints - predefined terminals 1-9
548 // TODO: The UI doesn't actually know how to use terminals 2-9!
549 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
550 if r.Method != http.MethodGet {
551 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
552 return
553 }
554 pathParts := strings.Split(r.URL.Path, "/")
555 if len(pathParts) < 4 {
556 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
557 return
558 }
559
560 sessionID := pathParts[3]
561 // Validate that the terminal ID is between 1-9
562 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
563 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
564 return
565 }
566
567 s.handleTerminalEvents(w, r, sessionID)
568 })
569
570 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
571 if r.Method != http.MethodPost {
572 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
573 return
574 }
575 pathParts := strings.Split(r.URL.Path, "/")
576 if len(pathParts) < 4 {
577 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
578 return
579 }
580 sessionID := pathParts[3]
581 s.handleTerminalInput(w, r, sessionID)
582 })
583
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700584 // Handler for interface selection via URL parameters (?m for mobile)
Earl Lee2e463fb2025-04-17 11:22:22 -0700585 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700586 webuiFS := embedded.WebUIFS()
587 appShell := "sketch-app-shell.html"
588 if r.URL.Query().Has("m") {
589 appShell = "mobile-app-shell.html"
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700590 }
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700591 http.ServeFileFS(w, r, webuiFS, appShell)
Earl Lee2e463fb2025-04-17 11:22:22 -0700592 })
593
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700594 // Handler for /commit-description - returns the description of a git commit
595 s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
596 if r.Method != http.MethodGet {
597 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
598 return
599 }
600
601 // Get the revision parameter
602 revision := r.URL.Query().Get("revision")
603 if revision == "" {
604 http.Error(w, "Missing revision parameter", http.StatusBadRequest)
605 return
606 }
607
608 // Run git command to get commit description
609 cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
610 // Use the working directory from the agent
611 cmd.Dir = s.agent.WorkingDir()
612
613 output, err := cmd.CombinedOutput()
614 if err != nil {
615 http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
616 return
617 }
618
619 // Prepare the response
620 resp := map[string]string{
621 "description": strings.TrimSpace(string(output)),
622 }
623
624 w.Header().Set("Content-Type", "application/json")
625 if err := json.NewEncoder(w).Encode(resp); err != nil {
626 slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
627 }
628 })
629
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000630 // Handler for /screenshot/{id} - serves screenshot images
631 s.mux.HandleFunc("/screenshot/", func(w http.ResponseWriter, r *http.Request) {
632 if r.Method != http.MethodGet {
633 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
634 return
635 }
636
637 // Extract the screenshot ID from the path
638 pathParts := strings.Split(r.URL.Path, "/")
639 if len(pathParts) < 3 {
640 http.Error(w, "Invalid screenshot ID", http.StatusBadRequest)
641 return
642 }
643
644 screenshotID := pathParts[2]
645
646 // Validate the ID format (prevent directory traversal)
647 if strings.Contains(screenshotID, "/") || strings.Contains(screenshotID, "\\") {
648 http.Error(w, "Invalid screenshot ID format", http.StatusBadRequest)
649 return
650 }
651
652 // Get the screenshot file path
653 filePath := browse.GetScreenshotPath(screenshotID)
654
655 // Check if the file exists
656 if _, err := os.Stat(filePath); os.IsNotExist(err) {
657 http.Error(w, "Screenshot not found", http.StatusNotFound)
658 return
659 }
660
661 // Serve the file
662 w.Header().Set("Content-Type", "image/png")
663 w.Header().Set("Cache-Control", "max-age=3600") // Cache for an hour
664 http.ServeFile(w, r, filePath)
665 })
666
Earl Lee2e463fb2025-04-17 11:22:22 -0700667 // Handler for POST /chat
668 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
669 if r.Method != http.MethodPost {
670 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
671 return
672 }
673
674 // Parse the request body
675 var requestBody struct {
676 Message string `json:"message"`
677 }
678
679 decoder := json.NewDecoder(r.Body)
680 if err := decoder.Decode(&requestBody); err != nil {
681 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
682 return
683 }
684 defer r.Body.Close()
685
686 if requestBody.Message == "" {
687 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
688 return
689 }
690
691 agent.UserMessage(r.Context(), requestBody.Message)
692
693 w.WriteHeader(http.StatusOK)
694 })
695
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000696 // Handler for POST /upload - uploads a file to /tmp
697 s.mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
698 if r.Method != http.MethodPost {
699 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
700 return
701 }
702
703 // Limit to 10MB file size
704 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
705
706 // Parse the multipart form
707 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
708 http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
709 return
710 }
711
712 // Get the file from the multipart form
713 file, handler, err := r.FormFile("file")
714 if err != nil {
715 http.Error(w, "Failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
716 return
717 }
718 defer file.Close()
719
720 // Generate a unique ID (8 random bytes converted to 16 hex chars)
721 randBytes := make([]byte, 8)
722 if _, err := rand.Read(randBytes); err != nil {
723 http.Error(w, "Failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
724 return
725 }
726
727 // Get file extension from the original filename
728 ext := filepath.Ext(handler.Filename)
729
730 // Create a unique filename in the /tmp directory
731 filename := fmt.Sprintf("/tmp/sketch_file_%s%s", hex.EncodeToString(randBytes), ext)
732
733 // Create the destination file
734 destFile, err := os.Create(filename)
735 if err != nil {
736 http.Error(w, "Failed to create destination file: "+err.Error(), http.StatusInternalServerError)
737 return
738 }
739 defer destFile.Close()
740
741 // Copy the file contents to the destination file
742 if _, err := io.Copy(destFile, file); err != nil {
743 http.Error(w, "Failed to save file: "+err.Error(), http.StatusInternalServerError)
744 return
745 }
746
747 // Return the path to the saved file
748 w.Header().Set("Content-Type", "application/json")
749 json.NewEncoder(w).Encode(map[string]string{"path": filename})
750 })
751
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700752 // Handler for /git/pushinfo - returns HEAD commit and remotes for push dialog
753 s.mux.HandleFunc("/git/pushinfo", s.handleGitPushInfo)
754
755 // Handler for /git/push - handles git push operations
756 s.mux.HandleFunc("/git/push", s.handleGitPush)
757
Earl Lee2e463fb2025-04-17 11:22:22 -0700758 // Handler for /cancel - cancels the current inner loop in progress
759 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
760 if r.Method != http.MethodPost {
761 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
762 return
763 }
764
765 // Parse the request body (optional)
766 var requestBody struct {
767 Reason string `json:"reason"`
768 ToolCallID string `json:"tool_call_id"`
769 }
770
771 decoder := json.NewDecoder(r.Body)
772 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
773 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
774 return
775 }
776 defer r.Body.Close()
777
778 cancelReason := "user requested cancellation"
779 if requestBody.Reason != "" {
780 cancelReason = requestBody.Reason
781 }
782
783 if requestBody.ToolCallID != "" {
784 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
785 if err != nil {
786 http.Error(w, err.Error(), http.StatusBadRequest)
787 return
788 }
789 // Return a success response
790 w.Header().Set("Content-Type", "application/json")
791 json.NewEncoder(w).Encode(map[string]string{
792 "status": "cancelled",
793 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700794 "reason": cancelReason,
795 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700796 return
797 }
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000798 // Call the CancelTurn method
799 agent.CancelTurn(fmt.Errorf("%s", cancelReason))
Earl Lee2e463fb2025-04-17 11:22:22 -0700800 // Return a success response
801 w.Header().Set("Content-Type", "application/json")
802 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
803 })
804
Pokey Rule397871d2025-05-19 15:02:45 +0100805 // Handler for /end - shuts down the inner sketch process
806 s.mux.HandleFunc("/end", func(w http.ResponseWriter, r *http.Request) {
807 if r.Method != http.MethodPost {
808 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
809 return
810 }
811
812 // Parse the request body (optional)
813 var requestBody struct {
Philip Zeyligerb5739402025-06-02 07:04:34 -0700814 Reason string `json:"reason"`
815 Happy *bool `json:"happy,omitempty"`
816 Comment string `json:"comment,omitempty"`
Pokey Rule397871d2025-05-19 15:02:45 +0100817 }
818
819 decoder := json.NewDecoder(r.Body)
820 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
821 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
822 return
823 }
824 defer r.Body.Close()
825
826 endReason := "user requested end of session"
827 if requestBody.Reason != "" {
828 endReason = requestBody.Reason
829 }
830
831 // Send success response before exiting
832 w.Header().Set("Content-Type", "application/json")
833 json.NewEncoder(w).Encode(map[string]string{"status": "ending", "reason": endReason})
834 if f, ok := w.(http.Flusher); ok {
835 f.Flush()
836 }
837
838 // Log that we're shutting down
839 slog.Info("Ending session", "reason", endReason)
840
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000841 // Give a brief moment for the response to be sent before exiting
Pokey Rule397871d2025-05-19 15:02:45 +0100842 go func() {
philip.zeyliger28e39ac2025-06-16 22:04:35 +0000843 time.Sleep(100 * time.Millisecond)
Pokey Rule397871d2025-05-19 15:02:45 +0100844 os.Exit(0)
845 }()
846 })
847
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700848 debugMux := initDebugMux(agent)
Earl Lee2e463fb2025-04-17 11:22:22 -0700849 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
850 debugMux.ServeHTTP(w, r)
851 })
852
853 return s, nil
854}
855
856// Utility functions
857func getHostname() string {
858 hostname, err := os.Hostname()
859 if err != nil {
860 return "unknown"
861 }
862 return hostname
863}
864
865func getWorkingDir() string {
866 wd, err := os.Getwd()
867 if err != nil {
868 return "unknown"
869 }
870 return wd
871}
872
873// createTerminalSession creates a new terminal session with the given ID
874func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
875 // Start a new shell process
876 shellPath := getShellPath()
877 cmd := exec.Command(shellPath)
878
879 // Get working directory from the agent if possible
880 workDir := getWorkingDir()
881 cmd.Dir = workDir
882
883 // Set up environment
884 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
885
886 // Start the command with a pty
887 ptmx, err := pty.Start(cmd)
888 if err != nil {
889 slog.Error("Failed to start pty", "error", err)
890 return nil, err
891 }
892
893 // Create the terminal session
894 session := &terminalSession{
895 pty: ptmx,
896 eventsClients: make(map[chan []byte]bool),
897 cmd: cmd,
898 }
899
900 // Start goroutine to read from pty and broadcast to all connected SSE clients
901 go s.readFromPtyAndBroadcast(sessionID, session)
902
903 return session, nil
David Crawshawb8431462025-07-09 13:10:32 +1000904}
905
906// handleTerminalEvents handles SSE connections for terminal output
Earl Lee2e463fb2025-04-17 11:22:22 -0700907func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
908 // Check if the session exists, if not, create it
909 s.ptyMutex.Lock()
910 session, exists := s.terminalSessions[sessionID]
911
912 if !exists {
913 // Create a new terminal session
914 var err error
915 session, err = s.createTerminalSession(sessionID)
916 if err != nil {
917 s.ptyMutex.Unlock()
918 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
919 return
920 }
921
922 // Store the new session
923 s.terminalSessions[sessionID] = session
924 }
925 s.ptyMutex.Unlock()
926
927 // Set headers for SSE
928 w.Header().Set("Content-Type", "text/event-stream")
929 w.Header().Set("Cache-Control", "no-cache")
930 w.Header().Set("Connection", "keep-alive")
931 w.Header().Set("Access-Control-Allow-Origin", "*")
932
933 // Create a channel for this client
934 events := make(chan []byte, 4096) // Buffer to prevent blocking
935
936 // Register this client's channel
937 session.eventsClientsMutex.Lock()
938 clientID := session.lastEventClientID + 1
939 session.lastEventClientID = clientID
940 session.eventsClients[events] = true
941 session.eventsClientsMutex.Unlock()
942
943 // When the client disconnects, remove their channel
944 defer func() {
945 session.eventsClientsMutex.Lock()
946 delete(session.eventsClients, events)
947 close(events)
948 session.eventsClientsMutex.Unlock()
949 }()
950
951 // Flush to send headers to client immediately
952 if f, ok := w.(http.Flusher); ok {
953 f.Flush()
954 }
955
956 // Send events to the client as they arrive
957 for {
958 select {
959 case <-r.Context().Done():
960 return
961 case data := <-events:
962 // Format as SSE with base64 encoding
963 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
964
965 // Flush the data immediately
966 if f, ok := w.(http.Flusher); ok {
967 f.Flush()
968 }
969 }
970 }
971}
972
973// handleTerminalInput processes input to the terminal
974func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
975 // Check if the session exists
976 s.ptyMutex.Lock()
977 session, exists := s.terminalSessions[sessionID]
978 s.ptyMutex.Unlock()
979
980 if !exists {
981 http.Error(w, "Terminal session not found", http.StatusNotFound)
982 return
983 }
984
985 // Read the request body (terminal input or resize command)
986 body, err := io.ReadAll(r.Body)
987 if err != nil {
988 http.Error(w, "Failed to read request body", http.StatusBadRequest)
989 return
990 }
991
992 // Check if it's a resize message
993 if len(body) > 0 && body[0] == '{' {
994 var msg TerminalMessage
995 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
996 if msg.Cols > 0 && msg.Rows > 0 {
997 pty.Setsize(session.pty, &pty.Winsize{
998 Cols: msg.Cols,
999 Rows: msg.Rows,
1000 })
1001
1002 // Respond with success
1003 w.WriteHeader(http.StatusOK)
1004 return
1005 }
1006 }
1007 }
1008
1009 // Regular terminal input
1010 _, err = session.pty.Write(body)
1011 if err != nil {
1012 slog.Error("Failed to write to pty", "error", err)
1013 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
1014 return
1015 }
1016
1017 // Respond with success
1018 w.WriteHeader(http.StatusOK)
1019}
1020
1021// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
1022func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
1023 buf := make([]byte, 4096)
1024 defer func() {
1025 // Clean up when done
1026 s.ptyMutex.Lock()
1027 delete(s.terminalSessions, sessionID)
1028 s.ptyMutex.Unlock()
1029
1030 // Close the PTY
1031 session.pty.Close()
1032
1033 // Ensure process is terminated
1034 if session.cmd.Process != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001035 session.cmd.Process.Kill()
1036 }
David Crawshawb8431462025-07-09 13:10:32 +10001037 session.cmd.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -07001038
1039 // Close all client channels
1040 session.eventsClientsMutex.Lock()
1041 for ch := range session.eventsClients {
1042 delete(session.eventsClients, ch)
1043 close(ch)
1044 }
1045 session.eventsClientsMutex.Unlock()
1046 }()
1047
1048 for {
1049 n, err := session.pty.Read(buf)
1050 if err != nil {
1051 if err != io.EOF {
1052 slog.Error("Failed to read from pty", "error", err)
1053 }
1054 break
1055 }
1056
1057 // Make a copy of the data for each client
1058 data := make([]byte, n)
1059 copy(data, buf[:n])
1060
1061 // Broadcast to all connected clients
1062 session.eventsClientsMutex.Lock()
1063 for ch := range session.eventsClients {
1064 // Try to send, but don't block if channel is full
1065 select {
1066 case ch <- data:
1067 default:
1068 // Channel is full, drop the message for this client
1069 }
1070 }
1071 session.eventsClientsMutex.Unlock()
1072 }
1073}
1074
1075// getShellPath returns the path to the shell to use
1076func getShellPath() string {
1077 // Try to use the user's preferred shell
1078 shell := os.Getenv("SHELL")
1079 if shell != "" {
1080 return shell
1081 }
1082
1083 // Default to bash on Unix-like systems
1084 if _, err := os.Stat("/bin/bash"); err == nil {
1085 return "/bin/bash"
1086 }
1087
1088 // Fall back to sh
1089 return "/bin/sh"
1090}
1091
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001092func initDebugMux(agent loop.CodingAgent) *http.ServeMux {
Earl Lee2e463fb2025-04-17 11:22:22 -07001093 mux := http.NewServeMux()
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001094 build := "unknown build"
1095 bi, ok := debug.ReadBuildInfo()
1096 if ok {
1097 build = fmt.Sprintf("%s@%v\n", bi.Path, bi.Main.Version)
1098 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001099 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
1100 w.Header().Set("Content-Type", "text/html; charset=utf-8")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001101 // TODO: pid is not as useful as "outside pid"
Earl Lee2e463fb2025-04-17 11:22:22 -07001102 fmt.Fprintf(w, `<!doctype html>
1103 <html><head><title>sketch debug</title></head><body>
1104 <h1>sketch debug</h1>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001105 pid %d<br>
1106 build %s<br>
Earl Lee2e463fb2025-04-17 11:22:22 -07001107 <ul>
Philip Zeyligera14b0182025-06-30 14:31:18 -07001108 <li><a href="pprof/cmdline">pprof/cmdline</a></li>
1109 <li><a href="pprof/profile">pprof/profile</a></li>
1110 <li><a href="pprof/symbol">pprof/symbol</a></li>
1111 <li><a href="pprof/trace">pprof/trace</a></li>
1112 <li><a href="pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001113 <li><a href="conversation-history">conversation-history</a></li>
Earl Lee2e463fb2025-04-17 11:22:22 -07001114 </ul>
1115 </body>
1116 </html>
Philip Zeyliger8d8b7ac2025-05-21 09:57:23 -07001117 `, os.Getpid(), build)
Earl Lee2e463fb2025-04-17 11:22:22 -07001118 })
1119 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
1120 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
1121 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
1122 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
1123 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -07001124
1125 // Add conversation history debug handler
1126 mux.HandleFunc("GET /debug/conversation-history", func(w http.ResponseWriter, r *http.Request) {
1127 w.Header().Set("Content-Type", "application/json")
1128
1129 // Use type assertion to access the GetConvo method
1130 type ConvoProvider interface {
1131 GetConvo() loop.ConvoInterface
1132 }
1133
1134 if convoProvider, ok := agent.(ConvoProvider); ok {
1135 // Call the DebugJSON method to get the conversation history
1136 historyJSON, err := convoProvider.GetConvo().DebugJSON()
1137 if err != nil {
1138 http.Error(w, fmt.Sprintf("Error getting conversation history: %v", err), http.StatusInternalServerError)
1139 return
1140 }
1141
1142 // Write the JSON response
1143 w.Write(historyJSON)
1144 } else {
1145 http.Error(w, "Agent does not support conversation history debugging", http.StatusNotImplemented)
1146 }
1147 })
1148
Earl Lee2e463fb2025-04-17 11:22:22 -07001149 return mux
1150}
1151
1152// isValidGitSHA validates if a string looks like a valid git SHA hash.
1153// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1154func isValidGitSHA(sha string) bool {
1155 // Git SHA must be a hexadecimal string with at least 4 characters
1156 if len(sha) < 4 || len(sha) > 40 {
1157 return false
1158 }
1159
1160 // Check if the string only contains hexadecimal characters
1161 for _, char := range sha {
1162 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1163 return false
1164 }
1165 }
1166
1167 return true
1168}
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001169
1170// /stream?from=N endpoint for Server-Sent Events
1171func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
1172 w.Header().Set("Content-Type", "text/event-stream")
1173 w.Header().Set("Cache-Control", "no-cache")
1174 w.Header().Set("Connection", "keep-alive")
1175 w.Header().Set("Access-Control-Allow-Origin", "*")
1176
1177 // Extract the 'from' parameter
1178 fromParam := r.URL.Query().Get("from")
1179 var fromIndex int
1180 var err error
1181 if fromParam != "" {
1182 fromIndex, err = strconv.Atoi(fromParam)
1183 if err != nil {
1184 http.Error(w, "Invalid 'from' parameter", http.StatusBadRequest)
1185 return
1186 }
1187 }
1188
1189 // Ensure 'from' is valid
1190 currentCount := s.agent.MessageCount()
1191 if fromIndex < 0 {
1192 fromIndex = 0
1193 } else if fromIndex > currentCount {
1194 fromIndex = currentCount
1195 }
1196
1197 // Send the current state immediately
1198 state := s.getState()
1199
1200 // Create JSON encoder
1201 encoder := json.NewEncoder(w)
1202
1203 // Send state as an event
1204 fmt.Fprintf(w, "event: state\n")
1205 fmt.Fprintf(w, "data: ")
1206 encoder.Encode(state)
1207 fmt.Fprintf(w, "\n\n")
1208
1209 if f, ok := w.(http.Flusher); ok {
1210 f.Flush()
1211 }
1212
1213 // Create a context for the SSE stream
1214 ctx := r.Context()
1215
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001216 // Setup heartbeat timer
1217 heartbeatTicker := time.NewTicker(45 * time.Second)
1218 defer heartbeatTicker.Stop()
1219
1220 // Create a channel for messages
1221 messageChan := make(chan *loop.AgentMessage, 10)
1222
Philip Zeyligereab12de2025-05-14 02:35:53 +00001223 // Create a channel for state transitions
1224 stateChan := make(chan *loop.StateTransition, 10)
1225
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001226 // Start a goroutine to read messages without blocking the heartbeat
1227 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001228 // Create an iterator to receive new messages as they arrive
1229 iterator := s.agent.NewIterator(ctx, fromIndex) // Start from the requested index
1230 defer iterator.Close()
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001231 defer close(messageChan)
1232 for {
1233 // This can block, but it's in its own goroutine
1234 newMessage := iterator.Next()
1235 if newMessage == nil {
1236 // No message available (likely due to context cancellation)
1237 slog.InfoContext(ctx, "No more messages available, ending message stream")
1238 return
1239 }
1240
1241 select {
1242 case messageChan <- newMessage:
1243 // Message sent to channel
1244 case <-ctx.Done():
1245 // Context cancelled
1246 return
1247 }
1248 }
1249 }()
1250
Philip Zeyligereab12de2025-05-14 02:35:53 +00001251 // Start a goroutine to read state transitions
1252 go func() {
Pokey Rule9d7f0cc2025-05-20 11:43:26 +01001253 // Create an iterator to receive state transitions
1254 stateIterator := s.agent.NewStateTransitionIterator(ctx)
1255 defer stateIterator.Close()
Philip Zeyligereab12de2025-05-14 02:35:53 +00001256 defer close(stateChan)
1257 for {
1258 // This can block, but it's in its own goroutine
1259 newTransition := stateIterator.Next()
1260 if newTransition == nil {
1261 // No transition available (likely due to context cancellation)
1262 slog.InfoContext(ctx, "No more state transitions available, ending state stream")
1263 return
1264 }
1265
1266 select {
1267 case stateChan <- newTransition:
1268 // Transition sent to channel
1269 case <-ctx.Done():
1270 // Context cancelled
1271 return
1272 }
1273 }
1274 }()
1275
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001276 // Stay connected and stream real-time updates
1277 for {
1278 select {
1279 case <-heartbeatTicker.C:
1280 // Send heartbeat event
1281 fmt.Fprintf(w, "event: heartbeat\n")
1282 fmt.Fprintf(w, "data: %d\n\n", time.Now().Unix())
1283
1284 // Flush to send the heartbeat immediately
1285 if f, ok := w.(http.Flusher); ok {
1286 f.Flush()
1287 }
1288
1289 case <-ctx.Done():
1290 // Client disconnected
1291 slog.InfoContext(ctx, "Client disconnected from SSE stream")
1292 return
1293
Philip Zeyligereab12de2025-05-14 02:35:53 +00001294 case _, ok := <-stateChan:
1295 if !ok {
1296 // Channel closed
1297 slog.InfoContext(ctx, "State transition channel closed, ending SSE stream")
1298 return
1299 }
1300
1301 // Get updated state
1302 state = s.getState()
1303
1304 // Send updated state after the state transition
1305 fmt.Fprintf(w, "event: state\n")
1306 fmt.Fprintf(w, "data: ")
1307 encoder.Encode(state)
1308 fmt.Fprintf(w, "\n\n")
1309
1310 // Flush to send the state immediately
1311 if f, ok := w.(http.Flusher); ok {
1312 f.Flush()
1313 }
1314
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001315 case newMessage, ok := <-messageChan:
1316 if !ok {
1317 // Channel closed
1318 slog.InfoContext(ctx, "Message channel closed, ending SSE stream")
1319 return
1320 }
1321
1322 // Send the new message as an event
1323 fmt.Fprintf(w, "event: message\n")
1324 fmt.Fprintf(w, "data: ")
1325 encoder.Encode(newMessage)
1326 fmt.Fprintf(w, "\n\n")
1327
1328 // Get updated state
1329 state = s.getState()
1330
1331 // Send updated state after the message
1332 fmt.Fprintf(w, "event: state\n")
1333 fmt.Fprintf(w, "data: ")
1334 encoder.Encode(state)
1335 fmt.Fprintf(w, "\n\n")
1336
1337 // Flush to send the message and state immediately
1338 if f, ok := w.(http.Flusher); ok {
1339 f.Flush()
1340 }
1341 }
1342 }
1343}
1344
1345// Helper function to get the current state
1346func (s *Server) getState() State {
1347 serverMessageCount := s.agent.MessageCount()
1348 totalUsage := s.agent.TotalUsage()
1349
Philip Zeyliger64f60462025-06-16 13:57:10 -07001350 // Get diff stats
1351 diffAdded, diffRemoved := s.agent.DiffStats()
1352
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001353 return State{
Philip Zeyliger49edc922025-05-14 09:45:45 -07001354 StateVersion: 2,
1355 MessageCount: serverMessageCount,
1356 TotalUsage: &totalUsage,
1357 Hostname: s.hostname,
1358 WorkingDir: getWorkingDir(),
1359 // TODO: Rename this field to sketch-base?
1360 InitialCommit: s.agent.SketchGitBase(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001361 Slug: s.agent.Slug(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001362 BranchName: s.agent.BranchName(),
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001363 BranchPrefix: s.agent.BranchPrefix(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001364 OS: s.agent.OS(),
1365 OutsideHostname: s.agent.OutsideHostname(),
1366 InsideHostname: s.hostname,
1367 OutsideOS: s.agent.OutsideOS(),
1368 InsideOS: s.agent.OS(),
1369 OutsideWorkingDir: s.agent.OutsideWorkingDir(),
1370 InsideWorkingDir: getWorkingDir(),
1371 GitOrigin: s.agent.GitOrigin(),
bankseancad67b02025-06-27 21:57:05 +00001372 GitUsername: s.agent.GitUsername(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001373 OutstandingLLMCalls: s.agent.OutstandingLLMCallCount(),
1374 OutstandingToolCalls: s.agent.OutstandingToolCalls(),
1375 SessionID: s.agent.SessionID(),
1376 SSHAvailable: s.sshAvailable,
1377 SSHError: s.sshError,
1378 InContainer: s.agent.IsInContainer(),
1379 FirstMessageIndex: s.agent.FirstMessageIndex(),
1380 AgentState: s.agent.CurrentStateName(),
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001381 TodoContent: s.agent.CurrentTodoContent(),
Philip Zeyliger0113be52025-06-07 23:53:41 +00001382 SkabandAddr: s.agent.SkabandAddr(),
philip.zeyliger6d3de482025-06-10 19:38:14 -07001383 LinkToGitHub: s.agent.LinkToGitHub(),
philip.zeyliger8773e682025-06-11 21:36:21 -07001384 SSHConnectionString: s.agent.SSHConnectionString(),
Philip Zeyliger64f60462025-06-16 13:57:10 -07001385 DiffLinesAdded: diffAdded,
1386 DiffLinesRemoved: diffRemoved,
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001387 OpenPorts: s.getOpenPorts(),
banksean5ab8fb82025-07-09 12:34:55 -07001388 TokenContextWindow: s.agent.TokenContextWindow(),
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001389 }
1390}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001391
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001392// getOpenPorts retrieves the current open ports from the agent
1393func (s *Server) getOpenPorts() []Port {
1394 ports := s.agent.GetPorts()
1395 if ports == nil {
1396 return nil
1397 }
1398
1399 result := make([]Port, len(ports))
1400 for i, port := range ports {
1401 result[i] = Port{
1402 Proto: port.Proto,
1403 Port: port.Port,
1404 Process: port.Process,
1405 Pid: port.Pid,
1406 }
1407 }
1408 return result
1409}
1410
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001411func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
1412 if r.Method != "GET" {
1413 w.WriteHeader(http.StatusMethodNotAllowed)
1414 return
1415 }
1416
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001417 // Get the git repository root directory from agent
1418 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001419
1420 // Parse query parameters
1421 query := r.URL.Query()
1422 commit := query.Get("commit")
1423 from := query.Get("from")
1424 to := query.Get("to")
1425
1426 // If commit is specified, use commit^ and commit as from and to
1427 if commit != "" {
1428 from = commit + "^"
1429 to = commit
1430 }
1431
1432 // Check if we have enough parameters
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001433 if from == "" {
1434 http.Error(w, "Missing required parameter: either 'commit' or at least 'from'", http.StatusBadRequest)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001435 return
1436 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001437 // Note: 'to' can be empty to indicate working directory (unstaged changes)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001438
1439 // Call the git_tools function
1440 diff, err := git_tools.GitRawDiff(repoDir, from, to)
1441 if err != nil {
1442 http.Error(w, fmt.Sprintf("Error getting git diff: %v", err), http.StatusInternalServerError)
1443 return
1444 }
1445
1446 // Return the result as JSON
1447 w.Header().Set("Content-Type", "application/json")
1448 if err := json.NewEncoder(w).Encode(diff); err != nil {
1449 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1450 return
1451 }
1452}
1453
1454func (s *Server) handleGitShow(w http.ResponseWriter, r *http.Request) {
1455 if r.Method != "GET" {
1456 w.WriteHeader(http.StatusMethodNotAllowed)
1457 return
1458 }
1459
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001460 // Get the git repository root directory from agent
1461 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001462
1463 // Parse query parameters
1464 hash := r.URL.Query().Get("hash")
1465 if hash == "" {
1466 http.Error(w, "Missing required parameter: 'hash'", http.StatusBadRequest)
1467 return
1468 }
1469
1470 // Call the git_tools function
1471 show, err := git_tools.GitShow(repoDir, hash)
1472 if err != nil {
1473 http.Error(w, fmt.Sprintf("Error running git show: %v", err), http.StatusInternalServerError)
1474 return
1475 }
1476
1477 // Create a JSON response
1478 response := map[string]string{
1479 "hash": hash,
1480 "output": show,
1481 }
1482
1483 // Return the result as JSON
1484 w.Header().Set("Content-Type", "application/json")
1485 if err := json.NewEncoder(w).Encode(response); err != nil {
1486 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1487 return
1488 }
1489}
1490
1491func (s *Server) handleGitRecentLog(w http.ResponseWriter, r *http.Request) {
1492 if r.Method != "GET" {
1493 w.WriteHeader(http.StatusMethodNotAllowed)
1494 return
1495 }
1496
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001497 // Get the git repository root directory and initial commit from agent
1498 repoDir := s.agent.RepoRoot()
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001499 initialCommit := s.agent.SketchGitBaseRef()
1500
1501 // Call the git_tools function
1502 log, err := git_tools.GitRecentLog(repoDir, initialCommit)
1503 if err != nil {
1504 http.Error(w, fmt.Sprintf("Error getting git log: %v", err), http.StatusInternalServerError)
1505 return
1506 }
1507
1508 // Return the result as JSON
1509 w.Header().Set("Content-Type", "application/json")
1510 if err := json.NewEncoder(w).Encode(log); err != nil {
1511 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1512 return
1513 }
1514}
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001515
1516func (s *Server) handleGitCat(w http.ResponseWriter, r *http.Request) {
1517 if r.Method != "GET" {
1518 w.WriteHeader(http.StatusMethodNotAllowed)
1519 return
1520 }
1521
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001522 // Get the git repository root directory from agent
1523 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001524
1525 // Parse query parameters
1526 query := r.URL.Query()
1527 path := query.Get("path")
1528
1529 // Check if path is provided
1530 if path == "" {
1531 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1532 return
1533 }
1534
1535 // Get file content using GitCat
1536 content, err := git_tools.GitCat(repoDir, path)
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001537 switch {
1538 case err == nil:
1539 // continued below
1540 case errors.Is(err, os.ErrNotExist), strings.Contains(err.Error(), "not tracked by git"):
Josh Bleecher Snyder5c29b3e2025-07-08 18:07:28 +00001541 w.WriteHeader(http.StatusNoContent)
1542 return
Josh Bleecher Snyderfadffe32025-07-10 00:08:38 +00001543 default:
1544 http.Error(w, fmt.Sprintf("error reading file: %v", err), http.StatusInternalServerError)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001545 return
1546 }
1547
1548 // Return the content as JSON for consistency with other endpoints
1549 w.Header().Set("Content-Type", "application/json")
1550 if err := json.NewEncoder(w).Encode(map[string]string{"output": content}); err != nil {
1551 http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
1552 return
1553 }
1554}
1555
1556func (s *Server) handleGitSave(w http.ResponseWriter, r *http.Request) {
1557 if r.Method != "POST" {
1558 w.WriteHeader(http.StatusMethodNotAllowed)
1559 return
1560 }
1561
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001562 // Get the git repository root directory from agent
1563 repoDir := s.agent.RepoRoot()
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001564
1565 // Parse request body
1566 var requestBody struct {
1567 Path string `json:"path"`
1568 Content string `json:"content"`
1569 }
1570
1571 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1572 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1573 return
1574 }
1575 defer r.Body.Close()
1576
1577 // Check if path is provided
1578 if requestBody.Path == "" {
1579 http.Error(w, "Missing required parameter: path", http.StatusBadRequest)
1580 return
1581 }
1582
1583 // Save file content using GitSaveFile
1584 err := git_tools.GitSaveFile(repoDir, requestBody.Path, requestBody.Content)
1585 if err != nil {
1586 http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
1587 return
1588 }
1589
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001590 // Auto-commit the changes
1591 err = git_tools.AutoCommitDiffViewChanges(r.Context(), repoDir, requestBody.Path)
1592 if err != nil {
1593 http.Error(w, fmt.Sprintf("Error auto-committing changes: %v", err), http.StatusInternalServerError)
1594 return
1595 }
1596
1597 // Detect git changes to push and notify user
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001598 if err = s.agent.DetectGitChanges(r.Context()); err != nil {
1599 http.Error(w, fmt.Sprintf("Error detecting git changes: %v", err), http.StatusInternalServerError)
1600 return
1601 }
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001602
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001603 // Return simple success response
1604 w.WriteHeader(http.StatusOK)
1605 w.Write([]byte("ok"))
1606}
Josh Bleecher Snydera8561f72025-07-15 23:47:59 +00001607
1608func (s *Server) handleGitUntracked(w http.ResponseWriter, r *http.Request) {
1609 if r.Method != "GET" {
1610 w.WriteHeader(http.StatusMethodNotAllowed)
1611 return
1612 }
1613
1614 repoDir := s.agent.RepoRoot()
1615 untrackedFiles, err := git_tools.GitGetUntrackedFiles(repoDir)
1616 if err != nil {
1617 http.Error(w, fmt.Sprintf("Error getting untracked files: %v", err), http.StatusInternalServerError)
1618 return
1619 }
1620
1621 w.Header().Set("Content-Type", "application/json")
1622 response := map[string][]string{
1623 "untracked_files": untrackedFiles,
1624 }
1625 _ = json.NewEncoder(w).Encode(response) // can't do anything useful with errors anyway
1626}
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001627
1628// handleGitPushInfo returns the current HEAD commit info and remotes for push dialog
1629func (s *Server) handleGitPushInfo(w http.ResponseWriter, r *http.Request) {
1630 if r.Method != "GET" {
1631 w.WriteHeader(http.StatusMethodNotAllowed)
1632 return
1633 }
1634
1635 repoDir := s.agent.RepoRoot()
1636
1637 // Get the current HEAD commit hash and subject in one command
1638 cmd := exec.Command("git", "log", "-n", "1", "--format=%H%x00%s", "HEAD")
1639 cmd.Dir = repoDir
1640 output, err := cmd.Output()
1641 if err != nil {
1642 http.Error(w, fmt.Sprintf("Error getting HEAD commit: %v", err), http.StatusInternalServerError)
1643 return
1644 }
1645
1646 parts := strings.Split(strings.TrimSpace(string(output)), "\x00")
1647 if len(parts) != 2 {
1648 http.Error(w, "Unexpected git log output format", http.StatusInternalServerError)
1649 return
1650 }
1651 hash := parts[0]
1652 subject := parts[1]
1653
1654 // Get list of remote names
1655 cmd = exec.Command("git", "remote")
1656 cmd.Dir = repoDir
1657 output, err = cmd.Output()
1658 if err != nil {
1659 http.Error(w, fmt.Sprintf("Error getting remotes: %v", err), http.StatusInternalServerError)
1660 return
1661 }
1662
1663 remoteNames := strings.Fields(strings.TrimSpace(string(output)))
1664
1665 remotes := make([]Remote, 0, len(remoteNames))
1666
1667 // Get URL and display name for each remote
1668 for _, remoteName := range remoteNames {
1669 cmd = exec.Command("git", "remote", "get-url", remoteName)
1670 cmd.Dir = repoDir
1671 urlOutput, err := cmd.Output()
1672 if err != nil {
1673 // Skip this remote if we can't get its URL
1674 continue
1675 }
1676 url := strings.TrimSpace(string(urlOutput))
1677
1678 // Set display name based on passthrough-upstream and remote name
1679 var displayName string
1680 var isGitHub bool
1681 if s.agent.PassthroughUpstream() && remoteName == "origin" {
1682 // For passthrough upstream, origin displays as "outside_hostname:outside_working_dir"
1683 displayName = fmt.Sprintf("%s:%s", s.agent.OutsideHostname(), s.agent.OutsideWorkingDir())
1684 isGitHub = false
1685 } else if remoteName == "origin" || remoteName == "upstream" {
1686 // Use git_origin value, simplified for GitHub URLs
1687 displayName, isGitHub = simplifyGitHubURL(s.agent.GitOrigin())
1688 } else {
1689 // For other remotes, use the remote URL directly
1690 displayName, isGitHub = simplifyGitHubURL(url)
1691 }
1692
1693 remotes = append(remotes, Remote{
1694 Name: remoteName,
1695 URL: url,
1696 DisplayName: displayName,
1697 IsGitHub: isGitHub,
1698 })
1699 }
1700
1701 w.Header().Set("Content-Type", "application/json")
1702 response := GitPushInfoResponse{
1703 Hash: hash,
1704 Subject: subject,
1705 Remotes: remotes,
1706 }
1707 _ = json.NewEncoder(w).Encode(response)
1708}
1709
1710// handleGitPush handles git push operations
1711func (s *Server) handleGitPush(w http.ResponseWriter, r *http.Request) {
1712 if r.Method != "POST" {
1713 w.WriteHeader(http.StatusMethodNotAllowed)
1714 return
1715 }
1716
1717 // Parse request body
1718 var requestBody GitPushRequest
1719
1720 if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
1721 http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
1722 return
1723 }
1724 defer r.Body.Close()
1725
1726 if requestBody.Remote == "" || requestBody.Branch == "" || requestBody.Commit == "" {
1727 http.Error(w, "Missing required parameters: remote, branch, and commit", http.StatusBadRequest)
1728 return
1729 }
1730
1731 repoDir := s.agent.RepoRoot()
1732
1733 // Build the git push command
1734 args := []string{"push"}
1735 if requestBody.DryRun {
1736 args = append(args, "--dry-run")
1737 }
1738 if requestBody.Force {
1739 args = append(args, "--force")
1740 }
1741
1742 // Determine the target refspec
1743 var targetRef string
1744 if s.agent.PassthroughUpstream() && requestBody.Remote == "upstream" {
1745 // Special case: upstream with passthrough-upstream pushes to refs/remotes/origin/<branch>
1746 targetRef = fmt.Sprintf("refs/remotes/origin/%s", requestBody.Branch)
1747 } else {
1748 // Normal case: push to refs/heads/<branch>
1749 targetRef = fmt.Sprintf("refs/heads/%s", requestBody.Branch)
1750 }
1751
1752 args = append(args, requestBody.Remote, fmt.Sprintf("%s:%s", requestBody.Commit, targetRef))
1753
1754 // Log the git push command being executed
1755 slog.InfoContext(r.Context(), "executing git push command",
1756 "command", "git",
1757 "args", args,
1758 "remote", requestBody.Remote,
1759 "branch", requestBody.Branch,
1760 "commit", requestBody.Commit,
1761 "target_ref", targetRef,
1762 "dry_run", requestBody.DryRun,
1763 "force", requestBody.Force,
1764 "repo_dir", repoDir)
1765
1766 cmd := exec.Command("git", args...)
1767 cmd.Dir = repoDir
1768 // Ideally we want to pass an extra HTTP header so that the
1769 // server can know that this was likely a user initiated action
1770 // and not an agent-initiated action. However, git push weirdly
1771 // doesn't take a "-c" option, and the only handy env variable that
1772 // because a header is the user agent, so we abuse it...
1773 cmd.Env = append(os.Environ(), "GIT_HTTP_USER_AGENT=sketch-intentional-push")
1774 output, err := cmd.CombinedOutput()
1775
1776 // Log the result of the git push command
1777 if err != nil {
1778 slog.WarnContext(r.Context(), "git push command failed",
1779 "error", err,
1780 "output", string(output),
1781 "args", args)
1782 } else {
1783 slog.InfoContext(r.Context(), "git push command completed successfully",
1784 "output", string(output),
1785 "args", args)
1786 }
1787
1788 // Prepare response
1789 response := GitPushResponse{
1790 Success: err == nil,
1791 Output: string(output),
1792 DryRun: requestBody.DryRun,
1793 }
1794
1795 if err != nil {
1796 response.Error = err.Error()
1797 }
1798
1799 w.Header().Set("Content-Type", "application/json")
1800 _ = json.NewEncoder(w).Encode(response)
1801}