diff --git a/sandbox/v2/DESIGN.md b/sandbox/v2/DESIGN.md index accf1461..15abb2f5 100644 --- a/sandbox/v2/DESIGN.md +++ b/sandbox/v2/DESIGN.md @@ -10,6 +10,7 @@ Yao Infrastructure ├── store — KV storage ├── fs — host filesystem ├── stream — streaming execution (planned) +├── workspace — persistent user storage ← new in V2 └── sandbox — isolated execution environments ← this module ``` @@ -31,14 +32,18 @@ Sandbox does NOT import or depend on Agent. Agent is one of many consumers. │ sandbox/v2 │ │ │ │ Manager (global singleton) │ -│ ├── Create / Get / Start / Stop / Remove │ -│ ├── List / Cleanup / Close │ +│ ├── Create / Get / GetOrCreate / List / Remove │ +│ ├── Start / Cleanup / Close │ +│ ├── Heartbeat (idle tracking) │ +│ ├── AddPool / RemovePool / Pools │ +│ ├── SetWorkspaceManager (workspace integration)│ +│ ├── EnsureImage / ImageExists / PullImage │ │ └── guard rails (limits, TTL) + Box factory │ │ │ │ Box (per-instance) │ │ ├── Exec(cmd) → ExecResult │ │ ├── Stream(cmd) → ExecStream (real-time I/O) │ -│ ├── Attach(port) → ServiceConn (WS/SSE/TCP) │ +│ ├── Attach(port) → ServiceConn (WS/SSE) │ │ ├── Workspace() → workspace.FS │ │ ├── VNC() → url │ │ ├── Proxy(port) → url │ @@ -50,11 +55,12 @@ Sandbox does NOT import or depend on Agent. Agent is one of many consumers. │ tai.Client pool (lazy-initialized) │ │ ├── "local" → tai.New("local") (Docker) │ │ ├── "gpu" → tai.New("tai://gpu") (Remote) │ -│ ├── "k8s" → tai.New("tai://k8s") (K8s) │ +│ ├── "k8s" → tai.New("tai://k8s",K8s)(K8s) │ │ └── ... │ │ │ │ Each tai.Client provides: │ │ ├── Sandbox() → CRUD + Exec + ExecStream │ +│ ├── Image() → Exists + Pull + Remove + List│ │ ├── Volume() → file I/O (local disk / gRPC) │ │ ├── Workspace() → fs.FS │ │ ├── Proxy() → URL resolve + Connect │ @@ -66,6 +72,7 @@ Sandbox does NOT import or depend on Agent. Agent is one of many consumers. ``` sandbox/v2 → tai ✓ (sole runtime dependency) +sandbox/v2 → workspace ✓ (workspace integration, optional) sandbox/v2 → agent ✗ NEVER sandbox/v2 → docker ✗ NEVER (tai handles it) agent → sandbox/v2 ✓ (consumer, via Manager API) @@ -80,19 +87,15 @@ Global singleton. Manages a **pool of named `tai.Client` connections** — each ### Pool ```go -// Pool defines a named tai.Client endpoint with its own policy. type Pool struct { - Name string // unique name, e.g. "local", "gpu", "k8s-prod" - Addr string // tai.New() address: "local", "tai://host", "docker:///path" - Options []tai.Option // tai.WithPorts(), tai.WithKubeConfig(), etc. - - // Guard rails (per-pool) + Name string + Addr string // tai.New() address: "local", "tai://host", "docker:///path" + Options []tai.Option // tai.K8s, tai.WithKubeConfig(), tai.WithPorts(), etc. MaxPerUser int // max boxes per user on this pool, 0 = unlimited MaxTotal int // max boxes total on this pool, 0 = unlimited - - // Default lifecycle (overridable per-box via CreateOptions) IdleTimeout time.Duration // 0 = no timeout MaxLifetime time.Duration // 0 = no limit + StopTimeout time.Duration // SIGTERM grace period before SIGKILL; 0 = DefaultStopTimeout (2s) } ``` @@ -107,9 +110,9 @@ pool: - name: gpu addr: "tai://gpu-server.internal" - max_per_user: 1 # GPU is expensive, 1 per user + max_per_user: 1 max_total: 4 - idle_timeout: 10m # reclaim fast + idle_timeout: 10m max_lifetime: 2h - name: k8s @@ -124,18 +127,10 @@ pool: ### Initialization ```go -package sandbox - var mgr *Manager -// Init initializes the global Manager. -// Config contains everything: pool definitions + guard rails. -// At least one Pool entry is required. The first entry is the default. -// Pass empty Pool list to disable sandbox (methods return ErrNotAvailable). -func Init(cfg Config) error - -// M returns the global Manager. Panics if Init was not called. -func M() *Manager +func Init(cfg Config) error // create Manager from Config; at least one Pool required +func M() *Manager // return global singleton; panics if Init not called ``` Startup sequence in `cmd/start.go`: @@ -150,114 +145,99 @@ grpc.Start // gRPC sandbox.M().Start(ctx) // discover existing containers, start cleanup loop ``` -`Init` creates the Manager from config (pool definitions + guard rails). `Start` connects to pools, discovers existing containers, and starts the cleanup loop. Two-step so that gRPC server is ready before Start (containers may send heartbeats immediately). +`Init` creates the Manager from config (pool definitions + guard rails). `Start` connects to pools, discovers existing containers, and starts the cleanup loop. Two-step so that gRPC server is ready before Start. Pool connections are created lazily on first use and reused across all Box instances. ### Config -Pool definitions only. Guard rails and lifecycle defaults are per-pool. Per-instance settings (image, memory, workdir, etc.) are in `CreateOptions`. - ```go type Config struct { - Pool []Pool // runtime endpoints; first is default + Pool []Pool } ``` -Container gRPC env vars (`YAO_GRPC_ADDR`, `YAO_GRPC_UPSTREAM`, etc.) are derived automatically at creation time — local address from Yao's gRPC config (`config.Conf.GRPC`), remote relay from pool's tai address. No manual configuration needed. - -Per-instance settings (image, memory, CPU, workdir, env, pool) are passed via `CreateOptions` by the caller — assistant config, JSAPI parameters, or Process arguments. The Manager doesn't impose defaults for container specs; that's the caller's responsibility. +Container gRPC env vars (`YAO_GRPC_ADDR`, `YAO_GRPC_UPSTREAM`, etc.) are derived automatically at creation time. Per-instance settings (image, memory, CPU, workdir, env, pool) are passed via `CreateOptions`. ### Core API ```go type Manager struct { - pool map[string]*tai.Client // name → connection (lazy-initialized) - poolDefs []Pool // pool definitions - defaultPool string // first pool name - config Config - boxes sync.Map // id → *Box - mu sync.Mutex // creation serialization + pool map[string]*tai.Client // name → connection (lazy-initialized) + poolDefs []Pool + defaultPool string // first pool name + config Config + boxes sync.Map // id → *Box + mu sync.Mutex + cancel context.CancelFunc + grpcPort int + wsManager *workspace.Manager // optional workspace integration } // --- Bootstrap --- - -// Start discovers existing containers from all pools, rebuilds the boxes map, -// and starts the cleanup loop. Called once after Init. func (m *Manager) Start(ctx context.Context) error +func (m *Manager) Close() error +func (m *Manager) SetGRPCPort(port int) +func (m *Manager) SetWorkspaceManager(wm *workspace.Manager) // --- Pool management --- - -// AddPool registers a new pool at runtime. Connects lazily on first use. func (m *Manager) AddPool(ctx context.Context, p Pool) error - -// RemovePool removes a pool by name. Fails if any running boxes are on it. -// Use force=true to stop all boxes on the pool first, then remove. func (m *Manager) RemovePool(ctx context.Context, name string, force bool) error - -// Pools returns all registered pool names and their status (connected/disconnected). func (m *Manager) Pools() []PoolInfo -// --- Heartbeat (called by gRPC handler, not by consumers) --- - -// Heartbeat updates the box's last heartbeat timestamp. -// Called by the gRPC Heartbeat handler when a container reports in. -// Returns ErrNotFound if sandbox_id is unknown (container orphaned or already removed). +// --- Heartbeat --- func (m *Manager) Heartbeat(sandboxID string, active bool, processCount int) error // --- CRUD --- - -// Create creates and starts a new sandbox. func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) - -// Get returns an existing sandbox by ID. Returns ErrNotFound if not exists. func (m *Manager) Get(ctx context.Context, id string) (*Box, error) - -// GetOrCreate returns existing sandbox or creates a new one. func (m *Manager) GetOrCreate(ctx context.Context, opts CreateOptions) (*Box, error) - -// List returns all sandboxes, optionally filtered. func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Box, error) - -// Remove stops and removes a sandbox. func (m *Manager) Remove(ctx context.Context, id string) error - -// Cleanup removes idle/expired sandboxes. Called periodically. func (m *Manager) Cleanup(ctx context.Context) error -// Close stops the cleanup loop and releases all pool connections. -func (m *Manager) Close() error +// --- Image management --- +func (m *Manager) ImageExists(ctx context.Context, pool, ref string) (bool, error) +func (m *Manager) PullImage(ctx context.Context, pool, ref string, opts ImagePullOptions) (<-chan PullProgress, error) +func (m *Manager) EnsureImage(ctx context.Context, pool, ref string, opts ImagePullOptions) error ``` ### CreateOptions -All per-instance settings live here. Caller decides everything about the container. - ```go type CreateOptions struct { - // Identity - ID string // explicit ID; empty = auto-generate - Owner string // user ID for isolation and limits - Labels map[string]string - - // Runtime target - Pool string // which tai.Client to use; empty = default pool + ID string + Owner string + Labels map[string]string + Pool string // which tai.Client to use; empty = default pool // Container spec - Image string // required - WorkDir string // container working directory, default "/workspace" - User string // container user - Env map[string]string // additional env vars - Memory int64 // bytes, 0 = no limit - CPUs float64 // 0 = no limit - VNC bool // enable VNC - Ports []PortMapping // extra port mappings + Image string // required + WorkDir string // default "/workspace" + User string + Env map[string]string + Memory int64 // bytes, 0 = no limit + CPUs float64 // 0 = no limit + VNC bool + Ports []PortMapping // Lifecycle - Policy LifecyclePolicy // default: Session - IdleTimeout time.Duration // override Manager default; 0 = use Manager default -} + Policy LifecyclePolicy // default: Session + IdleTimeout time.Duration // override pool default; 0 = use pool default + StopTimeout time.Duration // SIGTERM grace period; 0 = pool default or DefaultStopTimeout + // Workspace integration + WorkspaceID string // workspace to mount; empty = no workspace + MountMode string // "rw" (default) or "ro" + MountPath string // container path; default "/workspace" +} +``` + +When `WorkspaceID` is set, the Manager resolves the workspace's bound node via `workspace.Manager.NodeForWorkspace()` and forces the container onto that node. The workspace directory is bind-mounted into the container at `MountPath`. + +### LifecyclePolicy + +```go type LifecyclePolicy string const ( @@ -266,6 +246,8 @@ const ( LongRunning LifecyclePolicy = "longrunning" // user workspace, extended TTL Persistent LifecyclePolicy = "persistent" // never auto-cleaned ) + +const DefaultStopTimeout = 2 * time.Second ``` ## Box @@ -276,71 +258,51 @@ A `Box` is a single sandbox instance. All operations go through it. type Box struct { id string containerID string - pool string // which tai.Client this box runs on + pool string owner string policy LifecyclePolicy labels map[string]string - lastCall atomic.Int64 // last external API call (Exec/Workspace/VNC/Proxy) - lastHeartbeat atomic.Int64 // last container heartbeat - processCount atomic.Int32 // user processes inside container (from heartbeat) - ws workspace.FS // lazy-initialized, cached + lastCall atomic.Int64 // last external API call + lastHeartbeat atomic.Int64 // last container heartbeat + processCount atomic.Int32 // user processes inside container + idleTimeoutD time.Duration + stopTimeoutD time.Duration + createdAt time.Time + refreshToken string + vnc bool + image string + workspaceID string + ws workspace.FS // lazy-initialized, cached manager *Manager } -// lastActiveTime returns max(lastCall, lastHeartbeat). -func (b *Box) lastActiveTime() time.Time - -// ID returns the sandbox identifier. +// --- Identity --- func (b *Box) ID() string - -// Owner returns the user who owns this sandbox. func (b *Box) Owner() string - -// ContainerID returns the underlying container ID. func (b *Box) ContainerID() string +func (b *Box) Pool() string +func (b *Box) WorkspaceID() string // --- Execution --- - -// Exec runs a command and waits for it to finish. func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) - -// Stream runs a command with real-time streaming I/O. func (b *Box) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error) - -// Attach connects to a service running inside the sandbox on the given container port. func (b *Box) Attach(ctx context.Context, port int, opts ...AttachOption) (*ServiceConn, error) -// --- Filesystem (fs.FS compatible) --- - -// Workspace returns an fs.FS-compatible filesystem for this sandbox. -// Supports: Open, Stat, ReadFile, ReadDir, WriteFile, Remove, Rename, MkdirAll. -// Internally calls tai.Client.Workspace(box.id) — uses sandbox ID as volume session. +// --- Filesystem --- func (b *Box) Workspace() workspace.FS // --- Network --- - -// VNC returns the VNC WebSocket URL. Error if VNC not enabled. func (b *Box) VNC(ctx context.Context) (string, error) - -// Proxy returns the HTTP URL for a service running on the given port inside the sandbox. func (b *Box) Proxy(ctx context.Context, port int, path string) (string, error) // --- Lifecycle --- - -// Start starts a stopped sandbox. func (b *Box) Start(ctx context.Context) error - -// Stop stops the sandbox without removing it. func (b *Box) Stop(ctx context.Context) error - -// Remove stops and removes the sandbox. func (b *Box) Remove(ctx context.Context) error - -// Info returns current sandbox status. func (b *Box) Info(ctx context.Context) (*BoxInfo, error) ``` -### ExecOption / ExecResult +### ExecOption / ExecResult / ExecStream ```go type ExecOption func(*execConfig) @@ -354,87 +316,75 @@ type ExecResult struct { Stdout string Stderr string } -``` -### ExecStream - -```go type ExecStream struct { - Stdout io.ReadCloser // real-time stdout - Stderr io.ReadCloser // real-time stderr - Stdin io.WriteCloser // write to process stdin (nil if not interactive) + Stdout io.ReadCloser + Stderr io.ReadCloser + Stdin io.WriteCloser Wait func() (int, error) // block until exit, return exit code Cancel func() // kill the process } ``` -Usage: - -```go -// Interactive CLI (e.g. Claude) -s, _ := box.Stream(ctx, []string{"claude", "--chat"}) -go io.Copy(os.Stdout, s.Stdout) -s.Stdin.Write([]byte("help\n")) -code, _ := s.Wait() - -// Long-running process (e.g. dev server) -s, _ := box.Stream(ctx, []string{"npm", "run", "dev"}) -go io.Copy(logWriter, s.Stdout) // continuous output -// ... later -s.Cancel() -``` - ### AttachOption / ServiceConn ```go type AttachOption func(*attachConfig) -func WithProtocol(proto string) AttachOption // "ws", "sse", "tcp"; default "ws" -func WithPath(path string) AttachOption // URL path, e.g. "/v1/chat" +func WithProtocol(proto string) AttachOption // "ws", "sse"; default "ws" +func WithPath(path string) AttachOption func WithHeaders(h map[string]string) AttachOption type ServiceConn struct { - // Bidirectional (WebSocket, TCP) - Read func() ([]byte, error) - Write func(data []byte) error - - // Server-push (SSE) - Events <-chan []byte // nil if not SSE mode - - // Common - URL string // resolved URL for reference - Close func() error + Read func() ([]byte, error) // read next message (WS mode) + Write func(data []byte) error + Events <-chan []byte // SSE event channel + URL string + Close func() error } ``` -`port` is the port the service listens on **inside the container** (e.g. 3000 for a Node server). Routing to that port — Docker port mapping (local) or Tai HTTP proxy (remote) — is handled internally. +`port` is the port the service listens on **inside the container**. Routing — Docker port mapping (local) or Tai HTTP proxy (remote) — is handled internally. -**Local mode caveat**: `tai/proxy.NewLocal` resolves host ports via `Inspect()` → `PortMapping`. The container must have the port mapped at creation time (`CreateOptions.Ports`). If the port was not mapped, `Proxy()` and `Attach()` return an error. Remote mode has no such restriction — Tai HTTP proxy routes by container IP directly. - -Usage: +### Image Management ```go -// WebSocket — connect to Cursor Server inside sandbox -conn, _ := box.Attach(ctx, 3000, WithProtocol("ws"), WithPath("/ws")) -conn.Write([]byte(`{"type":"edit","file":"main.go"}`)) -msg, _ := conn.Read() -conn.Close() +type ImagePullOptions struct { + Auth *RegistryAuth +} -// SSE — connect to Claude API inside sandbox -conn, _ := box.Attach(ctx, 8080, WithProtocol("sse"), WithPath("/v1/messages")) -for event := range conn.Events { - fmt.Println(string(event)) +type RegistryAuth struct { + Username string + Password string + Server string } ``` -### PoolInfo +`EnsureImage` first checks `ImageExists`; if not present, calls `PullImage` and blocks until complete. For K8s pools this is a no-op — kubelet manages image pulling natively via `imagePullPolicy`. + +### BoxInfo / PoolInfo ```go +type BoxInfo struct { + ID string + ContainerID string + Pool string + Owner string + Status string // "running", "stopped", "creating" + Policy LifecyclePolicy + Labels map[string]string + Image string + CreatedAt time.Time + LastActive time.Time + ProcessCount int + VNC bool +} + type PoolInfo struct { - Name string // pool name - Addr string // tai address - Connected bool // tai.Client connection established - Boxes int // number of boxes on this pool + Name string + Addr string + Connected bool + Boxes int MaxPerUser int MaxTotal int IdleTimeout time.Duration @@ -442,232 +392,31 @@ type PoolInfo struct { } ``` -### BoxInfo +## Workspace Integration + +Sandbox V2 integrates with the workspace module via `Manager.SetWorkspaceManager()` and `CreateOptions.WorkspaceID`: ```go -type BoxInfo struct { - ID string - ContainerID string - Pool string - Owner string - Status string // "running", "stopped", "creating" - Policy LifecyclePolicy - Labels map[string]string - Image string - CreatedAt time.Time - LastActive time.Time // max(lastCall, lastHeartbeat) - ProcessCount int // user processes inside container (0 = idle) - VNC bool -} -``` +// Link workspace manager at startup +sbm.SetWorkspaceManager(wsm) -## Workspace — fs.FS Interface - -`Box.Workspace()` returns `workspace.FS` from `tai/workspace`. This is the standard Go `fs.FS` interface extended with write operations. - -```go -// tai/workspace.FS — already implemented -type FS interface { - fs.FS // Open(name) (fs.File, error) - fs.StatFS // Stat(name) (fs.FileInfo, error) - fs.ReadFileFS // ReadFile(name) ([]byte, error) - fs.ReadDirFS // ReadDir(name) ([]fs.DirEntry, error) - io.Closer - - WriteFile(name string, data []byte, perm os.FileMode) error - Remove(name string) error - RemoveAll(name string) error - Rename(oldname, newname string) error - MkdirAll(name string, perm os.FileMode) error -} -``` - -100% compatible with Go standard library: - -```go -ws := box.Workspace() - -// Standard fs functions work -data, _ := fs.ReadFile(ws, "main.go") -fs.WalkDir(ws, ".", func(path string, d fs.DirEntry, err error) error { ... }) -info, _ := fs.Stat(ws, "go.mod") - -// Extended write operations -ws.WriteFile("main.go", []byte("package main"), 0644) -ws.MkdirAll("src/pkg", 0755) -ws.Remove("tmp.txt") -ws.Rename("old.go", "new.go") -``` - -Local mode: reads/writes go directly to host disk via bind mount. -Remote mode: reads/writes go through tai Volume gRPC with lz4 compression. -Caller doesn't know or care which mode. - -## Container gRPC (already implemented) - -Container processes communicate with Yao via gRPC. No Unix sockets. - -``` -Local: Container → yao-grpc → Yao gRPC 127.0.0.1:9099 -Remote: Container → yao-grpc → Tai :9100 relay → Yao gRPC :9099 -``` - -Manager injects env vars at container creation: - -``` -# Local -YAO_GRPC_ADDR=127.0.0.1:9099 -YAO_TOKEN= -YAO_REFRESH_TOKEN= -YAO_SANDBOX_ID= - -# Remote (adds Tai relay) -YAO_GRPC_TAI=enable -YAO_GRPC_UPSTREAM=yao-host:9099 -``` - -Token issuance uses existing `openapi/oauth`. Manager creates token pair at container creation, revokes refresh token on Remove. - -## Process Registration - -Sandbox operations are exposed as Yao Processes under the `sandbox` namespace. - -```go -func init() { - process.Register("sandbox", handler) -} -``` - -| Process | Args | Returns | -|---------|------|---------| -| `sandbox.pool.Add` | `pool` (Pool JSON) | PoolInfo | -| `sandbox.pool.Remove` | `name`, `force?` | — | -| `sandbox.pool.List` | — | []PoolInfo | -| `sandbox.Create` | `options` (CreateOptions JSON) | BoxInfo | -| `sandbox.Get` | `id` | BoxInfo | -| `sandbox.GetOrCreate` | `options` | BoxInfo | -| `sandbox.Remove` | `id` | — | -| `sandbox.List` | `options` (ListOptions JSON) | []BoxInfo | -| `sandbox.Start` | `id` | — | -| `sandbox.Stop` | `id` | — | -| `sandbox.Exec` | `id`, `cmd[]`, `options?` | ExecResult | -| `sandbox.Stream` | `id`, `cmd[]`, `options?` | stream (chunked output) | -| `sandbox.Attach` | `id`, `port`, `options?` | ServiceConn info | -| `sandbox.ReadFile` | `id`, `path` | file content (string) | -| `sandbox.WriteFile` | `id`, `path`, `content` | — | -| `sandbox.ListDir` | `id`, `path` | []FileInfo | -| `sandbox.RemoveFile` | `id`, `path` | — | -| `sandbox.MkdirAll` | `id`, `path` | — | -| `sandbox.VNC` | `id` | URL string | -| `sandbox.Proxy` | `id`, `port`, `path?` | URL string | - -This allows any Yao script, Flow, or API to use sandbox: - -```json -{ - "process": "sandbox.Exec", - "args": ["sb-001", ["go", "build", "./..."]] -} -``` - -## JSAPI - -Global constructor function registered in `gou/runtime/v8`, following the `FS()` / `Store()` pattern. - -```javascript -// Pool management -Sandbox.AddPool({ name: "gpu2", addr: "tai://gpu2.internal" }) -Sandbox.RemovePool("gpu2") -var pools = Sandbox.Pools() -// [{ name: "local", addr: "local", connected: true, boxes: 3 }, ...] - -// Get or create a sandbox -var sb = Sandbox("my-workspace", { - image: "yaoapp/workspace:latest", - owner: "user-123" +// Create sandbox with workspace mount +box, err := sbm.Create(ctx, sandbox.CreateOptions{ + Image: "yaoapp/workspace:latest", + WorkspaceID: "ws-abc123", + MountMode: "rw", // default + MountPath: "/workspace", // default }) - -// File operations (fs.FS semantics) -var content = sb.ReadFile("src/main.go") -sb.WriteFile("src/main.go", "package main\n...") -var entries = sb.ListDir("src/") -var info = sb.Stat("src/main.go") -sb.MkdirAll("src/components") -sb.Remove("tmp.txt") -sb.Rename("old.go", "new.go") - -// Command execution — wait for result -var result = sb.Exec(["go", "build", "./..."]) -// result.exit_code, result.stdout, result.stderr - -// Streaming execution — real-time output -sb.Stream(["npm", "run", "dev"], function(chunk) { - log.Info(chunk) // real-time stdout/stderr - return 1 // 1=continue, 0=stop -}) - -// Connect to a service inside the sandbox -var conn = sb.Attach(3000, { protocol: "ws", path: "/ws" }) -conn.Write('{"type":"ping"}') -var msg = conn.Read() -conn.Close() - -// Network -var vncUrl = sb.VNCUrl() -var previewUrl = sb.ProxyUrl(3000, "/") - -// Info -var info = sb.Info() -// info.id, info.status, info.owner, info.created_at - -// Lifecycle -sb.Stop() -sb.Start() -sb.Remove() - -// Properties -sb.id // sandbox ID -sb.workdir // container working directory ``` -Registration in `gou/runtime/v8/isolate.go`: +When `WorkspaceID` is set: +1. Manager calls `workspace.Manager.NodeForWorkspace()` to resolve the workspace's bound node +2. Forces the container onto that node's pool +3. Calls `workspace.Manager.MountPath()` to get the host-side directory +4. Adds a Docker bind mount: `hostPath:mountPath:mode` +5. Box.Workspace() uses the workspace ID as the volume session key -```go -template.Set("Sandbox", sandboxT.New().ExportFunction(iso)) -``` - -Implementation: `gou/runtime/v8/objects/sandbox/sandbox.go` — wraps `sandbox.M().GetOrCreate()` + `Box` methods, using `bridge.GoValue` / `bridge.JsValue` for type conversion. - -## Bootstrap — Manager.Start() - -On `Manager.Start()`, the Manager recovers all existing sandboxes and starts the cleanup loop: - -``` -1. For each pool: - tai.Client.Sandbox().List(labels: {"managed-by": "yao-sandbox"}) - → discover running/stopped containers - -2. For each discovered container: - Parse labels → extract sandbox ID, owner, policy, pool name - Rebuild Box struct, register in boxes map - Set lastCall = now (grace period after restart) - -3. Start cleanupLoop goroutine -``` - -Containers are identified by the label `managed-by=yao-sandbox` plus `sandbox-id=`. Manager injects these labels at creation time. On restart, it queries each pool for containers with `managed-by=yao-sandbox` and rebuilds the in-memory state. - -**What happens to orphaned containers** (created by old Manager, no longer matching any pool): -- If a pool is removed from config, its containers are invisible to the new Manager -- They stay running in Docker/K8s until manually cleaned or TTL-expired by the runtime -- This is by design — Manager only manages containers it can reach - -Startup sequence in `cmd/start.go`: - -``` -sandbox.Init(config.Conf.Sandbox) // create Manager with pool + guard rails -sandbox.M().Start(ctx) // discover existing containers, start cleanup loop -``` +This guarantees that a workspace's container always runs on the same host where its storage lives. ## Container Setup — Manager.Create() @@ -675,404 +424,417 @@ When Manager creates a sandbox, it: 1. Validates `CreateOptions` (Image required) 2. Generates sandbox ID (or uses provided one) -3. Checks user limits (`MaxPerUser`) and total limits (`MaxTotal`) -4. Resolves pool (by name or default) -5. Creates OAuth token pair for container IPC via `openapi/oauth` -6. Builds `tai.sandbox.CreateOptions` from caller's `CreateOptions`: - - Image, Cmd (`sleep infinity`), User — all from caller - - Field name mapping: v2 `WorkDir` → tai `WorkingDir` - - Merges caller's Env with IPC env vars: - - `YAO_GRPC_ADDR`, `YAO_TOKEN`, `YAO_REFRESH_TOKEN`, `YAO_SANDBOX_ID` - - Remote mode: `YAO_GRPC_TAI=enable`, `YAO_GRPC_UPSTREAM` - - Memory/CPU limits, VNC flag, port mappings — all from caller - - Injects management labels: - - `managed-by=yao-sandbox` - - `sandbox-id=` - - `sandbox-owner=` - - `sandbox-pool=` - - `sandbox-policy=` -7. Calls `tai.Client.Sandbox().Create()` then `Start()` -8. Wraps in a `Box`, registers in `boxes` map -9. Starts idle tracking +3. Resolves workspace node binding (if WorkspaceID set) +4. Checks user limits (`MaxPerUser`) and total limits (`MaxTotal`) +5. Resolves pool (by name or default) +6. Creates OAuth token pair for container IPC +7. Builds `tai.sandbox.CreateOptions`: + - Injects management labels: `managed-by`, `sandbox-id`, `sandbox-owner`, `sandbox-pool`, `sandbox-policy`, `workspace-id` + - Sets container CMD to graceful-shutdown-aware sleep: `sh -c "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done"` + - Merges caller's Env with gRPC env vars (`YAO_SANDBOX_ID`, `YAO_TOKEN`, `YAO_REFRESH_TOKEN`, `YAO_GRPC_ADDR`, etc.) + - Adds workspace bind mount if WorkspaceID is set +8. Calls `tai.Client.Sandbox().Create()` then `Start()` +9. Wraps in a `Box`, registers in `boxes` map ## Lifecycle Management ### Idle Tracking — Dual Source -Idle is determined by two sources, taking the most recent of both: - ```go box.lastActive = max(lastExternalCall, lastHeartbeat) ``` | Source | What it tracks | Updated by | |--------|---------------|------------| -| External call | Caller is using the sandbox | `Box.Exec()`, `Box.Workspace()`, `Box.VNC()`, `Box.Proxy()` | -| Container heartbeat | Processes running inside the container | `yao-grpc` → gRPC `Heartbeat` RPC | - -**Why both**: external calls alone miss "user walked away but `npm run build` is still running". Heartbeat alone misses "user is reading output, hasn't issued a new command yet". Together they cover all cases. - -### Heartbeat — Container Side - -`yao-grpc` (already running inside every container) runs a background goroutine: - -``` -Every 30 seconds: - 1. Count user processes (ps aux, exclude sleep/init/yao-grpc) - 2. Count gRPC calls forwarded in last 30s (internal counter) - 3. If either > 0 → send Heartbeat(sandbox_id, active=true, process_count=N) - else → don't send (silent = idle) -``` - -~30 lines added to `tai/grpc/cmd/main.go`. Zero new dependencies. - -### Heartbeat — Server Side - -New gRPC RPC in `yao.proto`: - -```protobuf -rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse); - -message HeartbeatRequest { - string sandbox_id = 1; - bool active = 2; - int32 process_count = 3; -} -message HeartbeatResponse {} -``` - -Handler (~20 lines in `grpc/sandbox/`): looks up Box by `sandbox_id`, updates `lastHeartbeat`. Auth: reuses container's `YAO_TOKEN`, no new scope needed (piggyback on existing `grpc:mcp`). - -### Idle Decision Matrix - -| External calls | Heartbeat | Judgment | Action | -|---------------|-----------|----------|--------| -| Recent | Recent | Active | None | -| Recent | Silent | Active | None (user reading output) | -| None | Recent | Active | None (build/server still running) | -| None | Silent | **Idle** | Policy-based stop/remove | +| External call | Caller is using the sandbox | `Box.Exec()`, `Box.Stream()`, `Box.Workspace()`, `Box.VNC()`, `Box.Proxy()`, `Box.Attach()` | +| Container heartbeat | Processes running inside the container | gRPC `Heartbeat` RPC | ### Cleanup Loop -```go -func (m *Manager) cleanupLoop(ctx context.Context) { - ticker := time.NewTicker(1 * time.Minute) - for { - select { - case <-ticker.C: - m.Cleanup(ctx) - case <-ctx.Done(): - return - } - } -} - -func (m *Manager) Cleanup(ctx context.Context) error { - now := time.Now() - m.boxes.Range(func(key, value any) bool { - box := value.(*Box) - idle := now.Sub(box.lastActiveTime()) // max(external, heartbeat) - - switch box.policy { - case OneShot: - // already removed after Exec - case Session: - if idle > box.idleTimeout() { box.Remove(ctx) } - case LongRunning: - if idle > box.idleTimeout() { box.Stop(ctx) } - if lifetime > box.maxLifetime() { box.Remove(ctx) } - case Persistent: - // never auto-cleaned - } - return true - }) - return nil -} -``` - -### Policy Behavior +Runs every 60 seconds. Policy behavior: | Policy | Idle | Max Lifetime | Auto | |--------|------|-------------|------| | OneShot | — | — | Removed after first Exec completes | -| Session | Stop + Remove | Remove | Default for agent chats | +| Session | Remove | Remove | Default for agent chats | | LongRunning | Stop (keep data) | Remove | User workspaces | | Persistent | Never | Never | User-managed | +### Container Stop Behavior + +`DefaultStopTimeout = 2s`. Docker `ContainerStop` sends SIGTERM, waits the timeout, then SIGKILL. The V2 container CMD (`trap 'exit 0' TERM; ...`) exits immediately on SIGTERM, so actual stop time is near-instant. + +`Manager.Remove()` calls `Sandbox().Remove(force=true)` directly (SIGKILL + delete) — no redundant Stop call. This keeps remove latency under 200ms. + +## Tai SDK Interface + +Sandbox V2 depends on these tai sub-package interfaces: + +### tai.Client + +```go +func New(addr string, opts ...Option) (*Client, error) +func (c *Client) Sandbox() sandbox.Sandbox +func (c *Client) Image() sandbox.Image +func (c *Client) Volume() volume.Volume +func (c *Client) Workspace(sessionID string) workspace.FS +func (c *Client) Proxy() proxy.Proxy +func (c *Client) VNC() vnc.VNC +func (c *Client) DataDir() string +func (c *Client) IsLocal() bool +func (c *Client) Close() error +``` + +Address schemes: `"local"` (Docker default), `"docker://..."` (explicit Docker), `"tai://host"` (remote Tai Server). Remote mode auto-discovers service ports via ServerInfo gRPC, with `WithPorts()` taking precedence. + +### sandbox.Sandbox + +```go +type Sandbox interface { + Create(ctx, opts CreateOptions) (string, error) + Start(ctx, id string) error + Stop(ctx, id string, timeout time.Duration) error + Remove(ctx, id string, force bool) error + Exec(ctx, id string, cmd []string, opts ExecOptions) (*ExecResult, error) + ExecStream(ctx, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) + Inspect(ctx, id string) (*ContainerInfo, error) + List(ctx, opts ListOptions) ([]ContainerInfo, error) + Close() error +} +``` + +Implementations: `docker_core.go` (local Docker), `docker.go` (remote Docker via Tai proxy), `k8s.go` (Kubernetes via Tai proxy). + +### sandbox.Image + +```go +type Image interface { + Exists(ctx, ref string) (bool, error) + Pull(ctx, ref string, opts PullOptions) (<-chan PullProgress, error) + Remove(ctx, ref string, force bool) error + List(ctx) ([]ImageInfo, error) +} +``` + +Docker implementation pulls via Docker SDK with real-time progress streaming. K8s implementation is a no-op — kubelet handles image pulling. + +### proxy.Proxy + +```go +type Proxy interface { + URL(ctx, containerID string, port int, path string) (string, error) + Connect(ctx, containerID string, opts ConnectOptions) (*Connection, error) + Healthz(ctx) error +} +``` + +Local: resolves host ports via `Inspect()`. Remote: routes through Tai HTTP proxy which handles WebSocket upgrade and SSE streaming natively. + +## gRPC Token Injection + +```go +func CreateContainerTokens(sandboxID, owner string, scopes []string) (access, refresh string, err error) +func RevokeContainerTokens(refresh string) error +func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) map[string]string +``` + +Environment variables injected into each container: + +``` +# All modes +YAO_SANDBOX_ID= +YAO_TOKEN= +YAO_REFRESH_TOKEN= +YAO_GRPC_ADDR=127.0.0.1:9099 + +# Remote mode (tai://) adds: +YAO_GRPC_TAI=enable +YAO_GRPC_ADDR=:9100 +YAO_GRPC_UPSTREAM=127.0.0.1:9099 +``` + +## Errors + +```go +var ( + ErrNotAvailable = errors.New("sandbox: not available (no pools configured)") + ErrNotFound = errors.New("sandbox: not found") + ErrLimitExceeded = errors.New("sandbox: limit exceeded") + ErrPoolNotFound = errors.New("sandbox: pool not found") + ErrPoolInUse = errors.New("sandbox: pool has running boxes") +) +``` + ## Package Structure ``` sandbox/v2/ -├── sandbox.go // Init, M(), global singleton -├── manager.go // Manager struct, Create/Get/List/Remove/Cleanup -├── box.go // Box struct, Exec/Workspace/VNC/Proxy/lifecycle -├── config.go // Config, env parsing -├── types.go // CreateOptions, ExecResult, BoxInfo, enums -├── errors.go // sentinel errors -├── process.go // Yao Process registration (sandbox.*) -├── grpc.go // token creation + gRPC env var injection for containers -├── jsapi/ -│ └── sandbox.go // V8 JSAPI: Sandbox() constructor (lives in gou) -└── DESIGN.md // this document +├── sandbox.go // Init, M(), global singleton +├── manager.go // Manager: CRUD, pool management, image ops, cleanup +├── box.go // Box: Exec, Stream, Attach, Workspace, VNC, Proxy, lifecycle +├── types.go // CreateOptions, ExecResult, ExecStream, ServiceConn, BoxInfo, etc. +├── config.go // Config struct +├── errors.go // sentinel errors +├── grpc.go // token creation/revocation, gRPC env var injection +├── jsapi/ // (Phase 2) V8 JSAPI Sandbox() constructor +│ └── sandbox.go +├── export_test.go // ResetForTest() for test isolation +├── testutils_test.go // shared test helpers (multi-pool setup) +├── sandbox_test.go // Init/M singleton tests +├── manager_test.go // Manager CRUD tests +├── manager_lifecycle_test.go // Heartbeat, Cleanup, idle tracking tests +├── box_test.go // Box Exec/Workspace/Info tests +├── box_attach_test.go // Attach WS/SSE/VNC tests +├── box_workspace_test.go // Workspace integration tests +├── box_image_test.go // Image Pull API tests +├── bench_test.go // Performance benchmarks +├── grpc_test.go // Token/env building tests +├── DESIGN.md // this document +└── IMPL.md // implementation status and plan ``` -## Tai SDK Changes Required +--- -Sandbox V2 needs changes in `tai/` and `yao/grpc` before Phase 1 can fully work. These are **prerequisites** — the sandbox module itself has zero Docker/K8s awareness, so all runtime capabilities must exist in tai; heartbeat support requires additions to both the gRPC server and the in-container client. +# Workspace Module -### 1. `tai/sandbox` — Add `ExecStream` (streaming exec) +## Positioning -Current `Exec()` buffers all output and returns `ExecResult` after the process exits. `Box.Stream()` needs a streaming variant. +Workspace is a **top-level module** (`workspace/`), parallel to `sandbox/v2`. It provides persistent, user-managed storage that is decoupled from container lifecycle. Workspaces are pinned to a specific Tai node; containers referencing a workspace are automatically routed to that node. -```go -// tai/sandbox — new method on the Sandbox interface -type ExecStream struct { - Stdout io.ReadCloser - Stderr io.ReadCloser - Stdin io.WriteCloser - Wait func() (int, error) // blocks until exit, returns exit code - Cancel func() // kills the exec process -} - -func (s *Sandbox) ExecStream(ctx context.Context, containerID string, cmd []string, opts ...ExecOption) (*ExecStream, error) +``` +┌─────────────────────┐ ┌─────────────────────┐ +│ sandbox/v2 │ │ workspace │ +│ (container runtime) │◄────│ (persistent storage)│ +│ │ │ │ +│ CreateOptions { │ │ CRUD + File I/O │ +│ WorkspaceID ──────┼────►│ Node binding │ +│ } │ │ fs.FS interface │ +└──────────┬───────────┘ └──────────┬───────────┘ + │ │ + └──────────┬─────────────────┘ + ▼ + tai.Client pool ``` -Implementation per runtime: - -| Runtime | How | -|---------|-----| -| **Docker** (`docker_core.go`) | `ContainerExecCreate` + `ContainerExecAttach` — already returns a `HijackedResponse` with a raw stream. Current code pipes it into buffers; change to expose `io.ReadCloser` directly. `Cancel` calls `ContainerExecInspect` loop → kill. ~40 lines changed. | -| **K8s** (`k8s.go`) | `remotecommand.NewSPDYExecutor` + `StreamWithContext` — already supports streaming. Current code passes `bytes.Buffer`; change to pass `io.Pipe()`. ~30 lines changed. | - -Both runtimes already have the raw streaming capability — the change is to **stop buffering** and expose the stream directly. - -### 2. `tai/proxy` — Add `Connect` (bidirectional connection) - -Current `proxy.Proxy` only returns a URL string (`Resolve()`). `Box.Attach()` needs an actual connection. +## Core Types ```go -// tai/proxy — new method -type ConnectOptions struct { - Protocol string // "ws", "sse", "tcp"; default "ws" - Path string // URL path, e.g. "/v1/chat" - Headers map[string]string // extra request headers +type Workspace struct { + ID string + Name string + Owner string + Node string // Tai node this workspace is pinned to + Labels map[string]string + CreatedAt time.Time + UpdatedAt time.Time } -type Connection struct { - Read func() ([]byte, error) // read next message/event - Write func(data []byte) error // send data (no-op for SSE) - Events <-chan []byte // non-nil for SSE mode - URL string // resolved URL for reference - Close func() error -} - -func (p *Proxy) Connect(ctx context.Context, containerID string, port int, opts ConnectOptions) (*Connection, error) -``` - -Implementation: - -| Mode | How | -|------|-----| -| **Local** | Direct dial to `containerIP:port`. WebSocket via `gorilla/websocket` or `nhooyr.io/websocket`. SSE via `http.Get` + chunked read. TCP via `net.Dial`. | -| **Remote** | Dial through Tai HTTP proxy: `http://tai-host:8080/{containerID}:{port}/{path}`. Tai proxy already handles WebSocket upgrade and SSE streaming natively (`http.Hijacker` for WS, `FlushInterval: -1` for SSE). No Tai server changes needed. | - -The Tai HTTP proxy server (`tai/httpproxy/router.go`) already supports: -- **WebSocket**: detects `Upgrade: websocket` header, does TCP-level bidirectional relay -- **SSE**: reverse proxy with `FlushInterval: -1`, streams through transparently -- **Regular HTTP**: standard `httputil.ReverseProxy` - -So the `Connect` implementation in `tai/proxy` is a **client-side** addition only. The server side is ready. - -### 3. `tai/sandbox` — Add `Labels` and `User` to `CreateOptions` - -Current `tai/sandbox.CreateOptions` is missing two fields Manager needs: - -- **`Labels`**: for container discovery on restart (`managed-by=yao-sandbox`, `sandbox-id`, etc.) -- **`User`**: to run container processes as a specific user - -```go -// tai/sandbox — add to existing CreateOptions struct type CreateOptions struct { - // ... existing fields (Name, Image, Cmd, Env, Binds, WorkingDir, Memory, CPUs, VNC, Ports) ... - Labels map[string]string // container/pod labels for discovery and management - User string // container user, e.g. "1000:1000" + ID string // explicit ID; empty = auto-generate (ws-) + Name string + Owner string + Node string // target Tai node (required) + Labels map[string]string +} + +type ListOptions struct { + Owner string + Node string +} + +type UpdateOptions struct { + Name *string // nil = no change + Labels map[string]string // nil = no change; non-nil replaces all labels +} + +type NodeInfo struct { + Name string + Addr string + Online bool +} + +type DirEntry struct { + Name string + IsDir bool + Size int64 } ``` -Implementation: - -| Runtime | Field | How | -|---------|-------|-----| -| **Docker** | `Labels` | Set `cfg.Labels = opts.Labels` in `create()`. ~1 line. | -| **Docker** | `User` | Set `cfg.User = opts.User` in `create()`. ~1 line. | -| **K8s** | `Labels` | Set `pod.ObjectMeta.Labels` in `CreatePod`. ~1 line. | -| **K8s** | `User` | Set `SecurityContext.RunAsUser` in pod spec. ~3 lines. | - -`List` with label filtering is **already implemented** in both runtimes: -- Docker: `filters.NewArgs("label", k+"="+v)` in `docker_core.go:175` -- K8s: `metav1.ListOptions{LabelSelector: ...}` in `k8s.go:255` - -`ListOptions.Labels` field also already exists in `sandbox.go:68`. No changes needed for List. - -Also needed: **`ContainerInfo` must include `Labels`**. Current `ContainerInfo` struct has no `Labels` field. `Manager.Start()` discovers existing containers via `List()` and needs to read labels (`sandbox-id`, `sandbox-owner`, `sandbox-policy`, `sandbox-pool`) to rebuild Box state. +## Manager API ```go -// tai/sandbox — add to existing ContainerInfo struct -type ContainerInfo struct { - // ... existing fields (ID, Name, Image, Status, IP, Ports) ... - Labels map[string]string // container/pod labels +type Manager struct { + pools map[string]*tai.Client + mu sync.RWMutex } + +func NewManager(pools map[string]*tai.Client) *Manager + +// --- CRUD --- +func (m *Manager) Create(ctx, opts CreateOptions) (*Workspace, error) +func (m *Manager) Get(ctx, id string) (*Workspace, error) +func (m *Manager) List(ctx, opts ListOptions) ([]*Workspace, error) +func (m *Manager) Update(ctx, id string, opts UpdateOptions) (*Workspace, error) +func (m *Manager) Delete(ctx, id string, force bool) error + +// --- File I/O --- +func (m *Manager) ReadFile(ctx, id string, path string) ([]byte, error) +func (m *Manager) WriteFile(ctx, id string, path string, data []byte, perm os.FileMode) error +func (m *Manager) ListDir(ctx, id string, path string) ([]DirEntry, error) +func (m *Manager) Remove(ctx, id string, path string) error +func (m *Manager) FS(ctx, id string) (workspace.FS, error) + +// --- Node management --- +func (m *Manager) Nodes() []NodeInfo +func (m *Manager) AddPool(name string, client *tai.Client) +func (m *Manager) RemovePool(name string) + +// --- Sandbox integration --- +func (m *Manager) NodeForWorkspace(ctx, id string) (string, error) +func (m *Manager) MountPath(ctx, id string) (string, error) ``` -| Runtime | How | -|---------|-----| -| **Docker** | `list()`: read `c.Labels` from `ContainerList` response. `inspect()`: read `info.Config.Labels`. ~1 line each. | -| **K8s** | `list()`: read `pod.Labels` from `PodList` response. ~1 line. | +## Metadata Storage -### 4. `yao/grpc` + `tai/grpc` — Heartbeat RPC +Workspace metadata is stored as `.workspace.json` inside the workspace's root directory on the Tai node: -Manager uses dual idle tracking (external API calls + container heartbeat). The heartbeat path requires additions on both sides: the gRPC server (new RPC) and `yao-grpc` in-container client (new background goroutine). - -#### Server side — `yao/grpc` - -New RPC in `grpc/pb/yao.proto`: - -```protobuf -rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse); - -message HeartbeatRequest { - string sandbox_id = 1; - bool active = 2; // true if user processes detected - int32 process_count = 3; // number of user processes -} -message HeartbeatResponse {} +``` +/ +├── ws-abc123/ +│ ├── .workspace.json ← metadata (ID, Name, Owner, Node, Labels, timestamps) +│ ├── src/ +│ ├── go.mod +│ └── ... +├── ws-def456/ +│ └── ... ``` -Handler in `grpc/sandbox/` (~20 lines): +This approach collocates metadata with data — no external database required. `List()` scans top-level directories and reads each `.workspace.json`. `Get()` scans all nodes until the workspace is found. + +## Errors ```go -func (s *Server) Heartbeat(ctx context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) { - box, err := sandbox.M().Get(ctx, req.SandboxId) - if err != nil { - return nil, status.Errorf(codes.NotFound, "sandbox %s not found", req.SandboxId) - } - sandbox.M().Heartbeat(req.SandboxId, req.Active, int(req.ProcessCount)) - return &pb.HeartbeatResponse{}, nil -} +var ( + ErrNotFound = errors.New("workspace: not found") + ErrNodeMissing = errors.New("workspace: node is required") + ErrNodeOffline = errors.New("workspace: node is offline or not configured") + ErrHasMounts = errors.New("workspace: workspace has active container mounts") +) ``` -Auth: reuses container's `YAO_TOKEN` — no new OAuth scope needed. The token is already issued with gRPC access when Manager creates the container. - -#### Client side — `tai/grpc/cmd/main.go` (`yao-grpc`) - -New background goroutine (~30 lines) added to `yao-grpc` startup: - -```go -func heartbeatLoop(ctx context.Context, client *grpc.Client, sandboxID string) { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - for { - select { - case <-ticker.C: - count := countUserProcesses() // ps aux, exclude sleep/init/yao-grpc - active := count > 0 - if active { - client.Heartbeat(ctx, sandboxID, true, int32(count)) - } - // silent when idle — no heartbeat sent, Manager tracks absence - case <-ctx.Done(): - return - } - } -} - -func countUserProcesses() int { - // exec `ps -eo comm`, filter out known system processes - // (sleep, init, yao-grpc, sh -c sleep) - // return count of remaining user processes -} -``` - -`yao-grpc` reads `YAO_SANDBOX_ID` from env (injected by Manager at container creation). If empty, heartbeat is disabled (container not managed by sandbox). - -#### Heartbeat flow +## Package Structure ``` -Container (every 30s) Yao Server -───────────────────── ────────── -countUserProcesses() - ├── active (count > 0) - │ └── yao-grpc → Heartbeat RPC ──→ grpc/sandbox/Heartbeat() - │ └── sandbox.M().Heartbeat(id, true, N) - │ └── box.lastHeartbeat = now - │ box.processCount = N - └── idle (count == 0) - └── (no RPC sent) Manager sees: no heartbeat in 30s+ - └── combined with no external calls → idle +workspace/ +├── workspace.go // types, metadata marshal/unmarshal +├── manager.go // Manager: CRUD, file I/O, node management +├── errors.go // sentinel errors +├── testutils_test.go // shared test helpers +├── workspace_test.go // CRUD tests (Create/Get/List/Update/Delete/Nodes) +├── fileio_test.go // File I/O + fs.FS tests +├── bench_test.go // Performance benchmarks +└── DESIGN.md // detailed design document ``` -Key behaviors: -- **Only sends when active** — idle containers are silent, reducing gRPC traffic -- **30s interval** — matches Manager cleanup loop granularity (1 min), two missed heartbeats = considered idle -- **Crash-safe** — if `yao-grpc` dies, heartbeats stop, Manager treats it as idle after timeout -- **Zero new dependencies** — `yao-grpc` already has the gRPC client connection; heartbeat piggybacks on it +--- -### Summary +# Testing -| Change | Package | Effort | Blocks | -|--------|---------|--------|--------| -| `ExecStream` | `tai/sandbox` | ~40 lines Docker + ~30 lines K8s | `Box.Stream()` | -| `Connect` | `tai/proxy` | ~80 lines (client-side only, server ready) | `Box.Attach()` | -| `Labels` + `User` in `CreateOptions` | `tai/sandbox` | ~6 lines (Docker + K8s) | `Manager.Create()` labeling + user | -| `Labels` in `ContainerInfo` | `tai/sandbox` | ~3 lines (Docker list/inspect + K8s list) | `Manager.Start()` container discovery | -| `Heartbeat` RPC | `yao/grpc` | ~20 lines handler + 3 lines proto | `Manager.Heartbeat()` | -| Heartbeat goroutine | `tai/grpc` (`yao-grpc`) | ~30 lines | Container → Server heartbeat | +## Test Environment -`List` with label filtering is already implemented in both Docker and K8s runtimes — no changes needed. +Three pool modes configured via environment variables: -All changes are additive (no breaking changes to existing APIs). `Box.Exec()` and `Box.Workspace()` work with current tai — only Stream, Attach, and idle tracking need the new methods. +```bash +# Local — direct Docker daemon (always available) +SANDBOX_TEST_LOCAL_ADDR=local -## Migration Plan +# Remote — via Tai container (Docker backend) +SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1:9100 -### Phase 1: Core module +# K8s — via Tai container (K8s backend) +TAI_TEST_K8S_HOST= +TAI_TEST_KUBECONFIG= +TAI_TEST_K8S_PORT=6443 +TAI_TEST_K8S_NAMESPACE=default -Build `sandbox/v2` as a standalone package. No agent dependency. +# Test image +SANDBOX_TEST_IMAGE=yaoapp/sandbox-v2-test:latest +``` -**Tai / gRPC prerequisites** (do first): +Tests skip unavailable modes via `t.Skip`. Both sandbox/v2 and workspace tests iterate over all available pools. + +## Test Coverage + +### sandbox/v2 + +| File | Coverage | +|------|----------| +| `sandbox_test.go` | `Init()`, `M()`, singleton behavior | +| `manager_test.go` | Create, Get, GetOrCreate, List, Remove, pool management, limits | +| `manager_lifecycle_test.go` | Start (container discovery), Cleanup, idle tracking, Heartbeat | +| `box_test.go` | Exec, Info, Workspace (ReadFile/WriteFile), lifecycle | +| `box_attach_test.go` | Attach WS, Attach SSE, VNC URL, VNC Connect | +| `box_workspace_test.go` | Workspace file I/O through Box, workspace mount integration | +| `box_image_test.go` | ImageExists, PullImage (with progress), EnsureImage, K8s no-op | +| `grpc_test.go` | Token creation/revocation, env var building | +| `bench_test.go` | ContainerLifecycle, Create, Exec, ExecHeavy, Remove, Info, StopStart, WorkspaceReadWrite | + +### workspace + +| File | Coverage | +|------|----------| +| `workspace_test.go` | Create (auto/explicit ID, labels, invalid node), Get, List (filter owner/node), Update (name/labels), Delete, Nodes, NodeForWorkspace, AddPool, RemovePool, MountPath | +| `fileio_test.go` | ReadWriteFile, nested paths, ListDir, Remove, fs.FS (ReadFile, WriteFile, MkdirAll, Rename, WalkDir, Remove) | +| `bench_test.go` | WriteFile, ReadFile, ReadWriteCycle, WriteLargeFile, ListDir, FSWalkDir, CreateDelete | + +## CI Integration + +Consolidated into two CI jobs: + +| Job | Contents | +|-----|----------| +| `SandboxV2Test` | Image pre-pull → tai-test → sandbox/v2 (local+remote+k8s) → workspace (local+remote) | +| `BenchmarkSandboxV2` | Performance tests for sandbox/v2 + workspace (parallel with SandboxV2Test) | + +## Benchmark Results (Reference) + +| Benchmark | Local | Remote | K8s | +|-----------|-------|--------|-----| +| ContainerLifecycle | ~300ms | ~200ms | ~10s | +| Create | ~100ms | ~80ms | ~8s | +| Exec | ~30ms | ~50ms | ~150ms | +| Remove | ~180ms | ~120ms | ~220ms | +| Info | ~5ms | ~10ms | ~30ms | +| StopStart | ~2.2s | ~2.2s | N/A (skip) | + +K8s `StopStart` is skipped because K8s `Stop` deletes the Pod; a subsequent `Start` cannot restart a deleted Pod. + +Docker `StopStart` ~2.2s is expected: `DefaultStopTimeout = 2s` and Docker waits the full timeout before SIGKILL unless PID 1 exits on SIGTERM first. + +--- + +# Migration Plan + +## Phase 1: Core (DONE) + +- tai SDK: Sandbox, ExecStream, Image, Proxy.Connect, Labels, User +- sandbox/v2: Manager, Box, all CRUD + Exec + Stream + Attach + Workspace + VNC + Proxy + Image +- workspace: Manager, CRUD, file I/O, node binding, sandbox integration +- gRPC: Heartbeat RPC (proto + handler) +- Tests: unit + integration + benchmarks +- CI: consolidated SandboxV2Test + BenchmarkSandboxV2 + +## Phase 2: Process + JSAPI (PENDING) | Task | Detail | |------|--------| -| `tai/sandbox`: `ExecStream` | Streaming exec for Docker + K8s (~70 lines total) | -| `tai/proxy`: `Connect` | Client-side WebSocket/SSE/TCP connection (~80 lines) | -| `tai/sandbox`: `Labels` + `User` in `CreateOptions` | Add fields + wire into Docker/K8s create (~6 lines). List filter already done. | -| `tai/sandbox`: `Labels` in `ContainerInfo` | Add field + populate in Docker list/inspect, K8s list (~3 lines) | -| `yao/grpc`: `Heartbeat` RPC | Proto + handler (~20 lines) | -| `tai/grpc` (`yao-grpc`): heartbeat goroutine | Process detection + periodic report (~30 lines) | +| `sandbox/v2/process.go` | Register `sandbox.*` process namespace | +| `sandbox/v2/jsapi/` | V8 `Sandbox()` constructor (registered in gou runtime) | +| `workspace/process.go` | Register `workspace.*` process namespace | +| Integration with `cmd/start.go` | Call `sandbox.Init()` + `sandbox.M().Start()` | +| Wire `openapi/oauth` | `grpc.go` currently uses random token placeholders; replace with real OAuth issue/revoke | -**Sandbox V2 module:** - -| Task | Detail | -|------|--------| -| `sandbox.go` | `Init()`, `M()`, singleton lifecycle | -| `config.go` | Config struct, defaults | -| `types.go` | CreateOptions, ExecResult, BoxInfo, LifecyclePolicy, Pool | -| `errors.go` | ErrNotAvailable, ErrNotFound, ErrLimitExceeded | -| `manager.go` | Manager with tai.Client pool. Create/Get/GetOrCreate/List/Remove/Cleanup/Close | -| `box.go` | Box wrapping tai Sandbox/Volume/Workspace/Proxy/VNC. Dual idle tracking (lastCall + lastHeartbeat) | -| `grpc.go` | OAuth token pair creation, gRPC env var injection | -| Tests | Unit + integration (needs Docker for local mode) | - -### Phase 2: Process + JSAPI - -| Task | Detail | -|------|--------| -| `process.go` | Register `sandbox.*` process namespace | -| `jsapi/sandbox.go` | V8 `Sandbox()` constructor in gou | -| Tests | Process handler tests, JSAPI tests | - -### Phase 3: Agent integration - -In the Agent repo (not in sandbox/v2): +## Phase 3: Agent Integration (PENDING) | Task | Detail | |------|--------| @@ -1080,77 +842,29 @@ In the Agent repo (not in sandbox/v2): | Agent uses `Box.Workspace()` for file I/O | Replace Docker Copy/bind mount reads | | Agent uses `Box.Exec()` for commands | Replace Docker exec | | Agent uses `Box.VNC()` / `Box.Proxy()` | Replace vncproxy | -| Agent injects `Box` as `SandboxExecutor` | `ctx.sandbox` JSAPI unchanged for hooks | -| `BuildMCPConfigForSandbox()` uses Box env vars | No more hardcoded `/tmp/yao.sock` | -### Phase 4: Cutover +## Phase 4: Cutover (PENDING) | Task | Detail | |------|--------| | Move `sandbox/v2` → `sandbox` | Rename package | | Delete old sandbox code | manager.go, ipc/, bridge/, vncproxy/, docker/ | -| Delete `DESIGN-REMOTE.md` | Superseded by tai.Client | | Update `cmd/start.go` | Use new init path | -## What Gets Deleted (Phase 4) - -Everything in the current `sandbox/` that is replaced by tai: - -| Old | Replaced by | -|-----|------------| -| `manager.go` (Docker `*client.Client`) | `tai.Client.Sandbox()` | -| `ipc/` (Unix socket manager) | gRPC via `yao/grpc` + `tai/grpc` | -| `bridge/` (stdio→socket bridge) | `yao-grpc` binary (`tai/grpc/cmd`) | -| `vncproxy/` (Docker-based VNC) | `tai.Client.VNC()` | -| `proxy/` (Claude API proxy) | separate concern, not sandbox | -| `docker/` (Dockerfiles) | kept, they're image build files | -| `DESIGN-REMOTE.md` (Runtime interface) | tai.Client is the abstraction | -| `config.go` (old config) | new config in v2 | -| `helpers.go` (Docker helpers) | not needed | - -## Comparison: V1 vs V2 +## V1 vs V2 Comparison | Aspect | V1 (current) | V2 (this design) | |--------|-------------|-------------------| | **Positioning** | Agent's Claude executor | Yao infrastructure module | | **Runtime** | Direct Docker SDK | tai.Client pool (Docker/K8s/Remote) | -| **Execution** | Exec + Stream | Exec + Stream + Attach (service connections) | +| **Execution** | Exec + Stream | Exec + Stream + Attach (WS/SSE) | | **File I/O** | bind mount + Docker Copy | `workspace.FS` (fs.FS compatible) | -| **IPC** | Unix socket + yao-bridge | gRPC (yao-grpc, already done) | +| **IPC** | Unix socket + yao-bridge | gRPC (yao-grpc) | | **Idle detection** | External calls only | Dual: external calls + container heartbeat | | **Lifecycle** | Chat session only | Policy-based (oneshot/session/longrunning/persistent) | -| **Pool** | Single Docker daemon | Multi-pool with per-pool policies, dynamic add/remove | +| **Pool** | Single Docker daemon | Multi-pool with per-pool policies | | **Agent coupling** | Tightly coupled | Zero dependency | -| **JSAPI** | Only `ctx.sandbox` in hooks | Global `Sandbox()` + `ctx.sandbox` | -| **Process** | None | `sandbox.*` namespace | -| **Multi-node** | Local only | Local + Remote via Tai | +| **Workspace** | None | Persistent, node-bound, decoupled from containers | +| **Image management** | None | EnsureImage + Pull with progress | | **K8s** | Not supported | Supported via tai.Client | - -## Workspace - -Workspace is now a **top-level module** (`workspace/`), parallel to `sandbox/v2`. - -See [`workspace/DESIGN.md`](../workspace/DESIGN.md) for the full design document covering: -- Workspace as a first-class, persistent entity decoupled from containers -- Node binding and container scheduling -- Workspace CRUD and file I/O APIs -- Integration with Sandbox `CreateOptions` -- Metadata storage strategy -- Process and JSAPI registration -- Implementation plan - -### Integration point - -`sandbox/v2` integrates with Workspace via `CreateOptions.WorkspaceID`: - -```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" -} -``` - -When `WorkspaceID` is set, the Sandbox Manager resolves the Workspace's bound node and forces the container to be created on that node. See `workspace/DESIGN.md` for full details. +| **Multi-node** | Local only | Local + Remote via Tai | diff --git a/sandbox/v2/IMPL.md b/sandbox/v2/IMPL.md index 7c4a556f..e2357b31 100644 --- a/sandbox/v2/IMPL.md +++ b/sandbox/v2/IMPL.md @@ -1,704 +1,283 @@ -# Sandbox V2 — Implementation Plan - -Phase 1 implementation. Covers tai SDK prerequisites + sandbox/v2 core Go API. -No JSAPI, no Process registration — those are Phase 2. +# Sandbox V2 — Implementation Status Reference: [DESIGN.md](./DESIGN.md) -## Execution Order +--- -``` -Step 0: tai/sandbox — Labels, User, ContainerInfo.Labels (no deps) -Step 1: tai/sandbox — ExecStream (no deps) -Step 2: tai/proxy — Connect (no deps) -Step 3: yao/grpc — Heartbeat RPC (proto + handler) (no deps) -Step 4: tai/grpc — yao-grpc heartbeat goroutine (depends on Step 3 proto) -Step 4.5: docker — build v2 test images (depends on Steps 1–4) -Step 5: sandbox/v2 — core module (depends on Steps 0–4) -Step 6: tests (depends on Steps 5 + 4.5) -``` +## Phase 1: Core Module — DONE -Steps 0–3 are independent and can be parallelized. -Step 4.5 (images) depends on tai SDK + yao-grpc changes being compiled into binaries. +### tai SDK Prerequisites — DONE + +| Step | Package | What | Status | +|------|---------|------|--------| +| 0 | `tai/sandbox` | Labels, User in CreateOptions + ContainerInfo | DONE | +| 1 | `tai/sandbox` | ExecStream (Docker + K8s) | DONE | +| 2 | `tai/proxy` | Connect (WS/SSE, Local + Remote) | DONE | +| 3 | `tai/sandbox` | Image interface (Exists, Pull, Remove, List) | DONE | +| 4 | `tai/tai.go` | Client: Sandbox(), Image(), Proxy(), VNC(), Volume(), Workspace() | DONE | +| 5 | `yao/grpc` | Heartbeat RPC (proto + handler) | DONE | + +### sandbox/v2 Core — DONE + +| File | What | Status | +|------|------|--------| +| `sandbox.go` | `Init()`, `M()`, global singleton | DONE | +| `manager.go` | Manager: Create/Get/GetOrCreate/List/Remove/Cleanup/Close, Start (container recovery), AddPool/RemovePool/Pools, Heartbeat, SetGRPCPort, SetWorkspaceManager, ImageExists/PullImage/EnsureImage | DONE | +| `box.go` | Box: Exec, Stream, Attach, Workspace, VNC, Proxy, Start/Stop/Remove, Info, touch/lastActiveTime/idleTimeout/maxLifetime/stopTimeout | DONE | +| `types.go` | LifecyclePolicy (OneShot/Session/LongRunning/Persistent), Pool, PoolInfo, PortMapping, CreateOptions (with WorkspaceID/MountMode/MountPath), ListOptions, ExecOption/ExecResult/ExecStream, AttachOption/ServiceConn, ImagePullOptions/RegistryAuth, BoxInfo, DefaultStopTimeout | DONE | +| `config.go` | Config struct | DONE | +| `errors.go` | ErrNotAvailable, ErrNotFound, ErrLimitExceeded, ErrPoolNotFound, ErrPoolInUse | DONE | +| `grpc.go` | CreateContainerTokens, RevokeContainerTokens, BuildGRPCEnv | DONE | + +### workspace Module — DONE + +| File | What | Status | +|------|------|--------| +| `workspace.go` | Workspace struct, CreateOptions, ListOptions, UpdateOptions, NodeInfo, MountMode, metadata marshal/unmarshal | DONE | +| `manager.go` | Manager: Create/Get/List/Update/Delete, ReadFile/WriteFile/ListDir/Remove/FS, Nodes/AddPool/RemovePool, NodeForWorkspace/MountPath | DONE | +| `errors.go` | ErrNotFound, ErrNodeMissing, ErrNodeOffline, ErrHasMounts | DONE | + +### Tests — DONE + +| File | Coverage | Status | +|------|----------|--------| +| **sandbox/v2** | | | +| `sandbox_test.go` | Init, M, singleton | DONE | +| `manager_test.go` | Create, Get, GetOrCreate, List, Remove, pool limits (MaxTotal, MaxPerUser), multi-pool | DONE | +| `manager_lifecycle_test.go` | Start (recovery), Cleanup, idle tracking, Heartbeat | DONE | +| `box_test.go` | Exec, Info, Workspace (ReadFile/WriteFile), status | DONE | +| `box_attach_test.go` | Attach WS echo, Attach SSE events, VNC URL, VNC Connect (RFB handshake) | DONE | +| `box_workspace_test.go` | Workspace mount, file I/O through Box, invalid ID | DONE | +| `box_image_test.go` | ImageExists (Docker+K8s), PullImage (progress+K8s no-op), EnsureImage, bad ref | DONE | +| `grpc_test.go` | Token creation/revocation, env var building (local vs remote) | DONE | +| `bench_test.go` | ContainerLifecycle, Create, Exec, ExecHeavy, Remove, Info, StopStart, WorkspaceReadWrite | DONE | +| `testutils_test.go` | testPools (local/remote/k8s), setupManager, createTestBox, ensureTestImage | DONE | +| `export_test.go` | ResetForTest | DONE | +| **workspace** | | | +| `workspace_test.go` | Create (auto/explicit ID, labels, invalid node), Get, List (owner/node filter), Update (name/labels), Delete, Nodes, NodeForWorkspace, AddPool/RemovePool, MountPath | DONE | +| `fileio_test.go` | ReadWriteFile, nested paths, ListDir, Remove, fs.FS (ReadFile, WriteFile, MkdirAll, Rename, WalkDir, Remove, NotFound) | DONE | +| `bench_test.go` | WriteFile, ReadFile, ReadWriteCycle, WriteLargeFile, ListDir, FSWalkDir, CreateDelete | DONE | +| `testutils_test.go` | testPools, setupManagerForPool, clientForPool, localClient, setupManagerMultiNode, createWorkspace | DONE | + +### CI — DONE + +| Job | Contents | Status | +|-----|----------|--------| +| `SandboxV2Test` | Consolidated: image pre-pull → tai-test → sandbox/v2 (local+remote+k8s) → workspace (local+remote) | DONE | +| `BenchmarkSandboxV2` | Parallel: performance tests for sandbox/v2 + workspace | DONE | +| `GRPCTest` | Independent: gRPC tests (unchanged) | DONE | + +### Performance Optimizations — DONE + +| Optimization | Before | After | Impact | +|-------------|--------|-------|--------| +| Remove redundant Stop in Manager.Remove() | 2.14s | 177ms | 12x faster Docker remove | +| Container CMD trap SIGTERM | 2s+ stop | near-instant | Graceful shutdown on Stop | +| K8s Start: respect ctx deadline | 30s hardcoded | ctx-aware + 60s default | Proper timeout propagation | +| K8s Pod spec: Args vs Command | CMD overridden | ENTRYPOINT preserved | Correct container behavior | --- -## Step 0: `tai` — Labels, User, ContainerInfo.Labels + `tai.New("local")` +## Phase 2: Process + JSAPI — PENDING -**Files:** `tai/tai.go`, `tai/sandbox/sandbox.go`, `tai/sandbox/docker_core.go`, `tai/sandbox/k8s.go` +| Task | Package | Detail | +|------|---------|--------| +| `process.go` | `sandbox/v2` | Register `sandbox.*` process namespace (sandbox.Create, sandbox.Exec, sandbox.ReadFile, etc.) | +| `process.go` | `workspace` | Register `workspace.*` process namespace | +| `jsapi/sandbox.go` | `sandbox/v2/jsapi` | V8 JSAPI `Sandbox()` constructor (registered in gou runtime) | +| `cmd/start.go` integration | `yao` | Call `sandbox.Init(config.Conf.Sandbox)` + `sandbox.M().Start(ctx)` in startup sequence | +| Heartbeat bridge | `yao/grpc` | Wire gRPC Heartbeat handler → `sandbox.M().Heartbeat()` | +| Wire `openapi/oauth` | `sandbox/v2/grpc.go` | `CreateContainerTokens` currently generates random strings; `RevokeContainerTokens` is a no-op. Replace with real `openapi/oauth` issue/revoke calls | -### 0.0 `tai.New("")` → error, add `"local"` / `"127.0.0.1"` aliases +### Process Registration (planned) -```go -// tai/tai.go — parseAddr changes: -// - addr == "" → return error ("use local") -// - addr == "local" || addr == "127.0.0.1" → return "docker", "", "" (platform default socket) +``` +sandbox.pool.Add sandbox.pool.Remove sandbox.pool.List +sandbox.Create sandbox.Get sandbox.GetOrCreate +sandbox.Remove sandbox.List +sandbox.Start sandbox.Stop +sandbox.Exec sandbox.Stream sandbox.Attach +sandbox.ReadFile sandbox.WriteFile sandbox.ListDir +sandbox.RemoveFile sandbox.MkdirAll +sandbox.VNC sandbox.Proxy +sandbox.EnsureImage sandbox.ImageExists sandbox.PullImage + +workspace.Create workspace.Get workspace.List +workspace.Update workspace.Delete +workspace.ReadFile workspace.WriteFile workspace.ListDir +workspace.Remove workspace.FS +workspace.Nodes ``` -All callers must use explicit addresses. `"local"` means platform-default Docker daemon. +### JSAPI (planned) -### 0.1 Add `Labels` and `User` to `CreateOptions` +```javascript +// Sandbox +var sb = Sandbox("my-workspace", { + image: "yaoapp/workspace:latest", + owner: "user-123" +}) +sb.Exec(["go", "build", "./..."]) +sb.ReadFile("src/main.go") +sb.WriteFile("src/main.go", "package main\n...") +sb.Stream(["npm", "run", "dev"], function(chunk) { ... }) +var conn = sb.Attach(3000, { protocol: "ws", path: "/ws" }) +sb.Info() +sb.Stop() +sb.Start() +sb.Remove() -```go -// sandbox.go — add two fields to existing struct -type CreateOptions struct { - // ... existing fields ... - Labels map[string]string - User string -} +// Workspace +var ws = Workspace("my-workspace") +ws.ReadFile("src/main.go") +ws.WriteFile("src/main.go", "package main\n...") +ws.ListDir("src/") +ws.Remove("tmp.txt") ``` -### 0.2 Wire into Docker create - -```go -// docker_core.go — in create(), after building cfg: -cfg.Labels = opts.Labels -if opts.User != "" { - cfg.User = opts.User -} -``` - -### 0.3 Wire into K8s create - -```go -// k8s.go — in Create(), set pod labels: -pod.ObjectMeta.Labels = mergeLabels(pod.ObjectMeta.Labels, opts.Labels) - -// For User, parse and set SecurityContext.RunAsUser -``` - -### 0.4 Add `Labels` to `ContainerInfo` - -```go -// sandbox.go -type ContainerInfo struct { - // ... existing fields ... - Labels map[string]string -} -``` - -### 0.5 Populate Labels in Docker list/inspect - -```go -// docker_core.go — in list(): -ci.Labels = c.Labels - -// docker_core.go — in inspect(): -ci.Labels = info.Config.Labels -``` - -### 0.6 Populate Labels in K8s list - -```go -// k8s.go — in List(): -ci.Labels = pod.Labels -``` - -### 0.7 Tests - -- `TestCreateWithLabels` — create container with labels, list with label filter, verify match -- `TestCreateWithUser` — create container with user, exec `whoami`, verify -- `TestListLabels` — create 2 containers with different labels, list with filter, verify count - -**Estimated: ~20 lines code + ~60 lines tests** - --- -## Step 1: `tai/sandbox` — ExecStream +## Phase 3: Agent Integration — PENDING -**Files:** `tai/sandbox/sandbox.go`, `tai/sandbox/docker_core.go`, `tai/sandbox/k8s.go` +| Task | Detail | +|------|--------| +| Agent creates Box via `sandbox.M().GetOrCreate()` | Replace `infraSandbox.Manager` | +| Agent uses `Box.Workspace()` for file I/O | Replace Docker Copy/bind mount reads | +| Agent uses `Box.Exec()` for commands | Replace Docker exec | +| Agent uses `Box.VNC()` / `Box.Proxy()` | Replace vncproxy | +| Agent injects `Box` as `SandboxExecutor` | `ctx.sandbox` JSAPI unchanged for hooks | -### 1.1 Add to Sandbox interface +--- -```go -// sandbox.go -type ExecStream struct { - Stdout io.ReadCloser - Stderr io.ReadCloser - Stdin io.WriteCloser - Wait func() (int, error) - Cancel func() -} +## Phase 4: Cutover — PENDING -// Add to Sandbox interface: -ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecStream, error) +| Task | Detail | +|------|--------| +| Move `sandbox/v2` → `sandbox` | Rename package | +| Delete old sandbox code | manager.go, ipc/, bridge/, vncproxy/, docker/ | +| Delete `DESIGN-REMOTE.md` | Superseded by tai.Client | +| Update `cmd/start.go` | Use new init path | + +--- + +## Implementation Details + +### Container CMD + +All V2 containers use a SIGTERM-aware sleep as PID 1: + +```bash +sh -c "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done" ``` -### 1.2 Docker implementation +This ensures: +- Container stays alive indefinitely (no hardcoded `sleep infinity`) +- Exits immediately on SIGTERM (no 2s wait) +- Works on both Docker and K8s + +### Container Labels + +Manager injects these labels at creation time: + +``` +managed-by=yao-sandbox +sandbox-id= +sandbox-owner= +sandbox-pool= +sandbox-policy= +workspace-id= (if WorkspaceID set) +``` + +Used by `Manager.Start()` to discover and recover existing containers after restart. + +### Workspace Bind Mount + +When `CreateOptions.WorkspaceID` is set: + +``` +1. NodeForWorkspace(wsID) → node name +2. Force pool = node name +3. MountPath(wsID) → hostDir +4. Bind: hostDir:/workspace:rw +``` + +### Multi-Mode Testing + +`testPools()` returns all available pool configurations: ```go -// docker_core.go — new method -func (d *dockerCore) execStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecStream, error) { - execCfg := container.ExecOptions{ - Cmd: cmd, - WorkingDir: opts.WorkDir, - Env: envSlice(opts.Env), - AttachStdout: true, - AttachStderr: true, - AttachStdin: true, +func testPools() []poolConfig { + pools := []poolConfig{{Name: "local", Addr: testLocalAddr()}} + if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { + pools = append(pools, poolConfig{Name: "remote", Addr: addr}) } - execResp, err := d.cli.ContainerExecCreate(ctx, id, execCfg) - // ... - resp, err := d.cli.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{}) - // ... - // Use io.Pipe + stdcopy.StdCopy in a goroutine to demux stdout/stderr - // Wait: poll ContainerExecInspect until Running=false - // Cancel: context cancel → close resp.Conn -} -``` - -Key: `ContainerExecAttach` returns `HijackedResponse` with multiplexed stream. Use `stdcopy.StdCopy` in a goroutine writing to `io.Pipe` pairs for stdout/stderr separation. - -### 1.3 K8s implementation - -```go -// k8s.go — new method -func (s *k8sSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecStream, error) { - // remotecommand.NewSPDYExecutor - // StreamWithContext using io.Pipe for stdin/stdout/stderr - // Wait: executor returns when process exits - // Cancel: cancel the context -} -``` - -### 1.4 Tests - -- `TestExecStream_ShortCommand` — `echo hello`, read stdout, verify Wait returns 0 -- `TestExecStream_LongRunning` — `sleep 10`, Cancel after 1s, verify cleanup -- `TestExecStream_Stdin` — `cat`, write to stdin, read from stdout, verify echo -- `TestExecStream_ExitCode` — `exit 42`, verify Wait returns 42 - -**Estimated: ~80 lines code + ~100 lines tests** - ---- - -## Step 2: `tai/proxy` — Connect - -**Files:** `tai/proxy/proxy.go`, `tai/proxy/connect.go` (new) - -### 2.1 Add to Proxy interface - -```go -// proxy.go — extend interface -type Proxy interface { - URL(ctx context.Context, containerID string, port int, path string) (string, error) - Connect(ctx context.Context, containerID string, port int, opts ConnectOptions) (*Connection, error) - Healthz(ctx context.Context) error -} - -type ConnectOptions struct { - Protocol string // "ws", "sse", "tcp"; default "ws" - Path string - Headers map[string]string -} - -type Connection struct { - Read func() ([]byte, error) - Write func(data []byte) error - Events <-chan []byte - URL string - Close func() error -} -``` - -### 2.2 Implementation — `connect.go` - -Local: resolve URL via `URL()`, then dial directly. -Remote: resolve URL via `URL()` (points to Tai HTTP proxy), then dial. - -Both modes use the same dialing logic after URL resolution: -- **WebSocket**: `gorilla/websocket.Dialer.DialContext` -- **SSE**: `http.Get` + chunked body reader, parse `data:` lines into Events channel -- **TCP**: `net.Dial` - -### 2.3 Tests - -- `TestConnectWebSocket` — start a WS echo server in container, connect, send/receive -- `TestConnectSSE` — start an SSE server in container, connect, verify events arrive -- Skip TCP for now (less common use case) - -**Estimated: ~120 lines code + ~80 lines tests** - ---- - -## Step 3: `yao/grpc` — Heartbeat RPC - -**Files:** `grpc/pb/yao.proto`, `grpc/sandbox/heartbeat.go` (new), `grpc/api/api.go` - -### 3.1 Proto - -```protobuf -// Add to service Yao: -rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse); - -message HeartbeatRequest { - string sandbox_id = 1; - bool active = 2; - int32 process_count = 3; -} -message HeartbeatResponse {} -``` - -Regenerate: `protoc --go_out=. --go-grpc_out=. grpc/pb/yao.proto` - -### 3.2 Handler - -```go -// grpc/sandbox/heartbeat.go -func (s *Server) Heartbeat(ctx context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) { - err := sandbox.M().Heartbeat(req.SandboxId, req.Active, int(req.ProcessCount)) - if err != nil { - return nil, status.Errorf(codes.NotFound, "sandbox %s: %v", req.SandboxId, err) + if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" { + // ... K8s pool with kubeconfig, namespace, ports + pools = append(pools, poolConfig{Name: "k8s", ...}) } - return &pb.HeartbeatResponse{}, nil + return pools } ``` -### 3.3 Register in server - -Wire into `grpc/api/api.go` server registration (same pattern as Healthz). - -### 3.4 ACL virtual endpoint - -Add to `grpc/auth/endpoints.go`: +Every test iterates over all available pools: ```go -// Heartbeat → POST /grpc/heartbeat (reuse existing container token scope) -``` - -### 3.5 Tests - -- `TestHeartbeat_Success` — create sandbox, send heartbeat, verify no error -- `TestHeartbeat_NotFound` — send heartbeat with unknown sandbox_id, verify NotFound -- `TestHeartbeat_Auth` — verify token auth works (reuse testutils) - -**Estimated: ~40 lines code + ~50 lines tests** - ---- - -## Step 4: `tai/grpc` — yao-grpc heartbeat goroutine - -**Files:** `tai/grpc/cmd/main.go` (or equivalent entry point), `tai/grpc/heartbeat.go` (new) - -### 4.1 Heartbeat loop - -```go -// tai/grpc/heartbeat.go -func heartbeatLoop(ctx context.Context, client *grpc.Client, sandboxID string) { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - for { - select { - case <-ticker.C: - count := countUserProcesses() - if count > 0 { - client.Heartbeat(ctx, sandboxID, true, int32(count)) - } - case <-ctx.Done(): - return - } +func TestSomething(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + // test logic + }) } } - -func countUserProcesses() int { - // exec: ps -eo comm --no-headers - // filter out: sleep, init, yao-grpc, sh, bash (if parent is sleep) - // return count -} ``` -### 4.2 Wire into main +### Benchmark Helpers ```go -// In main() or NewFromEnv(), after client is connected: -sandboxID := os.Getenv("YAO_SANDBOX_ID") -if sandboxID != "" { - go heartbeatLoop(ctx, client, sandboxID) -} +func setupManagerForBench(b *testing.B, pc poolConfig) *sandbox.Manager +func ensureTestImageBench(b *testing.B, m *sandbox.Manager, pool string) +func createBoxForBench(b *testing.B, m *sandbox.Manager) *sandbox.Box ``` -Note: `heartbeatLoop` calls `client.Heartbeat()` (new public method on `tai/grpc.Client`), not `client.svc` (private). - -### 4.3 Tests - -- `TestCountUserProcesses` — unit test for process filtering logic -- `TestHeartbeatLoop_SendsWhenActive` — mock gRPC client, start background process, verify heartbeat sent -- `TestHeartbeatLoop_SilentWhenIdle` — no user processes, verify no RPC calls - -**Estimated: ~40 lines code + ~40 lines tests** +K8s-specific behavior: +- `BenchmarkStopStart`: skipped (K8s Stop deletes Pod) +- Create/Lifecycle benchmarks: 120s timeout for K8s Pod scheduling --- -## Step 4.5: Docker — V2 Test Images - -**Depends on:** Steps 1–4 (tai SDK ExecStream, proxy Connect, yao-grpc heartbeat) - -Sandbox V2 tests need containers that have `yao-grpc` (with heartbeat) pre-installed. Also need a test-specific image with Nginx for Attach WS/SSE testing. - -**Files:** `sandbox/docker/v2/` (new directory) - -### Image hierarchy - -``` -sandbox-v2-base ← base + yao-grpc + claude-proxy -sandbox-v2-test ← v2-base + nginx (WS echo + SSE endpoint) -``` - -### 4.5.1 `sandbox/docker/v2/Dockerfile.base` - -```dockerfile -FROM yaoapp/sandbox-base:latest - -# Replace yao-bridge with yao-grpc -ARG TARGETARCH -COPY yao-grpc-${TARGETARCH} /usr/local/bin/yao-grpc -RUN chmod +x /usr/local/bin/yao-grpc - -# Claude API proxy (OpenAPI-compatible) -COPY claude-proxy-${TARGETARCH} /usr/local/bin/claude-proxy -RUN chmod +x /usr/local/bin/claude-proxy - -# yao-grpc auto-start: if YAO_SANDBOX_ID is set, start heartbeat + serve -COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh - -WORKDIR /workspace -USER sandbox -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] -CMD ["sleep", "infinity"] -``` - -### 4.5.2 `sandbox/docker/v2/entrypoint.sh` - -```bash -#!/bin/bash -# Start yao-grpc in background if sandbox env vars are present -if [ -n "$YAO_SANDBOX_ID" ] && [ -n "$YAO_GRPC_ADDR" ]; then - yao-grpc serve & -fi - -# Start claude-proxy if config exists -if [ -n "$CLAUDE_PROXY_BACKEND" ] || [ -f /workspace/.claude-proxy.json ]; then - claude-proxy & -fi - -exec "$@" -``` - -### 4.5.3 `sandbox/docker/v2/Dockerfile.test` - -For unit tests — adds Nginx with a simple WS echo server and SSE endpoint. - -```dockerfile -FROM yaoapp/sandbox-v2-base:latest - -USER root - -# Nginx + test services -RUN apt-get update && apt-get install -y nginx python3 && rm -rf /var/lib/apt/lists/* - -# WS echo server (Python, ~15 lines) -COPY ws-echo.py /opt/test/ws-echo.py - -# SSE endpoint (Python, ~15 lines) -COPY sse-server.py /opt/test/sse-server.py - -# Nginx config — proxy WS on :3000, SSE on :3001 -COPY nginx-test.conf /etc/nginx/sites-available/default - -# Test entrypoint — start nginx + test services + original entrypoint -COPY test-entrypoint.sh /usr/local/bin/test-entrypoint.sh -RUN chmod +x /usr/local/bin/test-entrypoint.sh - -USER sandbox -WORKDIR /workspace -ENTRYPOINT ["/usr/local/bin/test-entrypoint.sh"] -CMD ["sleep", "infinity"] -``` - -### 4.5.4 Test services - -**`ws-echo.py`** — WebSocket echo on port 3000: - -```python -#!/usr/bin/env python3 -import asyncio, websockets -async def echo(ws): - async for msg in ws: - await ws.send(msg) -asyncio.run(websockets.serve(echo, "0.0.0.0", 3000)) -``` - -**`sse-server.py`** — SSE endpoint on port 3001: - -```python -#!/usr/bin/env python3 -from http.server import HTTPServer, BaseHTTPRequestHandler -import time -class Handler(BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.send_header("Content-Type", "text/event-stream") - self.send_header("Cache-Control", "no-cache") - self.end_headers() - for i in range(5): - self.wfile.write(f"data: event-{i}\n\n".encode()) - self.wfile.flush() - time.sleep(0.1) -HTTPServer(("0.0.0.0", 3001), Handler).serve_forever() -``` - -**`test-entrypoint.sh`**: - -```bash -#!/bin/bash -python3 /opt/test/ws-echo.py & -python3 /opt/test/sse-server.py & -exec /usr/local/bin/entrypoint.sh "$@" -``` - -### 4.5.5 Build script update - -Add `v2` and `v2-test` targets to `sandbox/docker/build.sh`: - -```bash -v2) - echo "=== Building V2 images ===" - # Build yao-grpc binary (replaces yao-bridge) - cd "$SCRIPT_DIR/../../tai/grpc/cmd" - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/v2/yao-grpc-amd64" . - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/v2/yao-grpc-arm64" . - cd "$SCRIPT_DIR" - - # Build claude-proxy binary - cd "$SCRIPT_DIR/../proxy/cmd/claude-proxy" - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/v2/claude-proxy-amd64" . - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/v2/claude-proxy-arm64" . - cd "$SCRIPT_DIR" - - build_multiarch "sandbox-v2-base" "v2/Dockerfile.base" "$PUSH" - build_multiarch "sandbox-v2-test" "v2/Dockerfile.test" "$PUSH" - ;; -``` - -### 4.5.6 Image usage - -| Image | Purpose | Used by | -|-------|---------|---------| -| `sandbox-v2-base` | Production base for V2 sandboxes. Has `yao-grpc` (heartbeat) + `claude-proxy`. | `Manager.Create()` default image candidate | -| `sandbox-v2-test` | Unit tests. Has WS echo + SSE server for Attach testing. | `SANDBOX_TEST_IMAGE` in CI and local dev | - -### 4.5.7 Env update - -```bash -# env.local.sh — change test image to v2-test -export SANDBOX_TEST_IMAGE="yaoapp/sandbox-v2-test:latest" -``` - -**Estimated: ~5 files (Dockerfiles + scripts + test services), ~100 lines** - ---- - -## Step 5: `sandbox/v2` — Core Module - -**Files:** all in `sandbox/v2/` - -### 5.1 `errors.go` - -```go -var ( - ErrNotAvailable = errors.New("sandbox: not available (no pools configured)") - ErrNotFound = errors.New("sandbox: not found") - ErrLimitExceeded = errors.New("sandbox: limit exceeded") - ErrPoolNotFound = errors.New("sandbox: pool not found") - ErrPoolInUse = errors.New("sandbox: pool has running boxes") -) -``` - -### 5.2 `types.go` - -All type definitions from DESIGN.md: -- `LifecyclePolicy`, `Pool`, `PoolInfo`, `PortMapping` -- `CreateOptions`, `ListOptions` -- `ExecOption`, `ExecResult`, `ExecStream` -- `AttachOption`, `ServiceConn`, `ConnectOptions` -- `BoxInfo` - -### 5.3 `config.go` - -```go -type Config struct { - Pool []Pool -} -``` - -### 5.4 `sandbox.go` — singleton - -```go -var mgr *Manager - -func Init(cfg Config) error { - m, err := newManager(cfg) - if err != nil { return err } - mgr = m - return nil -} - -func M() *Manager { - if mgr == nil { panic("sandbox.Init not called") } - return mgr -} -``` - -### 5.5 `manager.go` - -Core implementation. Key methods: - -| Method | Logic | -|--------|-------| -| `newManager(cfg)` | Parse pool defs, set default pool | -| `Start(ctx)` | For each pool: connect, list containers with `managed-by=yao-sandbox`, rebuild boxes map, start cleanupLoop | -| `Create(ctx, opts)` | Validate → check limits → resolve pool → lazy-connect tai.Client → create OAuth tokens → build tai.CreateOptions (merge env, labels, field mapping) → tai.Create → tai.Start → wrap Box → register | -| `Get(ctx, id)` | Lookup boxes map | -| `GetOrCreate(ctx, opts)` | Get by ID, if not found → Create | -| `List(ctx, opts)` | Filter boxes by owner/pool/labels | -| `Remove(ctx, id)` | Lookup box → tai.Stop → tai.Remove → revoke OAuth token → delete from map | -| `Cleanup(ctx)` | Range boxes, apply policy-based idle/lifetime rules | -| `Close()` | Cancel cleanup loop, close all tai.Clients | -| `Heartbeat(id, active, count)` | Lookup box → update lastHeartbeat + processCount atomics | -| `AddPool(ctx, p)` | Validate name unique → append to poolDefs | -| `RemovePool(ctx, name, force)` | Check no boxes (or force-remove them) → remove from poolDefs → close tai.Client if connected | -| `Pools()` | Return PoolInfo slice | - -Internal helpers: -- `getPool(name)` — lazy-connect tai.Client from poolDefs -- `buildTaiCreateOptions(opts, pool)` — field mapping + env injection + label injection -- `recoverBoxes(ctx, pool, client)` — list + parse labels + rebuild Box structs - -### 5.6 `box.go` - -```go -type Box struct { /* fields from DESIGN.md */ } - -func (b *Box) Exec(ctx, cmd, opts) // b.touch() → tai.Sandbox().Exec(b.containerID, ...) -func (b *Box) Stream(ctx, cmd, opts) // b.touch() → tai.Sandbox().ExecStream(b.containerID, ...) -func (b *Box) Attach(ctx, port, opts) // b.touch() → tai.Proxy().Connect(b.containerID, port, ...) -func (b *Box) Workspace() // lazy-init: tai.Client.Workspace(b.id) -func (b *Box) VNC(ctx) // b.touch() → tai.VNC().URL(b.containerID) -func (b *Box) Proxy(ctx, port, path) // b.touch() → tai.Proxy().URL(b.containerID, port, path) -func (b *Box) Start(ctx) // tai.Sandbox().Start(b.containerID) -func (b *Box) Stop(ctx) // tai.Sandbox().Stop(b.containerID, 10s) -func (b *Box) Remove(ctx) // b.manager.Remove(ctx, b.id) -func (b *Box) Info(ctx) // tai.Sandbox().Inspect + merge with box metadata - -func (b *Box) touch() // b.lastCall.Store(time.Now().UnixMilli()) -func (b *Box) lastActiveTime() Time // max(lastCall, lastHeartbeat) -func (b *Box) idleTimeout() Duration // box-level override or pool default -func (b *Box) maxLifetime() Duration // pool default -``` - -### 5.7 `grpc.go` — OAuth token injection - -```go -func createContainerTokens(sandboxID, owner string) (access, refresh string, err error) -func revokeContainerTokens(refresh string) error -func buildGRPCEnv(pool *Pool, sandboxID, access, refresh string) map[string]string -``` - -Uses `openapi/oauth` to create token pairs. Local mode: `YAO_GRPC_ADDR=127.0.0.1:`. Remote mode: adds `YAO_GRPC_TAI=enable` + `YAO_GRPC_UPSTREAM`. - -**Estimated: ~600 lines code total** - ---- - -## Step 6: Tests - -### 6.1 Test environment - -Two pools configured via env vars (reuse existing CI infrastructure): - -``` -# Local pool — direct Docker -SANDBOX_TEST_LOCAL_ADDR=local (default Docker daemon) - -# Remote pool — via Tai container (same as tai-test job) -SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1 (uses TAI_TEST_* ports) -``` - -Skip tests when Docker/Tai unavailable: `t.Skipf`. - -### 6.2 Test files - -| File | Coverage | -|------|----------| -| `sandbox_test.go` | `Init()`, `M()`, singleton behavior | -| `manager_test.go` | Create, Get, GetOrCreate, List, Remove, pool management | -| `manager_lifecycle_test.go` | Start (container discovery), Cleanup, idle tracking | -| `box_test.go` | Exec, Stream, Workspace (ReadFile/WriteFile/MkdirAll), Proxy, VNC, lifecycle | -| `box_attach_test.go` | Attach with WS/SSE (requires service in container) | -| `grpc_test.go` | Token creation/revocation, env var building | - -### 6.3 Key test scenarios - -| Test | What it verifies | -|------|-----------------| -| `TestCreateAndExec` | Create box → exec `echo hello` → verify stdout → remove | -| `TestCreateWithLabels` | Create → inspect labels → list with label filter | -| `TestWorkspace` | Create → WriteFile → ReadFile → verify content match | -| `TestIdleCleanup` | Create with Session + 1s idle timeout → wait → verify removed | -| `TestStartRecovery` | Create → restart Manager → Start → verify box recovered from labels | -| `TestPoolLimits` | Set MaxTotal=1 → create 1 → create 2nd → verify ErrLimitExceeded | -| `TestHeartbeatUpdates` | Create → call Heartbeat → verify lastActive updated | -| `TestStream` | Create → stream `sh -c "echo a; sleep 0.1; echo b"` → verify chunks arrive | -| `TestMultiPool` | Create on local → create on remote → verify both work | - -### 6.4 CI integration - -Add `sandbox-v2-test` job to `unit-test.yml` (same pattern as existing `sandbox-test` + `tai-test`): - -```yaml -sandbox-v2-test: - runs-on: ubuntu-latest - services: - # MongoDB (for Yao runtime) - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - - name: Start Tai container - run: | - docker run -d --name tai \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -p 2375:2375 -p 9100:9100 -p 8080:8080 \ - yaoapp/tai:latest - - name: Build V2 test image - run: | - cd sandbox/docker - bash build.sh v2 - - name: Run tests - env: - SANDBOX_TEST_LOCAL_ADDR: "local" - SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1" - SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest" - TAI_TEST_HOST: "127.0.0.1" - run: go test -v -count=1 ./sandbox/v2/... -``` - -**Estimated: ~400 lines tests** - ---- - -## Summary - -| Step | Package | Lines (code) | Lines (test) | Depends on | -|------|---------|-------------|-------------|------------| -| 0 | `tai/sandbox` | ~20 | ~60 | — | -| 1 | `tai/sandbox` | ~80 | ~100 | — | -| 2 | `tai/proxy` | ~120 | ~80 | — | -| 3 | `yao/grpc` | ~40 | ~50 | — | -| 4 | `tai/grpc` | ~40 | ~40 | Step 3 | -| 4.5 | `sandbox/docker/v2` | ~100 | — | Steps 1–4 | -| 5 | `sandbox/v2` | ~600 | — | Steps 0–4 | -| 6 | `sandbox/v2` | — | ~400 | Steps 5 + 4.5 | -| **Total** | | **~1000** | **~730** | | - -Steps 0–3 can start in parallel. Step 4 needs Step 3's proto. Step 5 needs all prerequisites done. Step 6 runs after Step 5. +## File Inventory + +### sandbox/v2 (7 source + 10 test = 17 files) + +| File | Lines | Purpose | +|------|-------|---------| +| `sandbox.go` | ~25 | Global singleton | +| `manager.go` | ~620 | Manager implementation | +| `box.go` | ~230 | Box implementation | +| `types.go` | ~170 | Type definitions | +| `config.go` | ~5 | Config struct | +| `errors.go` | ~10 | Error definitions | +| `grpc.go` | ~55 | Token/env injection | +| `testutils_test.go` | ~130 | Test helpers | +| `sandbox_test.go` | ~30 | Singleton tests | +| `manager_test.go` | ~250 | CRUD tests | +| `manager_lifecycle_test.go` | ~120 | Lifecycle tests | +| `box_test.go` | ~200 | Box tests | +| `box_attach_test.go` | ~260 | Attach/VNC tests | +| `box_workspace_test.go` | ~285 | Workspace tests | +| `box_image_test.go` | ~120 | Image tests | +| `grpc_test.go` | ~80 | Token tests | +| `bench_test.go` | ~230 | Benchmarks | + +### workspace (3 source + 4 test = 7 files) + +| File | Lines | Purpose | +|------|-------|---------| +| `workspace.go` | ~80 | Types + metadata | +| `manager.go` | ~320 | Manager implementation | +| `errors.go` | ~10 | Error definitions | +| `testutils_test.go` | ~90 | Test helpers | +| `workspace_test.go` | ~325 | CRUD tests | +| `fileio_test.go` | ~235 | File I/O tests | +| `bench_test.go` | ~150 | Benchmarks |