package api import ( "encoding/json" "io" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" "go.uber.org/zap" ) // handleWorkspaceStreamWS handles the unified workspace stream WebSocket endpoint. // It streams git status, file changes, file lists, and shell I/O over a single connection. func (s *Server) handleWorkspaceStreamWS(c *gin.Context) { conn, err := s.upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { s.logger.Error("WebSocket upgrade failed", zap.Error(err)) return } defer func() { if err := conn.Close(); err != nil { s.logger.Debug("failed to close workspace stream websocket", zap.Error(err)) } }() s.logger.Info("Workspace stream WebSocket connected") // Subscribe to unified workspace updates. Manager.SubscribeWorkspaceStream // fans out across the root tracker plus every per-repo tracker, so for // multi-repo task roots the client receives one event per repo (each // tagged with RepositoryName) on a single channel. sub := s.procMgr.SubscribeWorkspaceStream() defer s.procMgr.UnsubscribeWorkspaceStream(sub) // Get shell for input handling (may be nil) shell := s.procMgr.Shell() // Subscribe to shell output if shell is available var shellOutputCh chan []byte if shell != nil { shellOutputCh = make(chan []byte, 256) shell.Subscribe(shellOutputCh) defer shell.Unsubscribe(shellOutputCh) } // Send connected message connectedMsg := types.NewWorkspaceConnected() if err := conn.WriteJSON(connectedMsg); err != nil { s.logger.Debug("failed to send connected message", zap.Error(err)) return } // Done channel to signal goroutine shutdown done := make(chan struct{}) defer close(done) // Handle incoming messages (shell_input, shell_resize, ping) in a goroutine var shellWriter io.Writer if shell != nil { shellWriter = shell } go s.handleWorkspaceStreamInput(conn, shellWriter, done) // Forward all workspace events to WebSocket for { select { case <-done: return case msg, ok := <-sub: if !ok { return } if err := conn.WriteJSON(msg); err != nil { s.logger.Debug("workspace stream write error", zap.Error(err)) return } case data, ok := <-shellOutputCh: if !ok { // Shell output channel closed shellOutputCh = nil continue } // Forward shell output as workspace stream message shellMsg := types.NewWorkspaceShellOutput(string(data)) if err := conn.WriteJSON(shellMsg); err != nil { s.logger.Debug("workspace stream shell output write error", zap.Error(err)) return } } } } // handleFileTree handles file tree requests via HTTP GET func (s *Server) handleFileTree(c *gin.Context) { path := c.Query("path") depth := 1 if d := c.Query("depth"); d != "" { if _, err := json.Number(d).Int64(); err == nil { depth = mustParseInt(d) } } tree, err := s.procMgr.GetWorkspaceTracker().GetFileTree(path, depth) if err != nil { c.JSON(400, types.FileTreeResponse{Error: err.Error()}) return } c.JSON(200, types.FileTreeResponse{Root: tree}) } // handleFileContent handles file content requests via HTTP GET. // Optional `repo` query param scopes the read to a per-repo subdirectory for // multi-repo task workspaces; `path` is treated as repo-relative when set. func (s *Server) handleFileContent(c *gin.Context) { path := c.Query("path") if path == "" { c.JSON(400, types.FileContentResponse{Error: "path is required"}) return } scopedPath, err := s.procMgr.JoinRepoPath(c.Query("repo"), path) if err != nil { c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error()}) return } content, size, isBinary, resolvedPath, err := s.procMgr.GetWorkspaceTracker().GetFileContent(scopedPath) if err != nil { c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error(), Size: size}) return } // Return the repo-relative `path` to the caller — the `repo` scoping is an // internal detail; the frontend tracks repository_name separately. c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size, IsBinary: isBinary, ResolvedPath: resolvedPath}) } // handleFileContentAtRef handles file content requests at a specific git ref via HTTP GET. // Optional `repo` query param scopes the read to a per-repo subdirectory for // multi-repo task workspaces. func (s *Server) handleFileContentAtRef(c *gin.Context) { path := c.Query("path") if path == "" { c.JSON(400, types.FileContentResponse{Error: "path is required"}) return } ref := c.Query("ref") if ref == "" { c.JSON(400, types.FileContentResponse{Error: "ref is required"}) return } scopedPath, err := s.procMgr.JoinRepoPath(c.Query("repo"), path) if err != nil { c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error()}) return } content, size, isBinary, err := s.procMgr.GetWorkspaceTracker().GetFileContentAtRef(c.Request.Context(), scopedPath, ref) if err != nil { c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error(), Size: size}) return } c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size, IsBinary: isBinary}) } // handleFileUpdate handles file update requests via HTTP POST func (s *Server) handleFileUpdate(c *gin.Context) { var req streams.FileUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, streams.FileUpdateResponse{Error: "invalid request: " + err.Error()}) return } if req.Path == "" { c.JSON(400, streams.FileUpdateResponse{Error: "path is required"}) return } if req.Diff == "" { c.JSON(400, streams.FileUpdateResponse{Error: "diff is required"}) return } scopedPath, err := s.procMgr.JoinRepoPath(req.Repo, req.Path) if err != nil { c.JSON(400, streams.FileUpdateResponse{ Path: req.Path, Success: false, Error: err.Error(), }) return } // Apply the diff newHash, resolution, err := s.procMgr.GetWorkspaceTracker().ApplyFileDiff(c.Request.Context(), scopedPath, req.Diff, req.OriginalHash, req.DesiredContent) if err != nil { c.JSON(400, streams.FileUpdateResponse{ Path: req.Path, Success: false, Error: err.Error(), }) return } c.JSON(200, streams.FileUpdateResponse{ Path: req.Path, Success: true, NewHash: newHash, Resolution: resolution, }) } // handleFileSearch handles file search requests via HTTP GET func (s *Server) handleFileSearch(c *gin.Context) { query := c.Query("q") limit := 20 if l := c.Query("limit"); l != "" { if parsed := mustParseInt(l); parsed > 0 { limit = parsed } } files := s.procMgr.GetWorkspaceTracker().SearchFiles(query, limit) c.JSON(200, types.FileSearchResponse{Files: files}) } // handleFileCreate handles file create requests via HTTP POST. // Optional `Repo` body field scopes the create to a per-repo subdirectory // for multi-repo task workspaces; without it a UI-driven create on a // multi-repo task lands in the bare task root (one level above any actual // repo) instead of inside the intended repo. func (s *Server) handleFileCreate(c *gin.Context) { var req streams.FileCreateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, streams.FileCreateResponse{Error: "invalid request: " + err.Error()}) return } if req.Path == "" { c.JSON(400, streams.FileCreateResponse{Error: "path is required"}) return } scopedPath, joinErr := s.procMgr.JoinRepoPath(req.Repo, req.Path) if joinErr != nil { c.JSON(400, streams.FileCreateResponse{Path: req.Path, Error: joinErr.Error()}) return } s.logger.Info("handleFileCreate: creating file", zap.String("path", req.Path), zap.String("repo", req.Repo)) err := s.procMgr.GetWorkspaceTracker().CreateFile(scopedPath) if err != nil { c.JSON(400, streams.FileCreateResponse{ Path: req.Path, Success: false, Error: err.Error(), }) return } s.logger.Info("handleFileCreate: file created successfully", zap.String("path", req.Path)) c.JSON(200, streams.FileCreateResponse{ Path: req.Path, Success: true, }) } // handleFileDelete handles file delete requests via HTTP DELETE. // Optional `repo` query param scopes the delete to a per-repo subdirectory // for multi-repo task workspaces. func (s *Server) handleFileDelete(c *gin.Context) { path := c.Query("path") if path == "" { c.JSON(400, streams.FileDeleteResponse{Error: "path is required"}) return } scopedPath, joinErr := s.procMgr.JoinRepoPath(c.Query("repo"), path) if joinErr != nil { c.JSON(400, streams.FileDeleteResponse{Path: path, Error: joinErr.Error()}) return } err := s.procMgr.GetWorkspaceTracker().DeleteFile(scopedPath) if err != nil { c.JSON(400, streams.FileDeleteResponse{ Path: path, Success: false, Error: err.Error(), }) return } c.JSON(200, streams.FileDeleteResponse{ Path: path, Success: true, }) } // handleFileRename handles file/directory rename requests via HTTP POST. // Optional `Repo` body field scopes BOTH old_path and new_path to a per-repo // subdirectory; cross-repo renames aren't supported (the rename collapses // to a same-repo move). func (s *Server) handleFileRename(c *gin.Context) { var req streams.FileRenameRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, streams.FileRenameResponse{Error: "invalid request: " + err.Error()}) return } if req.OldPath == "" { c.JSON(400, streams.FileRenameResponse{Error: "old_path is required"}) return } if req.NewPath == "" { c.JSON(400, streams.FileRenameResponse{Error: "new_path is required"}) return } scopedOld, oldErr := s.procMgr.JoinRepoPath(req.Repo, req.OldPath) if oldErr != nil { c.JSON(400, streams.FileRenameResponse{OldPath: req.OldPath, NewPath: req.NewPath, Error: oldErr.Error()}) return } scopedNew, newErr := s.procMgr.JoinRepoPath(req.Repo, req.NewPath) if newErr != nil { c.JSON(400, streams.FileRenameResponse{OldPath: req.OldPath, NewPath: req.NewPath, Error: newErr.Error()}) return } err := s.procMgr.GetWorkspaceTracker().RenameFile(scopedOld, scopedNew) if err != nil { c.JSON(400, streams.FileRenameResponse{ OldPath: req.OldPath, NewPath: req.NewPath, Success: false, Error: err.Error(), }) return } c.JSON(200, streams.FileRenameResponse{ OldPath: req.OldPath, NewPath: req.NewPath, Success: true, }) } func (s *Server) handleWorkspaceStreamInput(conn *websocket.Conn, shell io.Writer, done <-chan struct{}) { for { select { case <-done: return default: var msg types.WorkspaceStreamMessage if err := conn.ReadJSON(&msg); err != nil { s.logger.Debug("workspace stream WebSocket read error", zap.Error(err)) return } switch msg.Type { case types.WorkspaceMessageTypeShellInput: if shell != nil { if _, err := shell.Write([]byte(msg.Data)); err != nil { s.logger.Debug("shell write error", zap.Error(err)) } } case types.WorkspaceMessageTypeShellResize: s.logger.Debug("shell resize requested", zap.Int("cols", msg.Cols), zap.Int("rows", msg.Rows)) case types.WorkspaceMessageTypePing: pongMsg := types.NewWorkspacePong() if err := conn.WriteJSON(pongMsg); err != nil { s.logger.Debug("workspace stream pong write error", zap.Error(err)) return } } } } } // mustParseInt parses a string to int, returns 0 on error func mustParseInt(s string) int { var n int if err := json.Unmarshal([]byte(s), &n); err != nil { return 0 } return n }