Files
kandev/apps/backend/internal/agentctl/server/api/workspace.go
T
Kandev Agent fc9aabcda0 feat(multi-repo): per-repo workspace tracking — Changes panel shows all repos
The agent's workspace tracker is now split per-repository for multi-repo
task roots. Each repo gets its own tracker that polls git independently
and stamps its repository name onto every emitted event. The Changes
panel aggregates files from every repo and groups them under per-repo
headers using the file-tree machinery from Phase 6.

Backend (agentctl):
- GitStatusUpdate / FileListUpdate gain a RepositoryName field. Empty
  for single-repo workspaces (legacy clients see no change).
- WorkspaceTracker carries repositoryName and stamps it onto every
  emitted status/file-list. Two constructors: NewWorkspaceTracker for
  the legacy single-repo path (with the bare-root fallback) and
  NewWorkspaceTrackerForRepo for explicit per-repo trackers.
- Manager scans direct child directories for git repos at construction.
  When 2+ repos are found, builds one tracker per repo subdir on top of
  the root tracker (which stays bound to the bare task root and emits
  nothing useful). Single-repo workspaces are unchanged.
- Manager.SubscribeWorkspaceStream / UnsubscribeWorkspaceStream fan out
  one client subscription across the root + all per-repo trackers via
  AttachWorkspaceStreamSubscriber / DetachWorkspaceStreamSubscriber on
  the tracker. The WS handler switched from the single-tracker subscribe
  to the manager fan-out.
- Tests cover: per-repo trackers spawn for multi-repo task roots,
  single-repo doesn't, both repos emit GitStatusUpdates tagged with
  RepositoryName.

Frontend:
- GitStatusEntry / GitStatusData carry repository_name (optional).
- session-runtime gitStatus.byEnvironmentRepo new map keyed by env id
  then repository name; setGitStatus routes per-repo updates into it
  while still updating the legacy byEnvironmentId for single-repo
  consumers. clearGitStatus drops both maps.
- migrateEnvKeyedData migrates the new per-repo map alongside the
  existing keys when sessionId resolves to an environmentId.
- WS git-status handler forwards repository_name from event.status.
- New useSessionGitStatusByRepo hook returns sorted [{repo, status}]
  pairs for the active session.
- task-changes-panel mergeReviewFiles iterates statusByRepo when
  available, stamping repository_name onto every uncommitted file so
  the Changes panel groups them under per-repo headers (using the
  ReviewFile.repository_name plumbing from Phase 6).
- Tests: per-repo routing, no overwrite on sibling repo update,
  legacy single-repo fallback, clear-on-cleanup.

Verified: pnpm exec vitest run 623/623, eslint --max-warnings 0 clean,
tsc --noEmit clean, make test (backend) clean, make lint 0 issues.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 09:53:28 +01:00

337 lines
9.0 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. 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
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
}