190 lines
6.1 KiB
Go
190 lines
6.1 KiB
Go
package grpc
|
|
|
|
import (
|
|
"context"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
workspacev1 "enkly/gen/workspace/v1"
|
|
"enkly/pkg/grpcerr"
|
|
)
|
|
|
|
// ignoredWorkspaceDirNames mirrors sandboxfs's own ignoredDirNames
|
|
// (unexported there): dependency/build/VCS directories that are noise
|
|
// in a file *listing* the same way they're noise for the agent's own
|
|
// list_directory/glob_search tools. Display-only — nothing security
|
|
// sensitive depends on it, since every path below still goes through
|
|
// Toolbox.ResolvePath regardless of what Tree skips.
|
|
var ignoredWorkspaceDirNames = map[string]bool{
|
|
".git": true, "node_modules": true, "vendor": true, ".venv": true,
|
|
"venv": true, "__pycache__": true, "dist": true, "build": true,
|
|
".idea": true, ".vscode": true, "target": true, ".next": true,
|
|
}
|
|
|
|
// maxWorkspaceFilesListed caps how many files Tree returns, mirroring
|
|
// internal/handlers/files.go's identical cap for the same reason: a
|
|
// workspace with an enormous tree shouldn't produce an enormous
|
|
// sidebar (or an enormous gRPC response).
|
|
const maxWorkspaceFilesListed = 500
|
|
|
|
// UploadFile replaces Handlers.saveUploadedFile. The gateway has
|
|
// already done every HTTP-specific part (multipart parsing, per-file
|
|
// size limits, CSRF, stripping any directory component the browser
|
|
// supplied) — see services/gateway/internal/handlers/files.go — and
|
|
// hands this RPC just a clean relative destination path and the bytes.
|
|
func (s *Server) UploadFile(ctx context.Context, req *workspacev1.UploadFileRequest) (*workspacev1.UploadFileResponse, error) {
|
|
full, err := s.Toolbox.ResolvePath(req.GetPath())
|
|
if err != nil {
|
|
return nil, grpcerr.InvalidArgument(err.Error())
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
return nil, grpcerr.Internal(err)
|
|
}
|
|
if err := os.WriteFile(full, req.GetContent(), 0o644); err != nil {
|
|
return nil, grpcerr.Internal(err)
|
|
}
|
|
return &workspacev1.UploadFileResponse{BytesWritten: int64(len(req.GetContent()))}, nil
|
|
}
|
|
|
|
// DownloadFile replaces Handlers.DownloadFile's file-reading half (the
|
|
// gateway still owns the actual HTTP streaming/Content-Disposition
|
|
// response).
|
|
func (s *Server) DownloadFile(ctx context.Context, req *workspacev1.DownloadFileRequest) (*workspacev1.DownloadFileResponse, error) {
|
|
full, err := s.Toolbox.ResolvePath(req.GetPath())
|
|
if err != nil {
|
|
return nil, grpcerr.NotFound("file")
|
|
}
|
|
info, err := os.Stat(full)
|
|
if err != nil || info.IsDir() {
|
|
return nil, grpcerr.NotFound("file")
|
|
}
|
|
data, err := os.ReadFile(full)
|
|
if err != nil {
|
|
return nil, grpcerr.Internal(err)
|
|
}
|
|
return &workspacev1.DownloadFileResponse{Content: data, Filename: filepath.Base(full)}, nil
|
|
}
|
|
|
|
// Tree replaces Handlers.listWorkspaceFiles (one_level = false, the
|
|
// default) AND the directory-listing half of Handlers.FileTree
|
|
// (one_level = true) — see TreeRequest's doc comment in the proto.
|
|
func (s *Server) Tree(ctx context.Context, req *workspacev1.TreeRequest) (*workspacev1.TreeResponse, error) {
|
|
if req.GetOneLevel() {
|
|
return s.treeOneLevel(req.GetPath())
|
|
}
|
|
return s.treeRecursive()
|
|
}
|
|
|
|
func (s *Server) treeOneLevel(relDir string) (*workspacev1.TreeResponse, error) {
|
|
full, err := s.Toolbox.ResolvePath(relDir)
|
|
if err != nil {
|
|
return nil, grpcerr.InvalidArgument(err.Error())
|
|
}
|
|
info, err := os.Stat(full)
|
|
if err != nil || !info.IsDir() {
|
|
return nil, grpcerr.NotFound("directory")
|
|
}
|
|
entries, err := os.ReadDir(full)
|
|
if err != nil {
|
|
return nil, grpcerr.Internal(err)
|
|
}
|
|
|
|
var nodes []*workspacev1.TreeNode
|
|
for _, e := range entries {
|
|
if e.Type()&fs.ModeSymlink != 0 {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if e.IsDir() && ignoredWorkspaceDirNames[name] {
|
|
continue
|
|
}
|
|
childRel := name
|
|
if relDir != "" {
|
|
childRel = relDir + "/" + name
|
|
}
|
|
node := &workspacev1.TreeNode{Name: name, RelPath: childRel, IsDir: e.IsDir()}
|
|
if !e.IsDir() {
|
|
if fi, err := e.Info(); err == nil {
|
|
node.Size = fi.Size()
|
|
node.ModifiedAt = timestamppb.New(fi.ModTime())
|
|
}
|
|
}
|
|
nodes = append(nodes, node)
|
|
}
|
|
// Directories first, then files, each alphabetical — matches
|
|
// readTreeChildren's ordering exactly.
|
|
sort.SliceStable(nodes, func(i, j int) bool {
|
|
if nodes[i].IsDir != nodes[j].IsDir {
|
|
return nodes[i].IsDir
|
|
}
|
|
return nodes[i].Name < nodes[j].Name
|
|
})
|
|
if len(nodes) > maxWorkspaceFilesListed {
|
|
nodes = nodes[:maxWorkspaceFilesListed]
|
|
}
|
|
return &workspacev1.TreeResponse{Nodes: nodes}, nil
|
|
}
|
|
|
|
func (s *Server) treeRecursive() (*workspacev1.TreeResponse, error) {
|
|
root := s.Toolbox.WorkspaceRoot
|
|
var nodes []*workspacev1.TreeNode
|
|
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil // skip unreadable entries rather than failing the whole listing
|
|
}
|
|
if p == root {
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
if ignoredWorkspaceDirNames[d.Name()] {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
rel, relErr := filepath.Rel(root, p)
|
|
if relErr != nil {
|
|
return nil
|
|
}
|
|
info, infoErr := d.Info()
|
|
if infoErr != nil {
|
|
return nil
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
nodes = append(nodes, &workspacev1.TreeNode{
|
|
Name: d.Name(), RelPath: rel, IsDir: false, Size: info.Size(),
|
|
ModifiedAt: timestamppb.New(info.ModTime()),
|
|
})
|
|
if len(nodes) >= maxWorkspaceFilesListed {
|
|
return filepath.SkipAll
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, grpcerr.Internal(err)
|
|
}
|
|
sort.Slice(nodes, func(i, j int) bool { return nodes[i].RelPath < nodes[j].RelPath })
|
|
return &workspacev1.TreeResponse{Nodes: nodes}, nil
|
|
}
|
|
|
|
// StatFile replaces the Agent.Toolbox.ResolvePath + os.Stat pairing
|
|
// internal/handlers/deliverables.go used to decide whether a tool call
|
|
// "produced" a downloadable file.
|
|
func (s *Server) StatFile(ctx context.Context, req *workspacev1.StatFileRequest) (*workspacev1.StatFileResponse, error) {
|
|
full, err := s.Toolbox.ResolvePath(req.GetPath())
|
|
if err != nil {
|
|
return &workspacev1.StatFileResponse{Exists: false}, nil
|
|
}
|
|
info, err := os.Stat(full)
|
|
if err != nil {
|
|
return &workspacev1.StatFileResponse{Exists: false}, nil
|
|
}
|
|
return &workspacev1.StatFileResponse{
|
|
Exists: true, IsDir: info.IsDir(), Size: info.Size(),
|
|
ModifiedAt: timestamppb.New(info.ModTime()),
|
|
}, nil
|
|
}
|