yao/workspace/manager.go
Max 82bb44cbda feat(workspace): add root directory retrieval for workspaces
- Implemented a new endpoint to retrieve the absolute path of a workspace's root directory.
- Enhanced the workspace interface with a GetRoot method to facilitate this functionality.
- Updated the workspace manager to utilize the new method for improved path resolution.

Made-with: Cursor
2026-03-13 18:25:14 +08:00

320 lines
7.5 KiB
Go

package workspace
import (
"context"
"fmt"
"os"
"time"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
taitypes "github.com/yaoapp/yao/tai/types"
"github.com/yaoapp/yao/tai/volume"
taiworkspace "github.com/yaoapp/yao/tai/workspace"
)
var mgr = NewManager()
// M returns the global Manager.
func M() *Manager {
return mgr
}
// Manager owns workspace CRUD, file I/O, and node management.
type Manager struct{}
// NewManager creates a workspace manager.
func NewManager() *Manager {
return &Manager{}
}
// Create allocates storage on the target node and persists metadata.
func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, error) {
if opts.Node == "" {
return nil, ErrNodeMissing
}
res, ok := tai.GetResources(opts.Node)
if !ok {
return nil, ErrNodeOffline
}
id := opts.ID
if id == "" {
id = generateID()
}
now := time.Now().UTC()
ws := &Workspace{
ID: id,
Name: opts.Name,
Owner: opts.Owner,
Node: opts.Node,
Labels: opts.Labels,
CreatedAt: now,
UpdatedAt: now,
}
vol := res.Volume
if err := vol.MkdirAll(ctx, id, "."); err != nil {
return nil, fmt.Errorf("workspace: create directory: %w", err)
}
data, err := marshalMeta(ws)
if err != nil {
return nil, err
}
if err := vol.WriteFile(ctx, id, metadataFile, data, 0644); err != nil {
return nil, fmt.Errorf("workspace: write metadata: %w", err)
}
return ws, nil
}
// Get returns a workspace by ID.
func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) {
for _, snap := range listNodes() {
res, ok := tai.GetResources(snap.TaiID)
if !ok {
continue
}
ws, err := readMeta(ctx, res.Volume, id)
if err != nil {
continue
}
if ws.Node == "" {
ws.Node = snap.TaiID
}
return ws, nil
}
return nil, ErrNotFound
}
// List returns workspaces, optionally filtered by owner and/or node.
func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, error) {
var result []*Workspace
for _, snap := range listNodes() {
if opts.Node != "" && snap.TaiID != opts.Node {
continue
}
res, ok := tai.GetResources(snap.TaiID)
if !ok {
continue
}
entries, err := res.Volume.ListDir(ctx, "", ".")
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir {
continue
}
ws, err := readMeta(ctx, res.Volume, e.Path)
if err != nil {
continue
}
if ws.Node == "" {
ws.Node = snap.TaiID
}
if opts.Owner != "" && ws.Owner != opts.Owner {
continue
}
result = append(result, ws)
}
}
return result, nil
}
// Update modifies workspace metadata (Name, Labels).
func (m *Manager) Update(ctx context.Context, id string, opts UpdateOptions) (*Workspace, error) {
ws, vol, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
if opts.Name != nil {
ws.Name = *opts.Name
}
if opts.Labels != nil {
ws.Labels = opts.Labels
}
ws.UpdatedAt = time.Now().UTC()
data, err := marshalMeta(ws)
if err != nil {
return nil, err
}
if err := vol.WriteFile(ctx, id, metadataFile, data, 0644); err != nil {
return nil, fmt.Errorf("workspace: write metadata: %w", err)
}
return ws, nil
}
// Delete removes workspace storage from the node.
func (m *Manager) Delete(ctx context.Context, id string, force bool) error {
_, vol, err := m.resolve(ctx, id)
if err != nil {
return err
}
if err := vol.Remove(ctx, id, ".", true); err != nil {
return fmt.Errorf("workspace: remove: %w", err)
}
return nil
}
// Nodes returns all registered Tai nodes with their online status.
func (m *Manager) Nodes() []NodeInfo {
nodes := listNodes()
result := make([]NodeInfo, 0, len(nodes))
for _, snap := range nodes {
result = append(result, NodeInfo{
Name: snap.TaiID,
Online: snap.Status == "online" || snap.Status == "",
})
}
return result
}
// FS returns an fs.FS-compatible filesystem for the given workspace.
func (m *Manager) FS(ctx context.Context, id string) (taiworkspace.FS, error) {
_, vol, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
return taiworkspace.New(vol, id), nil
}
// ReadFile reads a file from the workspace.
func (m *Manager) ReadFile(ctx context.Context, id string, path string) ([]byte, error) {
_, vol, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
data, _, err := vol.ReadFile(ctx, id, path)
return data, err
}
// WriteFile writes a file to the workspace.
func (m *Manager) WriteFile(ctx context.Context, id string, path string, data []byte, perm os.FileMode) error {
_, vol, err := m.resolve(ctx, id)
if err != nil {
return err
}
return vol.WriteFile(ctx, id, path, data, perm)
}
// ListDir lists entries in a workspace directory.
func (m *Manager) ListDir(ctx context.Context, id string, path string) ([]DirEntry, error) {
_, vol, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
entries, err := vol.ListDir(ctx, id, path)
if err != nil {
return nil, err
}
result := make([]DirEntry, len(entries))
for i, e := range entries {
result[i] = DirEntry{
Name: e.Path,
IsDir: e.IsDir,
Size: e.Size,
}
}
return result, nil
}
// Remove deletes a file or directory from the workspace.
func (m *Manager) Remove(ctx context.Context, id string, path string) error {
_, vol, err := m.resolve(ctx, id)
if err != nil {
return err
}
return vol.Remove(ctx, id, path, true)
}
// Rename renames a file or directory within the workspace.
func (m *Manager) Rename(ctx context.Context, id string, oldPath, newPath string) error {
_, vol, err := m.resolve(ctx, id)
if err != nil {
return err
}
return vol.Rename(ctx, id, oldPath, newPath)
}
// MkdirAll creates a directory (and parents) in the workspace.
func (m *Manager) MkdirAll(ctx context.Context, id string, path string) error {
_, vol, err := m.resolve(ctx, id)
if err != nil {
return err
}
return vol.MkdirAll(ctx, id, path)
}
// Volume returns the Volume interface for the node hosting the given workspace.
func (m *Manager) Volume(ctx context.Context, id string) (volume.Volume, string, error) {
_, vol, err := m.resolve(ctx, id)
if err != nil {
return nil, "", err
}
return vol, id, nil
}
// NodeForWorkspace returns the node name for a given workspace ID.
func (m *Manager) NodeForWorkspace(ctx context.Context, id string) (string, error) {
ws, _, err := m.resolve(ctx, id)
if err != nil {
return "", err
}
return ws.Node, nil
}
// MountPath returns the host-side directory path for a workspace.
func (m *Manager) MountPath(ctx context.Context, id string) (string, error) {
_, vol, err := m.resolve(ctx, id)
if err != nil {
return "", err
}
return vol.Abs(ctx, id, ".")
}
// --- internal ---
// resolve finds the workspace and its Volume by scanning all registered nodes.
func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, volume.Volume, error) {
for _, snap := range listNodes() {
res, ok := tai.GetResources(snap.TaiID)
if !ok {
continue
}
ws, err := readMeta(ctx, res.Volume, id)
if err != nil {
continue
}
return ws, res.Volume, nil
}
return nil, nil, ErrNotFound
}
func readMeta(ctx context.Context, vol volume.Volume, id string) (*Workspace, error) {
data, _, err := vol.ReadFile(ctx, id, metadataFile)
if err != nil {
return nil, err
}
return unmarshalMeta(data)
}
func listNodes() []taitypes.NodeMeta {
reg := registry.Global()
if reg == nil {
return nil
}
return reg.List()
}
// DirEntry represents a file or directory entry in a workspace listing.
type DirEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
}