Files
enkly-ai/services/workspace/internal/interfaces/grpc/files.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

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
}