fc9aabcda0
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>
337 lines
9.0 KiB
Go
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
|
|
}
|