yao/workspace/DESIGN.md
Max d25bfefee0 Enhance Sandbox V2 testing and integration with Workspace
- Introduce new unit test targets for Workspace and Sandbox V2 integration in the Makefile, ensuring comprehensive testing of the new features.
- Update CI workflows to support MongoDB service for Sandbox V2 tests and improve Docker image handling.
- Implement Workspace as a first-class entity, allowing for persistent storage decoupled from container lifecycles.
- Enhance the Box struct to manage workspace IDs and update related methods for improved functionality.
- Refactor tests to accommodate new Workspace features, ensuring robust testing of the integration.

These changes significantly improve the testing framework and functionality of the Sandbox V2, enhancing the overall architecture and user experience.
2026-03-05 21:00:50 +08:00

23 KiB
Raw Blame History

Workspace Design Document

Status: Draft Module: workspace (top-level, parallel to sandbox/v2) Depends on: tai SDK (Volume, VolumeProvider, Sandbox), sandbox/v2 Manager


Overview

Workspace is a first-class, persistent storage entity independent of containers, chat sessions, and user sessions. It represents a user's project files — source code, configs, build artifacts — that can be mounted into any number of ephemeral containers.

Workspace is the anchor point for container scheduling: when a Workspace is created on a specific Tai node (host machine), all subsequent containers that reference it are automatically routed to the same node, because bind mounts require co-location on the same physical host.


Problem

Current design: Box.Workspace() returns workspace.FS keyed by box.id — workspace and container are 1:1, same lifecycle. This couples file storage to container lifetime.

Real usage pattern:

User creates a project → uploads files → works on it across multiple chat sessions
  → attaches a long-running dev server → destroys/rebuilds containers freely
  → project files must survive all of this

Workspace must outlive containers. It is the persistent artifact; containers are disposable compute.


Architecture

┌──────────────────────────────────────────────────┐
│                  Application Layer                │
│                                                   │
│  Workspace Management UI        Chat Interface    │
│  ┌─────────────────────┐    ┌─────────────────┐  │
│  │ Create / Delete / UI │    │ Select Workspace│  │
│  │ Browse / Upload     │    │ Start Chat      │  │
│  └─────────┬───────────┘    └────────┬────────┘  │
│            │                         │            │
└────────────┼─────────────────────────┼────────────┘
             │                         │
             ▼                         ▼
┌──────────────────────────────────────────────────┐
│                  Yao Engine                        │
│                                                   │
│  workspace.Manager              sandbox.Manager   │
│  ┌────────────────┐          ┌─────────────────┐  │
│  │ CRUD            │◄────────│ Mount workspace │  │
│  │ File I/O        │         │ Route to node   │  │
│  │ Node binding    │         │ Create container│  │
│  └────────┬───────┘          └────────┬────────┘  │
│           │                           │           │
└───────────┼───────────────────────────┼───────────┘
            │                           │
            ▼                           ▼
┌──────────────────────────────────────────────────┐
│                  Tai Node (Host)                   │
│                                                   │
│  Volume gRPC             Container Runtime        │
│  ┌──────────────┐      ┌─────────────────────┐   │
│  │ ReadFile      │      │ Container A (rw)    │   │
│  │ WriteFile     │      │  └─ /workspace ─┐   │   │
│  │ ListDir       │      │                  │   │   │
│  │ SyncPush/Pull │      │ Container B (ro) │   │   │
│  └──────┬───────┘      │  └─ /workspace ─┐│   │   │
│         │               └────────────────┼┼───┘   │
│         │                                ││       │
│         ▼                                ▼▼       │
│  ┌──────────────────────────────────────────┐     │
│  │  /data/ws/{workspace-id}/                │     │
│  │    ├── .workspace.json  (metadata)       │     │
│  │    ├── src/                              │     │
│  │    ├── package.json                      │     │
│  │    └── ...                               │     │
│  └──────────────────────────────────────────┘     │
│                                                   │
│  VolumeProvider                                   │
│  ┌─────────────┬──────────────┬──────────────┐   │
│  │ BindMount   │ DockerVolume │ K8s PVC      │   │
│  │ (default)   │              │              │   │
│  └─────────────┴──────────────┴──────────────┘   │
└───────────────────────────────────────────────────┘

Core Design

Node Binding

Workspace is physically stored on a Tai node's disk. Bind mount requires Workspace and container to be on the same host. Therefore:

  • Workspace binds to a specific Tai node at creation time. This binding is immutable.
  • When a container references a Workspace (CreateOptions.WorkspaceID), the container is automatically routed to the same Tai node — the caller does not (and should not) specify a Pool.
  • One Tai node = one Pool = one host machine. These are equivalent in the current architecture.
创建 Workspace:
    用户选择节点 "gpu-server" → workspace.Create(opts)
    → Tai "gpu-server" 上创建 /data/ws/ws-123/

创建容器(选了 Workspace:
    → sandbox.Create(opts, WorkspaceID: "ws-123")
    → Manager 查到 ws-123 绑在 "gpu-server"
    → 自动路由到 "gpu-server" Pool
    → bind mount /data/ws/ws-123:/workspace:rw  ✓ 同机

创建容器(没选 Workspace:
    → 按原逻辑选 Pool用户指定或默认

This makes Workspace the scheduling anchor: once a Workspace is chosen, the node is determined.

Workspace struct

type Workspace struct {
    ID        string            // unique identifier, e.g. "ws-abc123"
    Name      string            // human-readable, e.g. "my-react-app"
    Owner     string            // user ID
    Node      string            // Tai node name (= Pool name); set at creation, immutable
    Labels    map[string]string // arbitrary metadata
    CreatedAt time.Time
    UpdatedAt time.Time
}

Node is the critical field: it pins this Workspace to a specific machine. All container operations referencing this Workspace are routed to this node.

No container references stored here. Workspace is pure storage — it doesn't know or care about containers.

MountMode

type MountMode string

const (
    MountRW MountMode = "rw"   // read-write (default)
    MountRO MountMode = "ro"   // read-only
)

Rules:

  • A Workspace can be mounted by multiple containers simultaneously
  • Each mount independently specifies rw or ro
  • No write-lock enforcement — caller manages concurrency
  • Default is rw

Rationale: In practice, Chat containers write source code and Runtime containers write build artifacts/logs — different files, no real conflict. Enforcing locks adds complexity without solving a real problem in this use case.


API Design

workspace.Manager

Workspace has its own manager, separate from sandbox.Manager. It owns Workspace CRUD and file I/O.

package workspace

type Manager struct {
    pools map[string]*tai.Client // node name → tai client (shared with sandbox.Manager)
}

// NewManager creates a workspace manager with the given pools.
// Pools are shared with sandbox.Manager — both reference the same tai.Client instances.
func NewManager(pools map[string]*tai.Client) *Manager

Workspace CRUD

type CreateOptions struct {
    ID     string            // explicit ID; empty = auto-generate (uuid)
    Name   string            // human-readable name
    Owner  string            // user ID
    Node   string            // target Tai node (required)
    Labels map[string]string
}

type ListOptions struct {
    Owner string // filter by owner; empty = all
    Node  string // filter by node; empty = all
}

// Create allocates storage on the target node and persists metadata.
func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, error)

// Get returns a workspace by ID.
// Checks the metadata file on the bound node.
func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error)

// List returns workspaces, optionally filtered.
func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, error)

// Delete removes workspace storage from the node.
// Fails if containers currently mount it (unless force=true).
func (m *Manager) Delete(ctx context.Context, id string, force bool) error

// 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)

type UpdateOptions struct {
    Name   *string            // nil = no change
    Labels map[string]string  // nil = no change; non-nil replaces all
}

File I/O (no container needed)

File operations go through the Tai Volume gRPC service, using the Workspace ID as the session identifier. No container is needed.

// FS returns an fs.FS view of the workspace, backed by Tai Volume gRPC.
func (m *Manager) FS(ctx context.Context, id string) (workspace.FS, error)

// ReadFile reads a file from the workspace.
func (m *Manager) ReadFile(ctx context.Context, id string, path string) ([]byte, error)

// WriteFile writes a file to the workspace.
func (m *Manager) WriteFile(ctx context.Context, id string, path string, data []byte, perm os.FileMode) error

// ListDir lists entries in a workspace directory.
func (m *Manager) ListDir(ctx context.Context, id string, path string) ([]DirEntry, error)

// Remove deletes a file or directory from the workspace.
func (m *Manager) Remove(ctx context.Context, id string, path string) error

// SyncPush uploads a local directory tree to the workspace.
func (m *Manager) SyncPush(ctx context.Context, id string, localPath string) error

// SyncPull downloads the workspace to a local directory.
func (m *Manager) SyncPull(ctx context.Context, id string, localPath string) error

These are thin wrappers around tai.Client.Volume().{ReadFile,WriteFile,ListDir,...} — the Tai SDK already implements all of these.


Integration with Sandbox

sandbox.CreateOptions changes

type CreateOptions struct {
    // ... existing fields ...

    WorkspaceID string    // workspace to mount; empty = no workspace
    MountMode   MountMode // "rw" (default) or "ro"
    MountPath   string    // container path; default "/workspace"
}

Container creation flow

When WorkspaceID is set in CreateOptions, the sandbox Manager:

Manager.Create(ctx, CreateOptions{
    Image:       "yaoapp/workspace:latest",
    WorkspaceID: "ws-abc123",
    MountMode:   MountRW,
})

  1. Validate CreateOptions (image required, etc.)
  2. If WorkspaceID is set:
     a. ws := workspaceManager.Get(ctx, workspaceID)
     b. Force Pool = ws.Node  (override any user-specified Pool)
     c. spec := taiClient.VolumeProvider().MountSpec(workspaceID)
     d. Inject mount into container create:
        - Docker: opts.Binds = ["/data/ws/ws-abc123:/workspace:rw"]
        - K8s:    opts.Volumes + opts.VolumeMounts (PVC)
  3. Create container via tai.Client.Sandbox().Create()
  4. Start container
  5. Return Box

Box.Workspace() behavior change

func (b *Box) Workspace() workspace.FS {
    sessionID := b.workspaceID
    if sessionID == "" {
        sessionID = b.id // backward compatible
    }
    client, _ := b.manager.getPool(b.pool)
    return client.Workspace(sessionID)
}

Multiple boxes mounting the same workspace -> same sessionID -> same files via Volume API.


Metadata Storage

Workspace metadata (ID, Name, Owner, Node, Labels, timestamps) is stored as a JSON file inside the workspace directory.

Storage path

/data/ws/{id}/.workspace.json

Schema

{
  "id": "ws-abc123",
  "name": "my-react-app",
  "owner": "user-001",
  "node": "gpu-server",
  "labels": {"project": "frontend"},
  "created_at": "2026-03-05T10:00:00Z",
  "updated_at": "2026-03-05T12:30:00Z"
}

Operations

Operation Implementation
Create Volume.WriteFile(id, ".workspace.json", json) + Volume.ResolvePath(id)
Get Volume.ReadFile(id, ".workspace.json") → unmarshal
List Volume.ListDir("") → iterate dirs → read .workspace.json each
Update Read → merge → Volume.WriteFile(id, ".workspace.json", json)
Delete Volume.Cleanup(id) (removes entire dir)

Phase 1 strategy: simple JSON files, zero external dependencies. Can migrate to SQLite or Yao's built-in DB if query/filter performance becomes a bottleneck.


Node Management

Listing available nodes

Application layer needs to present available nodes when user creates a Workspace. This comes from the sandbox Manager's pool configuration:

// In workspace.Manager or sandbox.Manager
func (m *Manager) Nodes() []NodeInfo

type NodeInfo struct {
    Name     string // pool name = node name, e.g. "gpu-server"
    Addr     string // tai:// address
    Online   bool   // is tai client connected
    // Can be extended with capacity info later
}

Dynamic node configuration

Nodes are configured at the application level (Yao settings/config). When a node is added or removed, both workspace.Manager and sandbox.Manager share the updated pool map. The Pool configuration API (from sandbox/v2) handles this — Workspace inherits it.

Application Config:
    nodes:
      - name: "local"
        addr: "tai://localhost"
      - name: "gpu-server"
        addr: "tai://192.168.1.100:9527"

→ Both managers share:
    pools["local"]      = tai.Client("tai://localhost")
    pools["gpu-server"] = tai.Client("tai://192.168.1.100:9527")

Node failure handling

If a Tai node goes offline:

  • Workspace CRUD for that node: returns error (node unreachable)
  • Container creation referencing a Workspace on that node: returns error
  • Workspaces on that node are not lost — data is still on the node's disk, will be available when node comes back online
  • No automatic migration (Phase 1). Can add migration (rsync between nodes) later if needed.

User Flows

Flow 1: Workspace management UI

1. User opens Workspace management UI
   → API: workspace.List(owner: "user-001")
   → Returns list of workspaces with metadata

2. User creates workspace
   → UI shows available nodes (from Nodes() API)
   → User selects "gpu-server"
   → API: workspace.Create({ name: "my-project", node: "gpu-server" })
   → Directory /data/ws/ws-123/ created on gpu-server
   → .workspace.json written

3. User uploads files
   → API: workspace.WriteFile("ws-123", "src/main.go", data)
   → File written to /data/ws/ws-123/src/main.go via Volume gRPC

4. User browses files
   → API: workspace.ListDir("ws-123", "src/")
   → Returns file listing

5. User deletes workspace
   → API: workspace.Delete("ws-123")
   → Checks no active mounts → removes /data/ws/ws-123/

Flow 2: Chat with Workspace

1. User opens Chat
   → Chat UI shows workspace selector
   → User picks "my-project" (ws-123, on node "gpu-server")

2. Agent needs a container:
   → sandbox.Create({
       image: "yaoapp/workspace:latest",
       workspace_id: "ws-123",
       mount_mode: "rw",
     })
   → Manager resolves ws-123.node = "gpu-server"
   → Container created on "gpu-server" Pool
   → -v /data/ws/ws-123:/workspace:rw
   → Agent can exec "ls /workspace/src/" inside container

3. Chat ends, container destroyed
   → Workspace files persist in /data/ws/ws-123/

4. User opens new Chat, selects same workspace
   → New container, same workspace, all files still there

Flow 3: Long-running Runtime + Chat

1. User starts Runtime container for workspace:
   → sandbox.Create({
       image: "node:20",
       workspace_id: "ws-123",
       mount_mode: "rw",
       policy: "persistent",
       ports: [{ container: 3000 }],
     })
   → Container starts on "gpu-server"
   → -v /data/ws/ws-123:/workspace:rw
   → Inside: cd /workspace && npm install && npm run dev

2. User accesses dev server via proxy
   → box.Proxy(ctx, 3000, "/")

3. User opens Chat with same workspace:
   → Second container created on "gpu-server"
   → Same workspace mounted
   → Agent modifies source → Runtime hot-reloads

4. Chat ends, chat container destroyed
   → Runtime container keeps running
   → Workspace files persist

Process & JSAPI

Process registration

Process Args Returns
workspace.Create options (CreateOptions JSON) Workspace
workspace.Get id Workspace
workspace.List options (ListOptions JSON) []Workspace
workspace.Update id, options (UpdateOptions JSON) Workspace
workspace.Delete id, force?
workspace.ReadFile id, path file content
workspace.WriteFile id, path, data
workspace.ListDir id, path []DirEntry
workspace.Remove id, path
workspace.Nodes []NodeInfo

JSAPI

// Workspace CRUD
var ws = Workspace.Create({ name: "my-project", node: "gpu-server" })
var ws = Workspace.Get("ws-abc123")
var list = Workspace.List({ owner: "user-001" })
Workspace.Update("ws-abc123", { name: "new-name" })
Workspace.Delete("ws-abc123")

// File operations (no container needed)
var data = Workspace.ReadFile("ws-abc123", "src/main.go")
Workspace.WriteFile("ws-abc123", "src/main.go", "package main\n...")
var entries = Workspace.ListDir("ws-abc123", "src/")
Workspace.Remove("ws-abc123", "tmp.txt")

// List available nodes
var nodes = Workspace.Nodes()
// → [{ name: "local", addr: "tai://localhost", online: true },
//    { name: "gpu-server", addr: "tai://192.168.1.100:9527", online: true }]

// Create container with workspace (via Sandbox API)
var sb = Sandbox("my-box", {
    image: "node:20",
    workspace_id: ws.id,   // → auto-routes to ws.node
    mount_mode: "rw",
})

Storage Backend (Tai)

The storage.VolumeProvider interface in Tai Server already has three implementations:

// tai/storage/provider.go
type VolumeProvider interface {
    ResolvePath(sessionID string) (string, error)
    MountSpec(sessionID string) MountConfig
    Cleanup(sessionID string) error
}

type MountConfig struct {
    Type   string // "bind" | "volume" | "pvc"
    Source string
    Target string // always /workspace
}
Provider Backend MountSpec Status
BindMountProvider Host directory (/data/ws/{id}/) type:"bind" Implemented, default
DockerVolumeProvider Docker named volume (tai-{id}) type:"volume" Implemented
K8sPVCProvider K8s PVC (tai-{id}-pvc, 10Gi RWO) type:"pvc" Implemented

Default is BindMountProvider for Docker environments (direct host path access for file CRUD). K8s environments use K8sPVCProvider.

The Tai Volume gRPC service (ReadFile, WriteFile, ListDir, etc.) already operates on the same dataDir/{sessionID}/ paths. No additional work needed — Workspace file operations reuse existing Volume gRPC endpoints.


Comparison: Before vs After

Aspect Before After
Workspace lifecycle Tied to Box (same ID, same lifetime) Independent entity, outlives containers
Workspace identity sessionID = box.id sessionID = workspace.id (explicit)
Container ↔ Workspace 1:1, implicit N:1, explicit via CreateOptions.WorkspaceID
Container scheduling User picks Pool Workspace determines Pool (node binding)
File persistence Lost when container removed Persists until workspace deleted
Multi-container access Not possible Multiple containers mount same workspace
Storage backend Volume gRPC only (no mount) Volume gRPC + bind mount into container
CRUD without container Not possible Via Volume API directly
Module status Part of sandbox/v2 Top-level module, parallel to sandbox/v2

Implementation Plan

Phase 1: Core (target: week 1-2)

Task Detail
workspace/workspace.go Workspace struct, MountMode, CreateOptions, metadata JSON read/write
workspace/manager.go Manager with CRUD + file I/O (thin wrapper over tai Volume)
workspace/manager_test.go Unit tests for CRUD and file operations
Node binding Workspace.Node field, Nodes() API
sandbox/v2 integration CreateOptions.WorkspaceID → resolve node → force Pool → inject mount
Box.Workspace() update Use workspaceID as sessionID when set

Phase 2: Wire into Tai (target: week 2-3)

Task Detail
Tai Server: VolumeProvider.MountSpec() Wire into container creation path
Tai gRPC: workspace metadata endpoints Optional — can use Volume gRPC directly for Phase 1
Process + JSAPI registration workspace.* processes, JS bindings

Phase 3: Advanced (target: week 3+)

Task Detail
Active mount tracking Track which containers mount which workspaces
Delete safety Refuse delete if active mounts exist
Workspace migration rsync between nodes (stretch goal)
Quota / size limits Per-workspace storage limits
Snapshot / backup Workspace snapshots for rollback

Backward Compatibility

No breaking changes. Containers created without WorkspaceID work exactly as before:

  • sessionID = box.id
  • No bind mount
  • Workspace FS backed by Volume gRPC as today