- 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.
600 lines
23 KiB
Markdown
600 lines
23 KiB
Markdown
# 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
|
||
|
||
```go
|
||
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
|
||
|
||
```go
|
||
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.
|
||
|
||
```go
|
||
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
|
||
|
||
```go
|
||
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.
|
||
|
||
```go
|
||
// 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
|
||
|
||
```go
|
||
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
|
||
|
||
```go
|
||
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
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```go
|
||
// 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
|
||
|
||
```javascript
|
||
// 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:
|
||
|
||
```go
|
||
// 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
|