yao/workspace/manager.go
Max ce0a97c0af feat(sandbox/v2): refactor benchmarks and tests to use TaiID
- Updated benchmark functions to utilize TaiID instead of pool names for improved consistency and accuracy in tests.
- Refactored test cases across various files to ensure compatibility with the new TaiID structure.
- Enhanced setup functions to accept pointers to poolConfig for better memory management.
- Removed deprecated config struct and adjusted related documentation to reflect the changes in the sandbox architecture.

Made-with: Cursor
2026-03-09 02:50:28 +08:00

331 lines
7.9 KiB
Go

package workspace
import (
"context"
"fmt"
"os"
"time"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
"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.
// All node/client lookups go through tai.GetClient → registry.
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
}
client, ok := tai.GetClient(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 := client.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.
// Scans all registered nodes.
func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) {
for _, snap := range listNodes() {
client, ok := tai.GetClient(snap.TaiID)
if !ok {
continue
}
ws, err := readMeta(ctx, client, 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
}
client, ok := tai.GetClient(snap.TaiID)
if !ok {
continue
}
entries, err := client.Volume().ListDir(ctx, "", ".")
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir {
continue
}
ws, err := readMeta(ctx, client, 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).
// Node and Owner are immutable after creation.
func (m *Manager) Update(ctx context.Context, id string, opts UpdateOptions) (*Workspace, error) {
ws, client, 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 := client.Volume().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 {
_, client, err := m.resolve(ctx, id)
if err != nil {
return err
}
vol := client.Volume()
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) {
_, client, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
return client.Workspace(id), nil
}
// ReadFile reads a file from the workspace.
func (m *Manager) ReadFile(ctx context.Context, id string, path string) ([]byte, error) {
_, client, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
data, _, err := client.Volume().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 {
_, client, err := m.resolve(ctx, id)
if err != nil {
return err
}
return client.Volume().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) {
_, client, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
entries, err := client.Volume().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 {
_, client, err := m.resolve(ctx, id)
if err != nil {
return err
}
return client.Volume().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 {
_, client, err := m.resolve(ctx, id)
if err != nil {
return err
}
return client.Volume().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 {
_, client, err := m.resolve(ctx, id)
if err != nil {
return err
}
return client.Volume().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) {
_, client, err := m.resolve(ctx, id)
if err != nil {
return nil, "", err
}
return client.Volume(), id, nil
}
// NodeForWorkspace returns the node name for a given workspace ID.
// Used by sandbox.Manager to route container creation to the correct pool.
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,
// suitable for use as a Docker bind mount source.
func (m *Manager) MountPath(ctx context.Context, id string) (string, error) {
_, client, err := m.resolve(ctx, id)
if err != nil {
return "", err
}
dataDir := client.DataDir()
if dataDir == "" {
return "", nil
}
return dataDir + "/" + id, nil
}
// --- internal ---
// resolve finds the workspace and its tai.Client by scanning all registered nodes.
func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, *tai.Client, error) {
for _, snap := range listNodes() {
client, ok := tai.GetClient(snap.TaiID)
if !ok {
continue
}
ws, err := readMeta(ctx, client, id)
if err != nil {
continue
}
return ws, client, nil
}
return nil, nil, ErrNotFound
}
func readMeta(ctx context.Context, client *tai.Client, id string) (*Workspace, error) {
data, _, err := client.Volume().ReadFile(ctx, id, metadataFile)
if err != nil {
return nil, err
}
return unmarshalMeta(data)
}
func listNodes() []registry.NodeSnapshot {
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"`
}