Files
kandev/apps/backend/internal/agentctl/server/api/workspace.go
T

334 lines
8.8 KiB
Go

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
sub := s.procMgr.GetWorkspaceTracker().SubscribeWorkspaceStream()
defer s.procMgr.GetWorkspaceTracker().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
func (s *Server) handleFileContent(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(400, types.FileContentResponse{Error: "path is required"})
return
}
content, size, isBinary, resolvedPath, err := s.procMgr.GetWorkspaceTracker().GetFileContent(path)
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, ResolvedPath: resolvedPath})
}
// handleFileContentAtRef handles file content requests at a specific git ref via HTTP GET
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
}
content, size, isBinary, err := s.procMgr.GetWorkspaceTracker().GetFileContentAtRef(c.Request.Context(), path, 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
}
// Apply the diff
newHash, resolution, err := s.procMgr.GetWorkspaceTracker().ApplyFileDiff(req.Path, 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
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
}
s.logger.Info("handleFileCreate: creating file", zap.String("path", req.Path))
err := s.procMgr.GetWorkspaceTracker().CreateFile(req.Path)
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
func (s *Server) handleFileDelete(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(400, streams.FileDeleteResponse{Error: "path is required"})
return
}
err := s.procMgr.GetWorkspaceTracker().DeleteFile(path)
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
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
}
err := s.procMgr.GetWorkspaceTracker().RenameFile(req.OldPath, req.NewPath)
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
}