472 lines
14 KiB
Go
472 lines
14 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/kandev/kandev/internal/agentctl/server/process"
|
|
"github.com/kandev/kandev/internal/agentctl/types"
|
|
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// workspaceStreamConn is the single seam onto the workspace stream socket.
|
|
//
|
|
// A gorilla *websocket.Conn supports exactly one concurrent writer, and this
|
|
// endpoint has two producers: the event-forwarding loop and the input loop
|
|
// that answers client pings. Both write through WriteJSON, so the lock lives
|
|
// in one place and a future write site cannot forget it.
|
|
type workspaceStreamConn struct {
|
|
conn *websocket.Conn
|
|
writeMu sync.Mutex
|
|
}
|
|
|
|
// WriteJSON sends one message. Safe for concurrent use.
|
|
func (w *workspaceStreamConn) WriteJSON(msg types.WorkspaceStreamMessage) error {
|
|
w.writeMu.Lock()
|
|
defer w.writeMu.Unlock()
|
|
return w.conn.WriteJSON(msg)
|
|
}
|
|
|
|
// ReadJSON receives one message. Only the input loop reads, so it needs no lock.
|
|
func (w *workspaceStreamConn) ReadJSON(msg *types.WorkspaceStreamMessage) error {
|
|
return w.conn.ReadJSON(msg)
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}()
|
|
|
|
stream := &workspaceStreamConn{conn: conn}
|
|
|
|
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 := stream.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(stream, shellWriter, done)
|
|
|
|
s.forwardWorkspaceStream(stream, sub, shellOutputCh, done)
|
|
}
|
|
|
|
// forwardWorkspaceStream forwards workspace events and shell output to the
|
|
// WebSocket until the client goes away or the handler shuts down.
|
|
func (s *Server) forwardWorkspaceStream(
|
|
stream *workspaceStreamConn,
|
|
sub types.WorkspaceStreamSubscriber,
|
|
shellOutputCh chan []byte,
|
|
done <-chan struct{},
|
|
) {
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case msg, ok := <-sub:
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := stream.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 := stream.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 {
|
|
status := http.StatusBadRequest
|
|
if errors.Is(err, process.ErrFileNotFound) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, 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
|
|
}
|
|
|
|
repo := c.Query("repo")
|
|
if repo == "" && requiresExplicitRepositoryScope(s.procMgr.RepositoryScopes()) {
|
|
c.JSON(400, types.FileContentResponse{Path: path, Error: "repo is required for multi-repo workspace"})
|
|
return
|
|
}
|
|
|
|
tracker, err := s.procMgr.GetWorkspaceTrackerFor(repo)
|
|
if err != nil {
|
|
c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
content, size, isBinary, err := tracker.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
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
results := s.procMgr.SearchWorkspaceFileResults(query, limit)
|
|
files := make([]string, 0, len(results))
|
|
for _, result := range results {
|
|
files = append(files, result.Path)
|
|
}
|
|
|
|
c.JSON(200, types.FileSearchResponse{Files: files, Results: results})
|
|
}
|
|
|
|
// handleWorkspaceContentSearch searches matching lines across every task repository.
|
|
func (s *Server) handleWorkspaceContentSearch(c *gin.Context) {
|
|
query := c.Query("q")
|
|
limit := 20
|
|
if rawLimit := c.Query("limit_per_repo"); rawLimit != "" {
|
|
if parsed := mustParseInt(rawLimit); parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
response, err := s.procMgr.SearchWorkspaceContent(c.Request.Context(), query, limit)
|
|
if err == nil {
|
|
c.JSON(http.StatusOK, response)
|
|
return
|
|
}
|
|
if errors.Is(err, process.ErrContentSearchQueryTooLong) {
|
|
c.JSON(http.StatusBadRequest, types.WorkspaceContentSearchResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, types.WorkspaceContentSearchResponse{Error: err.Error()})
|
|
}
|
|
|
|
// 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(stream *workspaceStreamConn, shell io.Writer, done <-chan struct{}) {
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
default:
|
|
var msg types.WorkspaceStreamMessage
|
|
if err := stream.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 := stream.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
|
|
}
|