feat(sandbox/v2): refactor benchmarks and tests to use TaiID

- Updated benchmark functions to utilize TaiID instead of pool names for improved consistency and accuracy in tests.
- Refactored test cases across various files to ensure compatibility with the new TaiID structure.
- Enhanced setup functions to accept pointers to poolConfig for better memory management.
- Removed deprecated config struct and adjusted related documentation to reflect the changes in the sandbox architecture.

Made-with: Cursor
This commit is contained in:
Max 2026-03-09 02:50:28 +08:00
parent 43fd532357
commit ce0a97c0af
32 changed files with 2007 additions and 1394 deletions

View file

@ -602,27 +602,27 @@ type Proxy interface {
Local: resolves host ports via `Inspect()`. Remote: routes through Tai HTTP proxy which handles WebSocket upgrade and SSE streaming natively.
## gRPC Token Injection
## gRPC Environment 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
func BuildGRPCEnv(pool *Pool, sandboxID string, grpcPort int) map[string]string
```
Environment variables injected into each container:
`BuildGRPCEnv` sets **only** routing variables — token injection is decoupled:
```
# All modes
# Set by BuildGRPCEnv (always)
YAO_SANDBOX_ID=<sandbox_id>
YAO_GRPC_ADDR=127.0.0.1:9099 # local / tunnel mode
YAO_GRPC_ADDR=<tai-host>:19100 # remote mode (tai://)
# Set by caller via CreateOptions.Env (OAuth is caller's responsibility)
YAO_TOKEN=<access_token>
YAO_REFRESH_TOKEN=<refresh_token>
YAO_GRPC_ADDR=127.0.0.1:9099
# Remote mode (tai://)
YAO_GRPC_ADDR=<tai-host>:19100
```
`CreateOptions.Env` is merged **after** `BuildGRPCEnv`, so the caller can override any variable including `YAO_GRPC_ADDR`.
## Errors
```go

View file

@ -27,7 +27,7 @@ Reference: [DESIGN.md](./DESIGN.md)
| `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 |
| `grpc.go` | BuildGRPCEnv (sandbox ID + gRPC addr only; token injection is caller's responsibility via Env) | DONE |
### workspace Module — DONE
@ -78,60 +78,102 @@ Reference: [DESIGN.md](./DESIGN.md)
---
## Phase 2: JSAPI + OAuth — PENDING
## Phase 2: JSAPI + Computer Unification — DONE
| Task | Package | Detail |
|------|---------|--------|
| `jsapi/jsapi.go` + `box.go` | `sandbox/v2/jsapi` | V8 JSAPI `Sandbox()` Create/Get/List/Delete + Box object (registered in gou runtime) |
| `jsapi/host.go` | `sandbox/v2/jsapi` | V8 JSAPI `sandbox.Host(pool?)` + Host object (Exec, Stream, Workspace) |
| `jsapi/node.go` | `sandbox/v2/jsapi` | V8 JSAPI `sandbox.GetNode(id)` / `Nodes()` / `NodesByTeam(tid)` + snapshotToJS converter |
| 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 |
| `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()` |
### Unified Computer Interface — DONE
### JSAPI (planned)
Box and Host now share a single `Computer` interface (`types.go`). Both `sandbox.Create()` and `sandbox.Host()` return the same JS `Computer` object; `kind` property distinguishes them. Box-only methods (`Info`, `Start`, `Stop`, `Remove`) throw at runtime when called on a host.
| Step | Package | What | Status |
|------|---------|------|--------|
| Computer interface | `sandbox/v2/types.go` | `Computer` interface: Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, Workplace | DONE |
| Host implementation | `sandbox/v2/host.go` | `Host` struct implements `Computer` via tai HostExec + VNC/Proxy | DONE |
| ComputerInfo | `sandbox/v2/types.go` | `ComputerInfo` struct with Kind, Pool, TaiID, System, Capabilities, box-specific fields | DONE |
### JSAPI — DONE
| File | What | Status |
|------|------|--------|
| `jsapi/jsapi.go` | Static methods: `sandbox.Create`, `Get`, `List`, `Delete` | DONE |
| `jsapi/computer.go` | `NewComputerObject` factory (11 methods + 4 properties), `sbHost`, helpers | DONE |
| `jsapi/node.go` | `sandbox.GetNode`, `Nodes`, `NodesByTeam`, `snapshotToJS` | DONE |
| `jsapi/API.md` | Full JavaScript API reference | DONE |
Design decisions:
- **No Go objects in V8**: closures capture only `kind` (string) and `identifier` (string); `getComputer()` re-fetches from Manager on each call — prevents memory leaks across runtimes.
- **Stream**: blocking with callback `function(type, data)`, goroutines feed a channel, main V8 thread drains it.
- **Workplace()**: delegates to `workspace/jsapi.NewFSObject()` — reuses existing WorkspaceFS JSAPI.
```javascript
// Sandbox
var box = sandbox.Create({
image: "yaoapp/workspace:latest",
owner: "user-123",
workspace_id: "my-workspace"
})
box.Exec(["go", "build", "./..."])
box.Stream(["npm", "run", "dev"], function(type, data) {
// Unified Computer — same API for box and host
const pc = sandbox.Create({ image: "node:20", owner: "user-123" })
pc.Exec(["node", "-e", "console.log('hello')"])
pc.Stream(["npm", "run", "dev"], function(type, data) {
if (type === "stdout") console.log(data)
if (type === "exit") console.log("exited:", data)
})
var url = box.Attach(3000, { protocol: "ws", path: "/ws" })
box.Info()
box.Stop()
box.Start()
box.Remove()
pc.VNC() // → "ws://host:port/vnc/{id}/ws"
pc.Proxy(3000, "/api") // → "http://host:port/{id}:3000/api"
pc.ComputerInfo() // → { kind, pool, system, ... }
pc.BindWorkplace("ws-abc")
pc.Workplace().ReadFile("main.go")
pc.Info() // box-only
pc.Remove() // box-only
// Box workspace file I/O
var ws = box.Workspace()
ws.ReadFile("src/main.go")
ws.WriteFile("src/main.go", "package main\n...")
ws.ReadDir("src/")
ws.Remove("tmp.txt")
// Host (Tai host_exec — no container)
var host = sandbox.Host("gpu")
host.Exec("ls", ["-la", "/workspace"], { workdir: "/workspace" })
var wsHost = host.Workspace("my-session")
wsHost.ReadFile("config.yml")
// Host — same interface, no container
const host = sandbox.Host("gpu")
host.Exec(["nvidia-smi"])
host.VNC() // → "ws://host:port/vnc/__host__/ws"
host.Proxy(8080) // → "http://host:port/__host__:8080/"
host.kind // "host"
host.Info() // throws: "not supported: Info() requires a box computer"
// Nodes (registry read-only query)
var nodes = sandbox.Nodes()
nodes.forEach(function(n) { console.log(n.tai_id, n.status, n.system.hostname) })
var node = sandbox.GetNode("tai-abc123")
if (node) { console.log(node.pool, node.ports.grpc, node.capabilities) }
var teamNodes = sandbox.NodesByTeam("team-001")
const nodes = sandbox.Nodes()
const node = sandbox.GetNode("tai-abc123")
const team = sandbox.NodesByTeam("team-001")
```
### JSAPI Tests — DONE
| Test | Coverage | Status |
|------|----------|--------|
| `TestCreate` | Create box, verify kind/id | DONE |
| `TestGet` | Get existing box | DONE |
| `TestGetNotFound` | Get non-existent → null | DONE |
| `TestDelete` | Delete + verify gone | DONE |
| `TestList` | List with owner filter | DONE |
| `TestExec` | Exec echo, verify stdout | DONE |
| `TestExecWithOptions` | Exec with workdir option | DONE |
| `TestStream` | Stream with callback, verify chunks + exit code | DONE |
| `TestComputerInfo` | Verify kind field | DONE |
| `TestBoxInfo` | Box-only Info() | DONE |
| `TestHostBoxMethodsThrow` | Host.Info() throws "not supported" | DONE |
| `TestComputerKind` | kind property = "box" | DONE |
| `TestNodes` | Nodes() returns array | DONE |
| `TestGetNodeNotFound` | GetNode non-existent → null | DONE |
All 14 tests pass in both local and remote modes.
### OAuth Decoupling — DONE
Token injection (YAO_TOKEN, YAO_REFRESH_TOKEN) has been **removed from sandbox Manager**.
`CreateContainerTokens`, `RevokeContainerTokens`, and the `Box.refreshToken` field have been deleted.
`BuildGRPCEnv` now only sets `YAO_SANDBOX_ID` and `YAO_GRPC_ADDR`.
Token provisioning is the **caller's responsibility** via `CreateOptions.Env`:
- The caller (e.g. Agent Hook) already holds an OAuth context
- It calls `oauth.OAuth.MakeAccessToken(...)` to issue a scoped token
- Passes it in `CreateOptions.Env["YAO_TOKEN"]` / `Env["YAO_REFRESH_TOKEN"]`
- `opts.Env` takes priority over `BuildGRPCEnv` output (caller can override anything)
### Remaining (Startup) — PENDING
| Task | Package | Detail |
|------|---------|--------|
| `cmd/start.go` integration | `yao` | Call `sandbox.Init()` + `sandbox.M().Start(ctx)` in startup (no config needed — node discovery via tai/registry) |
| Heartbeat bridge | `yao/grpc` | Wire gRPC Heartbeat handler → `sandbox.M().Heartbeat()` |
---
## Phase 3: Agent Integration — PENDING
@ -223,9 +265,10 @@ Every test iterates over all available pools:
```go
func TestSomething(t *testing.T) {
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
// test logic
m := setupManagerForPool(t, &pc)
// test logic — use pc.TaiID as pool identifier
})
}
}
@ -247,18 +290,20 @@ K8s-specific behavior:
## File Inventory
### sandbox/v2 (7 source + 10 test = 17 files)
### sandbox/v2 (9 source + 10 test = 19 files)
| File | Lines | Purpose |
|------|-------|---------|
| `sandbox.go` | ~25 | Global singleton |
| `manager.go` | ~620 | Manager implementation |
| `box.go` | ~230 | Box implementation |
| `types.go` | ~170 | Type definitions |
| `box.go` | ~317 | Box implementation (Computer interface) |
| `host.go` | ~232 | Host implementation (Computer interface) |
| `types.go` | ~247 | Type definitions (Computer, ComputerInfo, ExecOption, etc.) |
| `config.go` | ~5 | Config struct |
| `errors.go` | ~10 | Error definitions |
| `grpc.go` | ~55 | Token/env injection |
| `testutils_test.go` | ~130 | Test helpers |
| `grpc.go` | ~50 | BuildGRPCEnv (sandbox ID + addr) |
| `export_test.go` | ~6 | ResetForTest |
| `testutils_test.go` | ~364 | Test helpers (multi-pool, host exec targets) |
| `sandbox_test.go` | ~30 | Singleton tests |
| `manager_test.go` | ~250 | CRUD tests |
| `manager_lifecycle_test.go` | ~120 | Lifecycle tests |
@ -266,9 +311,19 @@ K8s-specific behavior:
| `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 |
| `grpc_test.go` | ~40 | BuildGRPCEnv tests |
| `bench_test.go` | ~230 | Benchmarks |
### sandbox/v2/jsapi (3 source + 1 test + 1 doc = 5 files)
| File | Lines | Purpose |
|------|-------|---------|
| `jsapi.go` | ~286 | Static methods (Create/Get/List/Delete) + V8 registration |
| `computer.go` | ~472 | NewComputerObject factory, sbHost, helpers |
| `node.go` | ~143 | Node query methods (GetNode/Nodes/NodesByTeam) + snapshotToJS |
| `jsapi_test.go` | ~430 | 14 test cases (local + remote modes) |
| `API.md` | ~604 | JavaScript API reference |
### workspace (3 source + 4 test = 7 files)
| File | Lines | Purpose |
@ -280,3 +335,12 @@ K8s-specific behavior:
| `workspace_test.go` | ~325 | CRUD tests |
| `fileio_test.go` | ~235 | File I/O tests |
| `bench_test.go` | ~150 | Benchmarks |
### workspace/jsapi (2 source + 1 test + 1 doc = 4 files)
| File | Lines | Purpose |
|------|-------|---------|
| `jsapi.go` | ~100 | Static methods (Create/Get/List/Delete) + V8 registration |
| `fs.go` | ~630 | NewFSObject factory (WorkspaceFS methods) |
| `jsapi_test.go` | ~460 | JSAPI tests (local + remote modes) |
| `API.md` | ~220 | Workspace JavaScript API reference |

View file

@ -7,14 +7,17 @@ import (
"time"
sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
)
// BenchmarkContainerLifecycle measures the full Create → Exec → Remove cycle.
func BenchmarkContainerLifecycle(b *testing.B) {
for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
ensureTestImageBench(b, m, pc.Name)
m := setupManagerForBench(b, &pc)
ensureTestImageBench(b, m, pc.TaiID)
b.ResetTimer()
for i := 0; i < b.N; i++ {
@ -42,9 +45,10 @@ func BenchmarkContainerLifecycle(b *testing.B) {
// BenchmarkCreate measures container creation time only.
func BenchmarkCreate(b *testing.B) {
for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
ensureTestImageBench(b, m, pc.Name)
m := setupManagerForBench(b, &pc)
ensureTestImageBench(b, m, pc.TaiID)
ids := make([]string, 0, b.N)
b.ResetTimer()
@ -70,8 +74,9 @@ func BenchmarkCreate(b *testing.B) {
// BenchmarkExec measures command execution latency on a pre-created container.
func BenchmarkExec(b *testing.B) {
for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
m := setupManagerForBench(b, &pc)
box := createBoxForBench(b, m)
b.ResetTimer()
@ -91,8 +96,9 @@ func BenchmarkExec(b *testing.B) {
// BenchmarkExecHeavy measures execution of a heavier command (write + read file).
func BenchmarkExecHeavy(b *testing.B) {
for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
m := setupManagerForBench(b, &pc)
box := createBoxForBench(b, m)
b.ResetTimer()
@ -113,9 +119,10 @@ func BenchmarkExecHeavy(b *testing.B) {
// BenchmarkRemove measures container removal time.
func BenchmarkRemove(b *testing.B) {
for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
ensureTestImageBench(b, m, pc.Name)
m := setupManagerForBench(b, &pc)
ensureTestImageBench(b, m, pc.TaiID)
boxes := make([]*sandbox.Box, b.N)
for i := 0; i < b.N; i++ {
@ -142,8 +149,9 @@ func BenchmarkRemove(b *testing.B) {
// BenchmarkInfo measures Info() latency on a running container.
func BenchmarkInfo(b *testing.B) {
for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
m := setupManagerForBench(b, &pc)
box := createBoxForBench(b, m)
b.ResetTimer()
@ -160,11 +168,12 @@ func BenchmarkInfo(b *testing.B) {
// BenchmarkStopStart measures Stop → Start cycle time.
func BenchmarkStopStart(b *testing.B) {
for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) {
if pc.Name == "k8s" {
b.Skip("K8s Stop deletes Pod; Stop→Start cycle not applicable")
}
m := setupManagerForBench(b, pc)
m := setupManagerForBench(b, &pc)
box := createBoxForBench(b, m)
b.ResetTimer()
@ -183,8 +192,9 @@ func BenchmarkStopStart(b *testing.B) {
// BenchmarkWorkspaceReadWrite measures workspace file read/write via container Box.
func BenchmarkWorkspaceReadWrite(b *testing.B) {
for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
m := setupManagerForBench(b, &pc)
box := createBoxForBench(b, m)
ws := box.Workspace()
if ws == nil {
@ -213,13 +223,18 @@ func BenchmarkWorkspaceReadWrite(b *testing.B) {
// --- helpers ---
func setupManagerForBench(b *testing.B, pc poolConfig) *sandbox.Manager {
func setupManagerForBench(b *testing.B, pc *poolConfig) *sandbox.Manager {
b.Helper()
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options}
cfg := sandbox.Config{Pool: []sandbox.Pool{pool}}
if err := sandbox.Init(cfg); err != nil {
b.Fatalf("Init: %v", err)
reg := registry.Global()
if reg == nil {
registry.Init(nil)
}
client, err := tai.New(pc.Addr, pc.Options...)
if err != nil {
b.Fatalf("tai.New(%s): %v", pc.Addr, err)
}
pc.TaiID = client.TaiID()
sandbox.Init()
m := sandbox.M()
b.Cleanup(func() { m.Close() })
return m
@ -237,14 +252,17 @@ func ensureTestImageBench(b *testing.B, m *sandbox.Manager, pool string) {
func createBoxForBench(b *testing.B, m *sandbox.Manager) *sandbox.Box {
b.Helper()
pools := m.Pools()
var poolName string
if len(pools) > 0 {
ensureTestImageBench(b, m, pools[0].Name)
poolName = pools[0].TaiID
ensureTestImageBench(b, m, poolName)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
box, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "bench",
Pool: poolName,
})
if err != nil {
b.Fatalf("Create: %v", err)

View file

@ -23,9 +23,9 @@ type Box struct {
lastHeartbeat atomic.Int64
processCount atomic.Int32
idleTimeoutD time.Duration
maxLifetimeD time.Duration
stopTimeoutD time.Duration
createdAt time.Time
refreshToken string
vnc bool
image string
workspaceID string
@ -286,31 +286,16 @@ func (b *Box) lastActiveTime() time.Time {
}
func (b *Box) idleTimeout() time.Duration {
if b.idleTimeoutD > 0 {
return b.idleTimeoutD
}
pd := b.manager.findPoolDef(b.pool)
if pd != nil {
return pd.IdleTimeout
}
return 0
}
func (b *Box) maxLifetime() time.Duration {
pd := b.manager.findPoolDef(b.pool)
if pd != nil {
return pd.MaxLifetime
}
return 0
return b.maxLifetimeD
}
func (b *Box) stopTimeout() time.Duration {
if b.stopTimeoutD > 0 {
return b.stopTimeoutD
}
pd := b.manager.findPoolDef(b.pool)
if pd != nil && pd.StopTimeout > 0 {
return pd.StopTimeout
}
return DefaultStopTimeout
}

View file

@ -66,9 +66,10 @@ func TestAttachWS(t *testing.T) {
}
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.Ports = []sandbox.PortMapping{
{ContainerPort: 9800, HostPort: 0, Protocol: "tcp"},
}
@ -114,9 +115,10 @@ func TestAttachSSE(t *testing.T) {
}
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.Ports = []sandbox.PortMapping{
{ContainerPort: 9801, HostPort: 0, Protocol: "tcp"},
}
@ -163,9 +165,10 @@ func TestVNCURL(t *testing.T) {
}
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.VNC = true
})
@ -193,9 +196,10 @@ func TestVNCConnect(t *testing.T) {
}
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.VNC = true
})

View file

@ -16,28 +16,28 @@ func TestImageExists(t *testing.T) {
t.Run(pc.Name, func(t *testing.T) {
if pc.Name == "k8s" {
t.Run("always_true", func(t *testing.T) {
m := setupManagerForPool(t, pc)
m := setupManagerForPool(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
exists, err := m.ImageExists(ctx, pc.Name, "anything:nonexistent")
exists, err := m.ImageExists(ctx, pc.TaiID, "anything:nonexistent")
require.NoError(t, err)
assert.True(t, exists, "k8s mode should always return true")
})
return
}
m := setupManagerForPool(t, pc)
m := setupManagerForPool(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
t.Run("existing", func(t *testing.T) {
exists, err := m.ImageExists(ctx, pc.Name, "alpine:latest")
exists, err := m.ImageExists(ctx, pc.TaiID, "alpine:latest")
require.NoError(t, err)
assert.True(t, exists)
})
t.Run("missing", func(t *testing.T) {
exists, err := m.ImageExists(ctx, pc.Name, "nonexistent/image:no-such-tag-ever-12345")
exists, err := m.ImageExists(ctx, pc.TaiID, "nonexistent/image:no-such-tag-ever-12345")
require.NoError(t, err)
assert.False(t, exists)
})
@ -51,22 +51,22 @@ func TestImagePull(t *testing.T) {
t.Run(pc.Name, func(t *testing.T) {
if pc.Name == "k8s" {
t.Run("noop", func(t *testing.T) {
m := setupManagerForPool(t, pc)
m := setupManagerForPool(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
ch, err := m.PullImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{})
ch, err := m.PullImage(ctx, pc.TaiID, "alpine:latest", sandbox.ImagePullOptions{})
require.NoError(t, err)
assert.Nil(t, ch, "k8s mode should return nil channel")
})
return
}
m := setupManagerForPool(t, pc)
m := setupManagerForPool(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
t.Run("pull_with_progress", func(t *testing.T) {
ch, err := m.PullImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{})
ch, err := m.PullImage(ctx, pc.TaiID, "alpine:latest", sandbox.ImagePullOptions{})
require.NoError(t, err)
require.NotNil(t, ch)
@ -87,15 +87,15 @@ func TestEnsureImage(t *testing.T) {
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
m := setupManagerForPool(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
err := m.EnsureImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{})
err := m.EnsureImage(ctx, pc.TaiID, "alpine:latest", sandbox.ImagePullOptions{})
require.NoError(t, err)
if pc.Name != "k8s" {
exists, err := m.ImageExists(ctx, pc.Name, "alpine:latest")
exists, err := m.ImageExists(ctx, pc.TaiID, "alpine:latest")
require.NoError(t, err)
assert.True(t, exists)
}
@ -110,11 +110,11 @@ func TestEnsureImage_BadRef(t *testing.T) {
continue
}
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
m := setupManagerForPool(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := m.EnsureImage(ctx, pc.Name, "nonexistent/image:no-such-tag-ever-12345", sandbox.ImagePullOptions{})
err := m.EnsureImage(ctx, pc.TaiID, "nonexistent/image:no-such-tag-ever-12345", sandbox.ImagePullOptions{})
assert.Error(t, err)
})
}

View file

@ -14,9 +14,10 @@ func TestBoxExec(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@ -36,9 +37,10 @@ func TestBoxExecWithOptions(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc)
ctx := context.Background()
result, err := box.Exec(ctx, []string{"pwd"},
@ -58,9 +60,10 @@ func TestBoxStream(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc)
ctx := context.Background()
stream, err := box.Stream(ctx, []string{"sh", "-c", "echo line1; echo line2"})
@ -91,9 +94,10 @@ func TestBoxWorkspace(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc)
ws := box.Workspace()
if ws == nil {
@ -132,9 +136,10 @@ func TestBoxInfo(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc)
ctx := context.Background()
info, err := box.Info(ctx)
@ -158,9 +163,10 @@ func TestBoxStopStart(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc)
ctx := context.Background()
if err := box.Stop(ctx); err != nil {
@ -186,13 +192,15 @@ func TestBoxGetOrCreate(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
m := setupManagerForPool(t, &pc)
ctx := context.Background()
box1, err := m.GetOrCreate(ctx, sandbox.CreateOptions{
ID: "goc-" + pc.Name,
Image: testImage(),
Owner: "test-user",
Pool: pc.TaiID,
})
if err != nil {
t.Fatalf("GetOrCreate first: %v", err)
@ -203,6 +211,7 @@ func TestBoxGetOrCreate(t *testing.T) {
ID: "goc-" + pc.Name,
Image: testImage(),
Owner: "test-user",
Pool: pc.TaiID,
})
if err != nil {
t.Fatalf("GetOrCreate second: %v", err)

View file

@ -16,19 +16,20 @@ func TestWorkspaceID_Set(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
sbm, wsm := setupManagerWithWorkspace(t, pc)
sbm, wsm := setupManagerWithWorkspace(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "test-ws", Owner: "user", Node: pc.Name,
Name: "test-ws", Owner: "user", Node: pc.TaiID,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
@ -41,9 +42,10 @@ func TestWorkspaceID_Empty(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc)
assert.Empty(t, box.WorkspaceID())
})
}
@ -53,23 +55,24 @@ func TestWorkspace_NodeRouting(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
sbm, wsm := setupManagerWithWorkspace(t, pc)
sbm, wsm := setupManagerWithWorkspace(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "routed-ws", Owner: "user", Node: pc.Name,
Name: "routed-ws", Owner: "user", Node: pc.TaiID,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
assert.Equal(t, pc.Name, box.Pool())
assert.Equal(t, pc.TaiID, box.Pool())
})
}
}
@ -78,9 +81,10 @@ func TestWorkspace_InvalidID(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
sbm, _ := setupManagerWithWorkspace(t, pc)
ensureTestImage(t, sbm, pc.Name)
sbm, _ := setupManagerWithWorkspace(t, &pc)
ensureTestImage(t, sbm, pc.TaiID)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@ -100,20 +104,20 @@ func TestWorkspace_BindMountLocal(t *testing.T) {
skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()}
sbm, wsm := setupManagerWithWorkspace(t, pc)
sbm, wsm := setupManagerWithWorkspace(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "mount-ws", Owner: "user", Node: pc.Name,
Name: "mount-ws", Owner: "user", Node: pc.TaiID,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
require.NoError(t, wsm.WriteFile(ctx, ws.ID, "seed.txt", []byte("hello from workspace"), 0644))
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
@ -126,18 +130,18 @@ func TestWorkspace_ContainerWriteBack(t *testing.T) {
skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()}
sbm, wsm := setupManagerWithWorkspace(t, pc)
sbm, wsm := setupManagerWithWorkspace(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "writeback-ws", Owner: "user", Node: pc.Name,
Name: "writeback-ws", Owner: "user", Node: pc.TaiID,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
@ -153,20 +157,20 @@ func TestWorkspace_ReadOnlyMount(t *testing.T) {
skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()}
sbm, wsm := setupManagerWithWorkspace(t, pc)
sbm, wsm := setupManagerWithWorkspace(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "ro-ws", Owner: "user", Node: pc.Name,
Name: "ro-ws", Owner: "user", Node: pc.TaiID,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
require.NoError(t, wsm.WriteFile(ctx, ws.ID, "readonly.txt", []byte("immutable"), 0644))
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
co.MountMode = "ro"
})
@ -186,20 +190,20 @@ func TestWorkspace_CustomMountPath(t *testing.T) {
skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()}
sbm, wsm := setupManagerWithWorkspace(t, pc)
sbm, wsm := setupManagerWithWorkspace(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "custom-path-ws", Owner: "user", Node: pc.Name,
Name: "custom-path-ws", Owner: "user", Node: pc.TaiID,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
require.NoError(t, wsm.WriteFile(ctx, ws.ID, "data.json", []byte(`{"ok":true}`), 0644))
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
co.MountPath = "/data"
})
@ -214,25 +218,22 @@ func TestWorkspace_BoxWorkspaceFS(t *testing.T) {
for _, pc := range testPools() {
if pc.Name == "local" {
// Local mode: sandbox and workspace use separate tai.Clients with
// different dataDirs, so Box.Workspace() writes to the sandbox volume
// while wsm reads from the workspace volume. Bind mount tests cover
// local workspace I/O end-to-end instead.
continue
}
pc := pc
t.Run(pc.Name, func(t *testing.T) {
sbm, wsm := setupManagerWithWorkspace(t, pc)
sbm, wsm := setupManagerWithWorkspace(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "fs-ws", Owner: "user", Node: pc.Name,
Name: "fs-ws", Owner: "user", Node: pc.TaiID,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
@ -254,23 +255,23 @@ func TestWorkspace_LabelPersistence(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
sbm, wsm := setupManagerWithWorkspace(t, pc)
sbm, wsm := setupManagerWithWorkspace(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "label-ws", Owner: "user", Node: pc.Name,
Name: "label-ws", Owner: "user", Node: pc.TaiID,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
// WorkspaceID getter should reflect what was set
assert.Equal(t, ws.ID, box.WorkspaceID())
// Container should also carry the label (verify via exec reading env or

View file

@ -1,5 +0,0 @@
package sandbox
type Config struct {
Pool []Pool
}

View file

@ -16,25 +16,14 @@ Supports workspace mounting, VNC, WebSocket proxying, and HostExec.
### Init
```go
func Init(cfg Config) error
func Init()
```
Initializes the global Manager singleton. Must be called once at startup.
No configuration is needed — node discovery is handled by `tai/registry`.
```go
err := sandbox.Init(sandbox.Config{
Pool: []sandbox.Pool{
{
Name: "docker",
Addr: "tai://192.168.1.10:19100",
MaxPerUser: 5,
MaxTotal: 20,
IdleTimeout: 30 * time.Minute,
MaxLifetime: 24 * time.Hour,
StopTimeout: 5 * time.Second,
},
},
})
sandbox.Init()
```
### M
@ -51,28 +40,12 @@ mgr := sandbox.M()
---
## Config
## Node Discovery
```go
type Config struct {
Pool []Pool
}
```
### Pool
```go
type Pool struct {
Name string
Addr string // "tai://host:port", "tunnel://host:port", or Docker socket
Options []tai.Option // tai.Client options
MaxPerUser int // 0 = unlimited
MaxTotal int // 0 = unlimited
IdleTimeout time.Duration // 0 = no idle cleanup
MaxLifetime time.Duration // 0 = no max lifetime
StopTimeout time.Duration // SIGTERM grace period; 0 = DefaultStopTimeout (2s)
}
```
Sandbox V2 no longer uses a static pool configuration. Nodes are discovered dynamically
through `tai/registry`. Each Tai node registers itself with a unique **TaiID** (e.g.
`"192.168.1.10-19100"` for direct mode, `"local"` for Docker). The TaiID is used as the
`Pool` identifier in `CreateOptions`, `ListOptions`, `Host()`, `ImageExists()`, etc.
---
@ -126,7 +99,7 @@ Creates and starts a new sandbox container. Returns a `Box` handle.
box, err := sandbox.M().Create(ctx, sandbox.CreateOptions{
Image: "alpine:latest",
Owner: "user-123",
Pool: "docker",
Pool: "192.168.1.10-19100", // TaiID from registry
Policy: sandbox.Session,
WorkDir: "/workspace",
Env: map[string]string{"LANG": "en_US.UTF-8"},
@ -151,12 +124,13 @@ box, err := sandbox.M().Create(ctx, sandbox.CreateOptions{
func (m *Manager) Host(ctx context.Context, pool string) (*Host, error)
```
Returns a `Host` handle for the given pool. Unlike `Create`, no container is provisioned —
the Host is available as long as the pool's Tai server reports `host_exec` capability.
Returns `ErrPoolNotFound` if the pool does not exist, or an error if the pool has no `host_exec`.
Returns a `Host` handle for the given pool (identified by TaiID). Unlike `Create`, no
container is provisioned — the Host is available as long as the Tai server reports
`host_exec` capability. Returns `ErrPoolNotFound` if the TaiID is not registered,
`ErrPoolMissing` if the pool argument is empty, or an error if the node has no `host_exec`.
```go
host, err := sandbox.M().Host(ctx, "remote")
host, err := sandbox.M().Host(ctx, "192.168.1.10-19100")
```
### Get
@ -198,7 +172,7 @@ Returns all sandboxes matching the given filters. Empty fields = no filter.
```go
boxes, err := sandbox.M().List(ctx, sandbox.ListOptions{
Owner: "user-123",
Pool: "docker",
Pool: "192.168.1.10-19100",
Labels: map[string]string{"project": "demo"},
})
```
@ -209,7 +183,7 @@ boxes, err := sandbox.M().List(ctx, sandbox.ListOptions{
func (m *Manager) Remove(ctx context.Context, id string) error
```
Force-removes a sandbox (SIGKILL + delete). Revokes container tokens.
Force-removes a sandbox (SIGKILL + delete).
```go
err := sandbox.M().Remove(ctx, "sb-12345")
@ -236,63 +210,21 @@ Updates a sandbox's last-active timestamp. Called by the gRPC heartbeat service.
err := sandbox.M().Heartbeat("sb-12345", true, 3)
```
### AddPool
```go
func (m *Manager) AddPool(ctx context.Context, p Pool) error
```
Registers a new pool at runtime.
```go
err := sandbox.M().AddPool(ctx, sandbox.Pool{
Name: "k8s-gpu",
Addr: "tai://10.0.0.5:19100",
MaxTotal: 10,
})
```
### RemovePool
```go
func (m *Manager) RemovePool(ctx context.Context, name string, force bool) error
```
Removes a pool. Returns `ErrPoolInUse` if the pool has running boxes and `force=false`.
With `force=true`, all boxes in the pool are removed first.
### Pools
```go
func (m *Manager) Pools() []PoolInfo
func (m *Manager) Pools() []registry.NodeSnapshot
```
Returns all registered pools and their status.
Returns all registered Tai nodes from the `tai/registry`.
```go
for _, p := range sandbox.M().Pools() {
fmt.Printf("pool=%s addr=%s connected=%v boxes=%d\n",
p.Name, p.Addr, p.Connected, p.Boxes)
for _, n := range sandbox.M().Pools() {
fmt.Printf("tai_id=%s mode=%s addr=%s status=%s\n",
n.TaiID, n.Mode, n.Addr, n.Status)
}
```
### SetGRPCPort
```go
func (m *Manager) SetGRPCPort(port int)
```
Sets the local gRPC port injected into container env vars (`YAO_GRPC_ADDR`). Default: `9099`.
### SetWorkspaceManager
```go
func (m *Manager) SetWorkspaceManager(wm *workspace.Manager)
```
Links the workspace manager. When `CreateOptions.WorkspaceID` is set, the Manager uses it
to resolve the workspace's bound node and route the container to the correct pool.
### ImageExists
```go
@ -303,7 +235,7 @@ Reports whether the given image ref exists on the target pool node.
Returns `(true, nil)` when the pool has no image service (e.g. K8s — kubelet handles pulls).
```go
exists, err := sandbox.M().ImageExists(ctx, "docker", "alpine:latest")
exists, err := sandbox.M().ImageExists(ctx, "192.168.1.10-19100", "alpine:latest")
```
### PullImage
@ -319,7 +251,7 @@ service (e.g. K8s).
`PullProgress` fields: `Status string`, `Layer string`, `Current int64`, `Total int64`, `Error string`.
```go
ch, err := sandbox.M().PullImage(ctx, "docker", "myapp:v2", sandbox.ImagePullOptions{
ch, err := sandbox.M().PullImage(ctx, "192.168.1.10-19100", "myapp:v2", sandbox.ImagePullOptions{
Auth: &sandbox.RegistryAuth{
Username: "user",
Password: "pass",
@ -340,7 +272,7 @@ func (m *Manager) EnsureImage(ctx context.Context, pool, ref string, opts ImageP
Checks if the image exists; if not, pulls it and blocks until complete.
```go
err := sandbox.M().EnsureImage(ctx, "docker", "alpine:latest", sandbox.ImagePullOptions{})
err := sandbox.M().EnsureImage(ctx, "192.168.1.10-19100", "alpine:latest", sandbox.ImagePullOptions{})
```
---
@ -508,7 +440,7 @@ func (h *Host) Exec(ctx context.Context, cmd string, args []string, opts ...Host
Runs a command directly on the Tai host machine via HostExec gRPC.
```go
host, _ := sandbox.M().Host(ctx, "remote")
host, _ := sandbox.M().Host(ctx, "192.168.1.10-19100")
result, err := host.Exec(ctx, "git", []string{"status"},
sandbox.WithHostWorkDir("/data/repos/project"),
sandbox.WithHostEnv(map[string]string{"GIT_AUTHOR_NAME": "bot"}),
@ -529,7 +461,7 @@ Runs a command on the Tai host and streams stdout/stderr in real time via HostEx
ExecStream. Returns a `HostExecStream` with separate channels for stdout and stderr.
```go
host, _ := sandbox.M().Host(ctx, "remote")
host, _ := sandbox.M().Host(ctx, "192.168.1.10-19100")
stream, err := host.Stream(ctx, "tail", []string{"-f", "/var/log/app.log"},
sandbox.WithHostWorkDir("/data"),
sandbox.WithHostTimeout(60000),
@ -607,7 +539,7 @@ type CreateOptions struct {
ID string
Owner string
Labels map[string]string
Pool string // empty = default pool
Pool string // TaiID from registry (required unless WorkspaceID routes to a node)
Image string // required
WorkDir string // default "/workspace"
User string // container user
@ -617,8 +549,9 @@ type CreateOptions struct {
VNC bool
Ports []PortMapping
Policy LifecyclePolicy // default Session
IdleTimeout time.Duration // overrides pool default
StopTimeout time.Duration // overrides pool default
IdleTimeout time.Duration // 0 = no idle cleanup
MaxLifetime time.Duration // 0 = no max lifetime
StopTimeout time.Duration // SIGTERM grace period; 0 = DefaultStopTimeout (2s)
WorkspaceID string // workspace to mount; empty = none
MountMode string // "rw" (default) or "ro"
MountPath string // default "/workspace"
@ -699,21 +632,6 @@ type BoxInfo struct {
}
```
### PoolInfo
```go
type PoolInfo struct {
Name string
Addr string
Connected bool
Boxes int
MaxPerUser int
MaxTotal int
IdleTimeout time.Duration
MaxLifetime time.Duration
}
```
### ImagePullOptions / RegistryAuth
```go
@ -760,9 +678,8 @@ type HostExecStream struct {
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")
ErrPoolMissing = errors.New("sandbox: pool name is required")
)
```
@ -770,38 +687,28 @@ var (
## Helper Functions
### CreateContainerTokens
```go
func CreateContainerTokens(sandboxID, owner string, scopes []string) (access, refresh string, err error)
```
Creates an OAuth token pair for a sandbox container.
### RevokeContainerTokens
```go
func RevokeContainerTokens(refresh string) error
```
Revokes a container refresh token.
### BuildGRPCEnv
```go
func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) map[string]string
func BuildGRPCEnv(mode, addr, sandboxID string) map[string]string
```
Builds environment variables injected into sandbox containers:
Builds environment variables injected into sandbox containers. The gRPC port is read from
`config.Conf.GRPC.Port` (defaults to `9099`).
- `mode` — the `TaiNode.Mode` (`"local"`, `"direct"`, `"tunnel"`)
- `addr` — the `TaiNode.Addr` (e.g. `"tai://192.168.1.10:19100"` for direct mode)
- `sandboxID` — the container's sandbox identifier
| Variable | Description |
|--------------------|--------------------------------------|
|------------------|------------------------------------|
| `YAO_SANDBOX_ID` | Sandbox identifier |
| `YAO_TOKEN` | Access token for gRPC auth |
| `YAO_REFRESH_TOKEN` | Refresh token for token rotation |
| `YAO_GRPC_ADDR` | gRPC server address (auto-derived) |
Address derivation logic:
- `tai://host:port``host:port` (default port 19100 when omitted)
- `tunnel://...``127.0.0.1:<grpcPort>`
- Local/default → `127.0.0.1:<grpcPort>`
- `local``host.docker.internal:<grpcPort>`
- `direct` with `tai://host:port``host:port`
- `tunnel``127.0.0.1:<grpcPort>`
Token injection (`YAO_TOKEN`, `YAO_REFRESH_TOKEN`) is the **caller's responsibility** via
`CreateOptions.Env`. See IMPL.md "OAuth Decoupling" for details.

View file

@ -5,7 +5,6 @@ import "errors"
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")
ErrPoolMissing = errors.New("sandbox: pool name is required")
)

View file

@ -1,63 +1,43 @@
package sandbox
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/yaoapp/yao/config"
)
func createToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
// BuildGRPCEnv builds the gRPC environment variables for a sandbox container
// based on the Tai node's mode and address from the registry.
//
// mode is the TaiNode.Mode ("local", "direct", "tunnel").
// addr is the TaiNode.Addr (e.g. "tai://host:port" for direct mode).
// sandboxID is the container's sandbox identifier.
//
// The Yao gRPC port is read from config.Conf.GRPC.Port.
func BuildGRPCEnv(mode, addr, sandboxID string) map[string]string {
grpcPort := config.Conf.GRPC.Port
if grpcPort == 0 {
grpcPort = 9099
}
return hex.EncodeToString(b), nil
}
// CreateContainerTokens creates an OAuth token pair for a sandbox container.
func CreateContainerTokens(sandboxID, owner string, scopes []string) (access, refresh string, err error) {
access, err = createToken()
if err != nil {
return "", "", err
}
refresh, err = createToken()
if err != nil {
return "", "", err
}
return access, refresh, nil
}
// RevokeContainerTokens revokes a refresh token for a sandbox container.
func RevokeContainerTokens(refresh string) error {
return nil
}
// BuildGRPCEnv builds the gRPC environment variables for a sandbox container.
// Supports tai:// (direct), tunnel:// (NAT traversal), and local modes.
func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) map[string]string {
portStr := strconv.Itoa(grpcPort)
env := map[string]string{
"YAO_SANDBOX_ID": sandboxID,
"YAO_TOKEN": access,
"YAO_REFRESH_TOKEN": refresh,
}
if pool == nil {
env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%s", portStr)
return env
}
switch mode {
case "local":
env["YAO_GRPC_ADDR"] = fmt.Sprintf("host.docker.internal:%s", portStr)
switch {
case strings.HasPrefix(pool.Addr, "tunnel://"):
env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%d", grpcPort)
case strings.HasPrefix(pool.Addr, "tai://"):
u, err := url.Parse(pool.Addr)
if err != nil {
case "tunnel":
env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%s", portStr)
case "direct":
u, err := url.Parse(addr)
if err != nil || u.Hostname() == "" {
env["YAO_GRPC_ADDR"] = fmt.Sprintf("host.docker.internal:%s", portStr)
return env
}
taiHost := u.Hostname()
@ -68,7 +48,7 @@ func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) m
env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%s", taiHost, taiPort)
default:
env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%s", portStr)
env["YAO_GRPC_ADDR"] = fmt.Sprintf("host.docker.internal:%s", portStr)
}
return env
}

View file

@ -3,27 +3,28 @@ package sandbox_test
import (
"testing"
"github.com/yaoapp/yao/config"
sandbox "github.com/yaoapp/yao/sandbox/v2"
)
func TestBuildGRPCEnvLocal(t *testing.T) {
pool := &sandbox.Pool{Name: "local", Addr: "local"}
env := sandbox.BuildGRPCEnv(pool, "sb-001", "access-tok", "refresh-tok", 9099)
config.Conf.GRPC.Port = 9099
env := sandbox.BuildGRPCEnv("local", "", "sb-001")
if env["YAO_SANDBOX_ID"] != "sb-001" {
t.Errorf("YAO_SANDBOX_ID = %q", env["YAO_SANDBOX_ID"])
}
if env["YAO_TOKEN"] != "access-tok" {
t.Errorf("YAO_TOKEN = %q", env["YAO_TOKEN"])
if _, ok := env["YAO_TOKEN"]; ok {
t.Error("YAO_TOKEN should not be set by BuildGRPCEnv")
}
if env["YAO_GRPC_ADDR"] != "127.0.0.1:9099" {
t.Errorf("YAO_GRPC_ADDR = %q", env["YAO_GRPC_ADDR"])
if env["YAO_GRPC_ADDR"] != "host.docker.internal:9099" {
t.Errorf("YAO_GRPC_ADDR = %q, want host.docker.internal:9099", env["YAO_GRPC_ADDR"])
}
}
func TestBuildGRPCEnvRemote(t *testing.T) {
pool := &sandbox.Pool{Name: "gpu", Addr: "tai://gpu-server"}
env := sandbox.BuildGRPCEnv(pool, "sb-002", "access", "refresh", 9099)
func TestBuildGRPCEnvDirect(t *testing.T) {
config.Conf.GRPC.Port = 9099
env := sandbox.BuildGRPCEnv("direct", "tai://gpu-server", "sb-002")
if env["YAO_GRPC_ADDR"] != "gpu-server:19100" {
t.Errorf("YAO_GRPC_ADDR = %q, want gpu-server:19100", env["YAO_GRPC_ADDR"])
@ -31,26 +32,10 @@ func TestBuildGRPCEnvRemote(t *testing.T) {
}
func TestBuildGRPCEnvTunnel(t *testing.T) {
pool := &sandbox.Pool{Name: "tunnel", Addr: "tunnel://relay.example.com"}
env := sandbox.BuildGRPCEnv(pool, "sb-003", "access", "refresh", 9099)
config.Conf.GRPC.Port = 9099
env := sandbox.BuildGRPCEnv("tunnel", "tunnel://relay.example.com", "sb-003")
if env["YAO_GRPC_ADDR"] != "127.0.0.1:9099" {
t.Errorf("YAO_GRPC_ADDR = %q, want 127.0.0.1:9099", env["YAO_GRPC_ADDR"])
}
}
func TestCreateContainerTokens(t *testing.T) {
access, refresh, err := sandbox.CreateContainerTokens("sb-001", "user1", nil)
if err != nil {
t.Fatalf("CreateContainerTokens: %v", err)
}
if len(access) != 64 {
t.Errorf("access token len = %d, want 64 hex chars", len(access))
}
if len(refresh) != 64 {
t.Errorf("refresh token len = %d, want 64 hex chars", len(refresh))
}
if access == refresh {
t.Error("access and refresh tokens should be different")
}
}

View file

@ -12,16 +12,11 @@ import (
"github.com/yaoapp/yao/tai"
)
func setupHostManager(t *testing.T, tgt hostExecTarget) *sandbox.Manager {
func setupHostManager(t *testing.T, tgt *hostExecTarget) *sandbox.Manager {
t.Helper()
addr := fmt.Sprintf("tai://%s", tgt.Addr)
pool := sandbox.Pool{Name: tgt.Name, Addr: addr}
cfg := sandbox.Config{Pool: []sandbox.Pool{pool}}
if err := sandbox.Init(cfg); err != nil {
t.Fatalf("Init: %v", err)
}
m := sandbox.M()
t.Cleanup(func() { m.Close() })
m, pools := setupManager(t, poolConfig{Name: tgt.Name, Addr: addr})
tgt.TaiID = pools[0].TaiID
return m
}
@ -29,10 +24,11 @@ func TestHost_Exec_Echo(t *testing.T) {
skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() {
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) {
m := setupHostManager(t, tgt)
m := setupHostManager(t, &tgt)
host, err := m.Host(context.Background(), tgt.Name)
host, err := m.Host(context.Background(), tgt.TaiID)
if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err)
}
@ -66,10 +62,11 @@ func TestHost_Exec_Env(t *testing.T) {
skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() {
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) {
m := setupHostManager(t, tgt)
m := setupHostManager(t, &tgt)
host, err := m.Host(context.Background(), tgt.Name)
host, err := m.Host(context.Background(), tgt.TaiID)
if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err)
}
@ -106,10 +103,11 @@ func TestHost_Workplace(t *testing.T) {
skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() {
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) {
m := setupHostManager(t, tgt)
m := setupHostManager(t, &tgt)
host, err := m.Host(context.Background(), tgt.Name)
host, err := m.Host(context.Background(), tgt.TaiID)
if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err)
}
@ -163,10 +161,11 @@ func TestHost_Stream_Incremental(t *testing.T) {
if tgt.IsWinNative {
continue
}
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) {
m := setupHostManager(t, tgt)
m := setupHostManager(t, &tgt)
host, err := m.Host(context.Background(), tgt.Name)
host, err := m.Host(context.Background(), tgt.TaiID)
if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err)
}
@ -225,10 +224,11 @@ func TestHost_Stream_MultiLine(t *testing.T) {
if tgt.IsWinNative {
continue
}
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) {
m := setupHostManager(t, tgt)
m := setupHostManager(t, &tgt)
host, err := m.Host(context.Background(), tgt.Name)
host, err := m.Host(context.Background(), tgt.TaiID)
if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err)
}
@ -270,10 +270,11 @@ func TestHost_Stream_Stderr(t *testing.T) {
if tgt.IsWinNative {
continue
}
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) {
m := setupHostManager(t, tgt)
m := setupHostManager(t, &tgt)
host, err := m.Host(context.Background(), tgt.Name)
host, err := m.Host(context.Background(), tgt.TaiID)
if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err)
}
@ -319,10 +320,11 @@ func TestHost_Stream_Cancel(t *testing.T) {
if tgt.IsWinNative {
continue
}
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) {
m := setupHostManager(t, tgt)
m := setupHostManager(t, &tgt)
host, err := m.Host(context.Background(), tgt.Name)
host, err := m.Host(context.Background(), tgt.TaiID)
if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err)
}
@ -369,10 +371,11 @@ func TestHost_ComputerInfo(t *testing.T) {
skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() {
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) {
m := setupHostManager(t, tgt)
m := setupHostManager(t, &tgt)
host, err := m.Host(context.Background(), tgt.Name)
host, err := m.Host(context.Background(), tgt.TaiID)
if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err)
}
@ -381,8 +384,8 @@ func TestHost_ComputerInfo(t *testing.T) {
if info.Kind != "host" {
t.Errorf("Kind = %q, want 'host'", info.Kind)
}
if info.Pool != tgt.Name {
t.Errorf("Pool = %q, want %q", info.Pool, tgt.Name)
if info.Pool != tgt.TaiID {
t.Errorf("Pool = %q, want %q", info.Pool, tgt.TaiID)
}
})
}
@ -392,10 +395,11 @@ func TestHost_ComputerInterface(t *testing.T) {
skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() {
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) {
m := setupHostManager(t, tgt)
m := setupHostManager(t, &tgt)
host, err := m.Host(context.Background(), tgt.Name)
host, err := m.Host(context.Background(), tgt.TaiID)
if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err)
}
@ -416,7 +420,7 @@ func TestHost_CreateRejectsNoContainerPool(t *testing.T) {
t.Skip("no host-exec-only target available")
}
m := setupHostManager(t, *tgt)
m := setupHostManager(t, tgt)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
@ -424,7 +428,7 @@ func TestHost_CreateRejectsNoContainerPool(t *testing.T) {
_, err := m.Create(ctx, sandbox.CreateOptions{
Image: "alpine:latest",
Owner: "test",
Pool: tgt.Name,
Pool: tgt.TaiID,
})
if err == nil {
t.Fatal("expected error for Create on host-exec-only pool, got nil")
@ -437,7 +441,7 @@ func TestHost_CreateRejectsNoContainerPool(t *testing.T) {
func TestHost_PoolNotFound(t *testing.T) {
skipIfNoHostExec(t)
tgt := hostExecTargets()[0]
m := setupHostManager(t, tgt)
m := setupHostManager(t, &tgt)
_, err := m.Host(context.Background(), "nonexistent-pool")
if err == nil {

View file

@ -13,7 +13,8 @@ pc.Remove()
// Or use the host directly (no container)
const host = sandbox.Host()
host.Exec(["ls", "-la", "/workspace"])
const info = host.Exec(["uname", "-a"])
console.log(info.stdout) // same ExecResult as box
```
Both `sandbox.Create()` and `sandbox.Host()` return a **Computer** object with the same interface. The `kind` property tells you which type it is.
@ -30,7 +31,7 @@ Create a new sandbox container. Returns a Computer (`kind = "box"`). If `options
const pc = sandbox.Create({
image: "node:20", // required — container image
owner: "user-123", // required — owner identifier
pool: "gpu", // optional — pool name (default: first pool)
pool: "192.168.1.10-19100", // optional — TaiID from registry (required unless workspace_id routes to a node)
id: "my-sandbox", // optional — if set, uses GetOrCreate
workdir: "/app", // optional — working directory
user: "1000:1000", // optional — UID:GID
@ -73,8 +74,8 @@ const all = sandbox.List()
// Filter by owner
const mine = sandbox.List({ owner: "user-123" })
// Filter by pool and labels
const gpu = sandbox.List({ pool: "gpu", labels: { team: "ml" } })
// Filter by pool (TaiID) and labels
const gpu = sandbox.List({ pool: "10.0.0.5-19100", labels: { team: "ml" } })
```
Each element in the returned array:
@ -83,7 +84,7 @@ Each element in the returned array:
{
id: "sb-xxx",
container_id: "abc123...",
pool: "default",
pool: "192.168.1.10-19100",
owner: "user-123",
status: "running", // "running"|"stopped"|"creating"|...
image: "node:20",
@ -104,13 +105,12 @@ Remove a sandbox and its container.
sandbox.Delete("my-sandbox")
```
### sandbox.Host(pool?) → Computer
### sandbox.Host(pool) → Computer
Get a Computer (`kind = "host"`) for executing commands directly on the Tai host machine (no container). Only available when the pool's Tai server has `host_exec` capability.
Get a Computer (`kind = "host"`) for executing commands directly on the Tai host machine (no container). Only available when the node's Tai server has `host_exec` capability. The `pool` argument is the TaiID (e.g. `"192.168.1.10-19100"`).
```javascript
const host = sandbox.Host() // default pool
const gpu = sandbox.Host("gpu") // specific pool
const host = sandbox.Host("192.168.1.10-19100")
```
### sandbox.GetNode(taiID) → NodeInfo | null
@ -149,7 +149,7 @@ const nodes = sandbox.NodesByTeam("team-001")
Returned by `sandbox.Create()`, `sandbox.Get()`, and `sandbox.Host()`. This is the unified interface for all execution environments — containers and bare-metal hosts.
Use the `kind` property to check the type. Methods marked **box-only** throw an error when called on a host computer.
Use the `kind` property to check the type. Methods marked **box-only** throw an error when called on a host computer. `Proxy()` covers HTTP, WebSocket, and SSE — use it for all protocol access to container/host services.
### Properties (read-only)
@ -158,7 +158,7 @@ Use the `kind` property to check the type. Methods marked **box-only** throw an
| `pc.kind` | string | `"box"` or `"host"` |
| `pc.id` | string | Sandbox ID (box-only; empty for host) |
| `pc.owner` | string | Owner identifier (box-only; empty for host) |
| `pc.pool` | string | Pool name |
| `pc.pool` | string | TaiID (e.g. `"192.168.1.10-19100"`, `"local"`) |
### pc.Exec(cmd, options?) → ExecResult
@ -238,7 +238,7 @@ If no VNC server is running, the WebSocket connection will fail — handle this
### pc.Proxy(port, path?) → string
Get an HTTP proxy URL for a service port.
Get a proxy URL for a service port. Supports HTTP, WebSocket (`ws://`), and SSE — the Tai proxy handles protocol upgrades automatically.
- **Box**: routes to `container-ip:{port}`
- **Host**: routes to `127.0.0.1:{port}` on the Tai machine via `__host__`
@ -260,7 +260,7 @@ Get identity and registry information.
```javascript
const info = pc.ComputerInfo()
console.log(info.kind) // "box" or "host"
console.log(info.pool) // pool name
console.log(info.pool) // TaiID
console.log(info.system.os) // "linux" | "windows" | "darwin"
console.log(info.status) // "running" | "stopped" | ...
```
@ -269,7 +269,7 @@ Returns a [ComputerInfo](#computerinfo-object) object.
### pc.BindWorkplace(workspaceID) → void
Bind a workspace to this computer for the current session.
Bind a workspace to this computer for the current session. For box computers created with a `workspace_id` option, the workspace is already bound at creation time — calling `BindWorkplace` overrides it.
```javascript
pc.BindWorkplace("ws-project-abc")
@ -277,7 +277,7 @@ pc.BindWorkplace("ws-project-abc")
### pc.Workplace() → WorkspaceFS | null
Access the workspace bound via `BindWorkplace()`. Returns `null` if no workspace is bound.
Access the workspace filesystem bound via `BindWorkplace()`. Returns `null` if no workspace is bound. ("Workplace" is the binding on a Computer; "Workspace" is the filesystem it points to.)
```javascript
pc.BindWorkplace("ws-project-abc")
@ -288,30 +288,9 @@ ws.WriteFile("output.json", JSON.stringify(data))
See [WorkspaceFS Object](#workspacefs-object) for the full method list.
### pc.Attach(port, options?) → string — box-only
Get a WebSocket or SSE endpoint URL for a service running inside the container. Throws on host computers.
```javascript
const wsURL = pc.Attach(3000, { protocol: "ws", path: "/ws" })
// "ws://tai-host:8099/container-id:3000/ws"
const sseURL = pc.Attach(8080, { protocol: "sse", path: "/events" })
// "http://tai-host:8099/container-id:8080/events"
```
Options:
```javascript
{
protocol: "ws" | "sse", // default "ws"; affects URL scheme (ws:// vs http://)
path: "/ws" // optional URL path suffix
}
```
### pc.Info() → BoxInfo — box-only
Get current container status information. Throws on host computers.
Get current container runtime status (process count, last active time, etc.). For node-level identity info (OS, CPU, capabilities), use `ComputerInfo()` instead. Throws on host computers.
```javascript
const info = pc.Info()
@ -353,7 +332,7 @@ Returned by `pc.ComputerInfo()`. Read-only snapshot of a Computer's identity and
```javascript
{
kind: "box", // "box" | "host"
pool: "default",
pool: "192.168.1.10-19100", // TaiID
tai_id: "tai-abc123",
machine_id: "m-xyz",
version: "1.2.3",
@ -390,7 +369,7 @@ Returned by `sandbox.GetNode()`, `sandbox.Nodes()`, `sandbox.NodesByTeam()`. Rea
machine_id: "m-xyz",
version: "1.2.3",
mode: "direct", // "direct" | "tunnel"
addr: "192.168.1.100",
addr: "tai://192.168.1.100:19100",
status: "online", // "online" | "offline" | "connecting"
pool: "gpu",
connected_at: "2026-03-07T08:00:00Z",
@ -509,16 +488,17 @@ pc.Stream(["npm", "run", "dev"], { workdir: "/app" }, function(type, data) {
### Host execution for GPU workloads
```javascript
const host = sandbox.Host("gpu")
const host = sandbox.Host("10.0.0.5-19100")
const result = host.Exec(["nvidia-smi"])
console.log(result.stdout)
host.Exec(["python3", "train.py", "--epochs=10"], {
const train = host.Exec(["python3", "train.py", "--epochs=10"], {
workdir: "/workspace/ml",
env: { CUDA_VISIBLE_DEVICES: "0,1" },
timeout: 3600000
})
if (train.exit_code !== 0) throw new Error("training failed: " + train.stderr)
```
### Uniform interface — same code for box and host
@ -534,7 +514,7 @@ function runTask(pc, cmd, opts) {
// Works the same for both
const box = sandbox.Create({ image: "node:20", owner: "u1" })
const host = sandbox.Host("gpu")
const host = sandbox.Host("10.0.0.5-19100")
runTask(box, ["node", "-e", "console.log('hi')"])
runTask(host, ["echo", "hello"])
@ -558,7 +538,7 @@ const appURL = pc.Proxy(3000)
// "http://tai-host:8099/container-id:3000/"
// Same methods work on host
const host = sandbox.Host()
const host = sandbox.Host("192.168.1.10-19100")
const hostVNC = host.VNC()
// "ws://tai-host:16080/vnc/__host__/ws"
```

View file

@ -1,138 +1,471 @@
package jsapi
import (
"context"
"encoding/json"
"sync"
"time"
sandbox "github.com/yaoapp/yao/sandbox/v2"
wsjsapi "github.com/yaoapp/yao/workspace/jsapi"
"rogchap.com/v8go"
)
// sbHost: `sandbox.Host(pool?)` → Computer (kind="host")
//
// Go: Manager.Host(ctx, pool) (*Host, error)
//
// Args:
//
// pool: string (optional) — pool name; empty = default pool
//
// Returns: Computer object (kind="host") if the pool has host_exec capability, otherwise throws.
func sbHost(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. pool := ""; if len(info.Args()) > 0 && info.Args()[0].IsString() { pool = info.Args()[0].String() }
// 2. host, err := sandbox.M().Host(ctx, pool)
// 3. if err != nil { throw in V8 }
// 4. Return NewComputerObject(v8ctx, "host", pool)
return v8go.Undefined(info.Context().Isolate())
// ---------------------------------------------------------------------------
// Helpers — shared across jsapi files
// ---------------------------------------------------------------------------
func throwError(info *v8go.FunctionCallbackInfo, msg string) *v8go.Value {
iso := info.Context().Isolate()
e, _ := v8go.NewValue(iso, msg)
iso.ThrowException(e)
return v8go.Undefined(iso)
}
// NewComputerObject creates a unified JS Computer object backed by either a Box or Host.
// The `kind` field ("box" or "host") determines which methods are available at runtime.
// Box-only methods (Attach, Info, Start, Stop, Remove) throw an error when called on a host.
//
// # Properties (read-only)
//
// pc.kind → string // "box" | "host" ← ComputerInfo().Kind
// pc.id → string // sandbox ID ← Box.ID() (empty for host)
// pc.owner → string // owner ← Box.Owner() (empty for host)
// pc.pool → string // pool name ← ComputerInfo().Pool
//
// # Methods — Computer interface (both box and host)
//
// pc.Exec(cmd, options?) → ExecResult
//
// Go: Computer.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error)
//
// JS args:
// cmd: string[] → cmd []string
// options: { → ExecOption
// workdir: string, → WithWorkDir(dir)
// env: object, → WithEnv(map[string]string)
// stdin: string, → WithStdin([]byte)
// timeout: number, → WithTimeout(ms → time.Duration)
// max_output: number → WithMaxOutput(bytes int64)
// }
// JS returns: {
// exit_code: number, ← ExecResult.ExitCode
// stdout: string, ← ExecResult.Stdout
// stderr: string, ← ExecResult.Stderr
// duration_ms: number, ← ExecResult.DurationMs
// error: string, ← ExecResult.Error
// truncated: boolean ← ExecResult.Truncated
// }
//
// pc.Stream(cmd, callback) / pc.Stream(cmd, options, callback)
//
// Go: Computer.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error)
//
// Blocks until the process exits. The last argument must be a JS function.
// Callback signature: function(type, data)
// type = "stdout" → data is string (chunk)
// type = "stderr" → data is string (chunk)
// type = "exit" → data is number (exit code)
//
// pc.VNC() → string
//
// Go: Computer.VNC(ctx) (string, error)
// Box: returns ws://host:port/vnc/{containerID}/ws
// Host: returns ws://host:port/vnc/__host__/ws
//
// pc.Proxy(port, path?) → string
//
// Go: Computer.Proxy(ctx, port int, path string) (string, error)
// Box: returns http://host:port/{containerID}:{port}/{path}
// Host: returns http://host:port/__host__:{port}/{path}
//
// pc.ComputerInfo() → ComputerInfo
//
// Go: Computer.ComputerInfo() ComputerInfo
// JS returns: { kind, pool, tai_id, machine_id, version, mode, status, capabilities,
// system: { os, arch, hostname, num_cpu, total_mem },
// box_id, container_id, owner, image, policy, labels }
//
// pc.BindWorkplace(workspaceID) → void
//
// Go: Computer.BindWorkplace(workspaceID string)
//
// pc.Workplace() → WorkspaceFS | null
//
// Go: Computer.Workplace() workspace.FS
// Returns WorkspaceFS if a workplace is bound, null otherwise.
//
// # Methods — Box-only (throw on host)
//
// pc.Attach(port, options?) → string
//
// Gets a WebSocket/SSE endpoint URL for a container service.
// JS args:
// port: number
// options: { protocol: "ws"|"sse", path: string }
// JS returns: string (URL)
//
// pc.Info() → BoxInfo
//
// Go: Box.Info(ctx) (*BoxInfo, error)
// JS returns: { id, container_id, pool, owner, status, image, vnc, policy,
// labels, created_at, last_active, process_count }
//
// pc.Start() → void
//
// Go: Box.Start(ctx) error
//
// pc.Stop() → void
//
// Go: Box.Stop(ctx) error
//
// pc.Remove() → void
//
// Go: Box.Remove(ctx) error
func NewComputerObject(v8ctx *v8go.Context, kind string, id string) (*v8go.Value, error) {
// TODO: Phase 2 implementation
// 1. Create JS object via v8go.NewObjectTemplate
// 2. Set read-only properties: kind, id, owner, pool
// - kind: "box" or "host"
// - id/owner: from sandbox.M().Get(id) for box; empty for host
// - pool: from ComputerInfo().Pool
// 3. Bind Computer interface methods:
// - Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, Workplace
// 4. Bind box-only methods with kind guard:
// - Attach, Info, Start, Stop, Remove
// - If kind == "host", these throw: "not supported: {method}() requires a box computer"
return nil, nil
func parseStringArray(val *v8go.Value) []string {
obj, err := val.AsObject()
if err != nil {
return nil
}
lenVal, err := obj.Get("length")
if err != nil {
return nil
}
length := int(lenVal.Int32())
result := make([]string, 0, length)
for i := 0; i < length; i++ {
item, err := obj.GetIdx(uint32(i))
if err != nil || !item.IsString() {
continue
}
result = append(result, item.String())
}
return result
}
func parseStringMap(v8ctx *v8go.Context, val *v8go.Value) map[string]string {
result := make(map[string]string)
if !val.IsObject() {
return result
}
jsonStr, err := v8go.JSONStringify(v8ctx, val)
if err != nil {
return result
}
_ = json.Unmarshal([]byte(jsonStr), &result)
return result
}
func parseExecOptions(v8ctx *v8go.Context, args []*v8go.Value) ([]string, []sandbox.ExecOption, *v8go.Value) {
if len(args) < 1 || !args[0].IsObject() {
return nil, nil, nil
}
cmd := parseStringArray(args[0])
if len(cmd) == 0 {
return nil, nil, nil
}
var opts []sandbox.ExecOption
var callback *v8go.Value
for i := 1; i < len(args); i++ {
v := args[i]
if v.IsFunction() {
callback = v
break
}
if v.IsObject() {
optsObj, err := v.AsObject()
if err != nil {
continue
}
if wd, e := optsObj.Get("workdir"); e == nil && wd.IsString() {
opts = append(opts, sandbox.WithWorkDir(wd.String()))
}
if env, e := optsObj.Get("env"); e == nil && env.IsObject() {
envMap := parseStringMap(v8ctx, env)
if len(envMap) > 0 {
opts = append(opts, sandbox.WithEnv(envMap))
}
}
if stdin, e := optsObj.Get("stdin"); e == nil && stdin.IsString() {
opts = append(opts, sandbox.WithStdin([]byte(stdin.String())))
}
if t, e := optsObj.Get("timeout"); e == nil && t.IsNumber() {
opts = append(opts, sandbox.WithTimeout(time.Duration(t.Number())*time.Millisecond))
}
if mo, e := optsObj.Get("max_output"); e == nil && mo.IsNumber() {
opts = append(opts, sandbox.WithMaxOutput(int64(mo.Number())))
}
}
}
return cmd, opts, callback
}
func execResultToJS(v8ctx *v8go.Context, r *sandbox.ExecResult) *v8go.Value {
data, _ := json.Marshal(map[string]interface{}{
"exit_code": r.ExitCode,
"stdout": r.Stdout,
"stderr": r.Stderr,
"duration_ms": r.DurationMs,
"error": r.Error,
"truncated": r.Truncated,
})
val, _ := v8go.JSONParse(v8ctx, string(data))
return val
}
func boxInfoToJS(v8ctx *v8go.Context, b *sandbox.BoxInfo) *v8go.Value {
data, _ := json.Marshal(map[string]interface{}{
"id": b.ID,
"container_id": b.ContainerID,
"pool": b.Pool,
"owner": b.Owner,
"status": b.Status,
"image": b.Image,
"vnc": b.VNC,
"policy": string(b.Policy),
"labels": b.Labels,
"created_at": b.CreatedAt.Format(time.RFC3339),
"last_active": b.LastActive.Format(time.RFC3339),
"process_count": b.ProcessCount,
})
val, _ := v8go.JSONParse(v8ctx, string(data))
return val
}
func computerInfoToJS(v8ctx *v8go.Context, c sandbox.ComputerInfo) *v8go.Value {
data, _ := json.Marshal(map[string]interface{}{
"kind": c.Kind,
"pool": c.Pool,
"tai_id": c.TaiID,
"machine_id": c.MachineID,
"version": c.Version,
"mode": c.Mode,
"status": c.Status,
"capabilities": c.Capabilities,
"system": map[string]interface{}{
"os": c.System.OS,
"arch": c.System.Arch,
"hostname": c.System.Hostname,
"num_cpu": c.System.NumCPU,
"total_mem": c.System.TotalMem,
},
"box_id": c.BoxID,
"container_id": c.ContainerID,
"owner": c.Owner,
"image": c.Image,
"policy": string(c.Policy),
"labels": c.Labels,
})
val, _ := v8go.JSONParse(v8ctx, string(data))
return val
}
// getComputer re-fetches a Computer from the Manager by kind + identifier.
// kind="box" → identifier is boxID, kind="host" → identifier is pool name.
func getComputer(ctx context.Context, kind, identifier string) (sandbox.Computer, error) {
m := sandbox.M()
if kind == "box" {
return m.Get(ctx, identifier)
}
return m.Host(ctx, identifier)
}
// ---------------------------------------------------------------------------
// sbHost — sandbox.Host(pool?)
// ---------------------------------------------------------------------------
func sbHost(info *v8go.FunctionCallbackInfo) *v8go.Value {
ctx := context.Background()
v8ctx := info.Context()
pool := ""
args := info.Args()
if len(args) > 0 && args[0].IsString() {
pool = args[0].String()
}
if _, err := sandbox.M().Host(ctx, pool); err != nil {
return throwError(info, err.Error())
}
val, err := NewComputerObject(v8ctx, "host", pool)
if err != nil {
return throwError(info, err.Error())
}
return val
}
// ---------------------------------------------------------------------------
// NewComputerObject — unified JS Computer object factory
// ---------------------------------------------------------------------------
// NewComputerObject creates a JS Computer object. Closures capture only
// kind (string) and identifier (string) — no Go objects cross into V8.
func NewComputerObject(v8ctx *v8go.Context, kind string, identifier string) (*v8go.Value, error) {
iso := v8ctx.Isolate()
ctx := context.Background()
// Mutable workplace binding lives in closure, not in V8 heap.
var workplaceID string
tpl := v8go.NewObjectTemplate(iso)
// -- Exec --
tpl.Set("Exec", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
cmd, opts, _ := parseExecOptions(info.Context(), info.Args())
if len(cmd) == 0 {
return throwError(info, "Exec requires cmd (string[])")
}
comp, err := getComputer(ctx, kind, identifier)
if err != nil {
return throwError(info, err.Error())
}
result, err := comp.Exec(ctx, cmd, opts...)
if err != nil {
return throwError(info, err.Error())
}
return execResultToJS(info.Context(), result)
}))
// -- Stream --
tpl.Set("Stream", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
cmd, opts, cbVal := parseExecOptions(info.Context(), info.Args())
if len(cmd) == 0 {
return throwError(info, "Stream requires cmd (string[]) and callback")
}
if cbVal == nil || !cbVal.IsFunction() {
return throwError(info, "Stream requires a callback function as last argument")
}
cbFn, err := cbVal.AsFunction()
if err != nil {
return throwError(info, "Stream callback is not a function")
}
comp, err := getComputer(ctx, kind, identifier)
if err != nil {
return throwError(info, err.Error())
}
stream, err := comp.Stream(ctx, cmd, opts...)
if err != nil {
return throwError(info, err.Error())
}
type chunk struct {
typ string
data interface{}
}
ch := make(chan chunk, 64)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
buf := make([]byte, 4096)
for {
n, err := stream.Stdout.Read(buf)
if n > 0 {
ch <- chunk{"stdout", string(buf[:n])}
}
if err != nil {
break
}
}
}()
go func() {
defer wg.Done()
buf := make([]byte, 4096)
for {
n, err := stream.Stderr.Read(buf)
if n > 0 {
ch <- chunk{"stderr", string(buf[:n])}
}
if err != nil {
break
}
}
}()
go func() {
code, _ := stream.Wait()
wg.Wait()
ch <- chunk{"exit", code}
close(ch)
}()
v8c := info.Context()
global := v8c.Global()
for c := range ch {
var dataVal *v8go.Value
switch v := c.data.(type) {
case string:
dataVal, _ = v8go.NewValue(iso, v)
case int:
dataVal, _ = v8go.NewValue(iso, int32(v))
}
typeVal, _ := v8go.NewValue(iso, c.typ)
if typeVal != nil && dataVal != nil {
_, _ = cbFn.Call(global, typeVal, dataVal)
}
}
return v8go.Undefined(iso)
}))
// -- VNC --
tpl.Set("VNC", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
comp, err := getComputer(ctx, kind, identifier)
if err != nil {
return throwError(info, err.Error())
}
url, err := comp.VNC(ctx)
if err != nil {
return throwError(info, err.Error())
}
val, _ := v8go.NewValue(iso, url)
return val
}))
// -- Proxy --
tpl.Set("Proxy", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 1 || !args[0].IsNumber() {
return throwError(info, "Proxy requires port (number)")
}
port := int(args[0].Int32())
path := "/"
if len(args) > 1 && args[1].IsString() {
path = args[1].String()
}
comp, err := getComputer(ctx, kind, identifier)
if err != nil {
return throwError(info, err.Error())
}
url, err := comp.Proxy(ctx, port, path)
if err != nil {
return throwError(info, err.Error())
}
val, _ := v8go.NewValue(iso, url)
return val
}))
// -- ComputerInfo --
tpl.Set("ComputerInfo", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
comp, err := getComputer(ctx, kind, identifier)
if err != nil {
return throwError(info, err.Error())
}
return computerInfoToJS(info.Context(), comp.ComputerInfo())
}))
// -- BindWorkplace --
tpl.Set("BindWorkplace", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 1 || !args[0].IsString() {
return throwError(info, "BindWorkplace requires workspaceID (string)")
}
workplaceID = args[0].String()
comp, err := getComputer(ctx, kind, identifier)
if err != nil {
return throwError(info, err.Error())
}
comp.BindWorkplace(workplaceID)
return v8go.Undefined(iso)
}))
// -- Workplace → reuse workspace JSAPI NewFSObject --
tpl.Set("Workplace", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
if workplaceID == "" {
return v8go.Null(iso)
}
val, err := wsjsapi.NewFSObject(info.Context(), workplaceID)
if err != nil {
return throwError(info, err.Error())
}
return val
}))
// -- Box-only: Info --
tpl.Set("Info", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
if kind == "host" {
return throwError(info, "not supported: Info() requires a box computer")
}
comp, err := getComputer(ctx, kind, identifier)
if err != nil {
return throwError(info, err.Error())
}
box := comp.(*sandbox.Box)
bi, err := box.Info(ctx)
if err != nil {
return throwError(info, err.Error())
}
return boxInfoToJS(info.Context(), bi)
}))
// -- Box-only: Start --
tpl.Set("Start", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
if kind == "host" {
return throwError(info, "not supported: Start() requires a box computer")
}
comp, err := getComputer(ctx, kind, identifier)
if err != nil {
return throwError(info, err.Error())
}
if err := comp.(*sandbox.Box).Start(ctx); err != nil {
return throwError(info, err.Error())
}
return v8go.Undefined(iso)
}))
// -- Box-only: Stop --
tpl.Set("Stop", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
if kind == "host" {
return throwError(info, "not supported: Stop() requires a box computer")
}
comp, err := getComputer(ctx, kind, identifier)
if err != nil {
return throwError(info, err.Error())
}
if err := comp.(*sandbox.Box).Stop(ctx); err != nil {
return throwError(info, err.Error())
}
return v8go.Undefined(iso)
}))
// -- Box-only: Remove --
tpl.Set("Remove", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
if kind == "host" {
return throwError(info, "not supported: Remove() requires a box computer")
}
comp, err := getComputer(ctx, kind, identifier)
if err != nil {
return throwError(info, err.Error())
}
if err := comp.(*sandbox.Box).Remove(ctx); err != nil {
return throwError(info, err.Error())
}
return v8go.Undefined(iso)
}))
// Instantiate and set read-only properties
obj, err := tpl.NewInstance(v8ctx)
if err != nil {
return nil, err
}
obj.Set("kind", kind)
idStr := ""
ownerStr := ""
poolStr := identifier
if kind == "box" {
if comp, err := getComputer(ctx, kind, identifier); err == nil {
box := comp.(*sandbox.Box)
idStr = box.ID()
ownerStr = box.Owner()
poolStr = box.Pool()
} else {
idStr = identifier
}
}
obj.Set("id", idStr)
obj.Set("owner", ownerStr)
obj.Set("pool", poolStr)
return obj.Value, nil
}

View file

@ -32,7 +32,12 @@
package jsapi
import (
"context"
"encoding/json"
"time"
v8 "github.com/yaoapp/gou/runtime/v8"
sandbox "github.com/yaoapp/yao/sandbox/v2"
"rogchap.com/v8go"
)
@ -54,112 +59,227 @@ func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
return obj
}
// sbCreate: `sandbox.Create(options)` → Box
//
// Go: Manager.Create(ctx, CreateOptions) (*Box, error)
//
// Manager.GetOrCreate(ctx, CreateOptions) (*Box, error) — when opts.id is set
//
// JS options → Go CreateOptions mapping:
//
// {
// id: string → CreateOptions.ID // optional; triggers GetOrCreate
// owner: string → CreateOptions.Owner // required
// pool: string → CreateOptions.Pool // default: first pool
// image: string → CreateOptions.Image // required
// workdir: string → CreateOptions.WorkDir
// user: string → CreateOptions.User // e.g. "1000:1000"
// env: object → CreateOptions.Env // map[string]string
// memory: number → CreateOptions.Memory // bytes (int64)
// cpus: number → CreateOptions.CPUs // float64 e.g. 1.5
// vnc: boolean → CreateOptions.VNC
// ports: array → CreateOptions.Ports // [{container_port, host_port, host_ip, protocol}] → []PortMapping
// policy: string → CreateOptions.Policy // "oneshot"|"session"|"longrunning"|"persistent"
// idle_timeout: number → CreateOptions.IdleTimeout // ms → time.Duration
// stop_timeout: number → CreateOptions.StopTimeout // ms → time.Duration
// workspace_id: string → CreateOptions.WorkspaceID
// mount_mode: string → CreateOptions.MountMode // "rw"|"ro"
// mount_path: string → CreateOptions.MountPath
// labels: object → CreateOptions.Labels // map[string]string
// }
//
// Returns: Computer object (kind="box") — see computer.go
// sbCreate: `sandbox.Create(options)` → Computer (kind="box")
func sbCreate(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. Parse options from info.Args()[0]
// 2. Validate required fields (image, owner)
// 3. If opts.id != "" → sandbox.M().GetOrCreate(ctx, opts)
// else → sandbox.M().Create(ctx, opts)
// 4. Return NewComputerObject(v8ctx, "box", box.ID())
return v8go.Undefined(info.Context().Isolate())
v8ctx := info.Context()
ctx := context.Background()
args := info.Args()
if len(args) < 1 || !args[0].IsObject() {
return throwError(info, "Create requires options object")
}
// sbGet: `sandbox.Get(id)` → Box | null
//
// Go: Manager.Get(ctx, id) (*Box, error)
//
// Args:
//
// id: string — sandbox ID
//
// Returns: Computer object (kind="box") if found, null if not found
optsVal := args[0]
jsonStr, err := v8go.JSONStringify(v8ctx, optsVal)
if err != nil {
return throwError(info, "Create: invalid options: "+err.Error())
}
var raw map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
return throwError(info, "Create: invalid options JSON: "+err.Error())
}
opts := sandbox.CreateOptions{}
if v, ok := raw["id"].(string); ok {
opts.ID = v
}
if v, ok := raw["owner"].(string); ok {
opts.Owner = v
}
if v, ok := raw["pool"].(string); ok {
opts.Pool = v
}
if v, ok := raw["image"].(string); ok {
opts.Image = v
}
if v, ok := raw["workdir"].(string); ok {
opts.WorkDir = v
}
if v, ok := raw["user"].(string); ok {
opts.User = v
}
if v, ok := raw["env"].(map[string]interface{}); ok {
env := make(map[string]string, len(v))
for k, val := range v {
if s, ok := val.(string); ok {
env[k] = s
}
}
opts.Env = env
}
if v, ok := raw["memory"].(float64); ok {
opts.Memory = int64(v)
}
if v, ok := raw["cpus"].(float64); ok {
opts.CPUs = v
}
if v, ok := raw["vnc"].(bool); ok {
opts.VNC = v
}
if v, ok := raw["policy"].(string); ok {
opts.Policy = sandbox.LifecyclePolicy(v)
}
if v, ok := raw["idle_timeout"].(float64); ok {
opts.IdleTimeout = time.Duration(v) * time.Millisecond
}
if v, ok := raw["stop_timeout"].(float64); ok {
opts.StopTimeout = time.Duration(v) * time.Millisecond
}
if v, ok := raw["workspace_id"].(string); ok {
opts.WorkspaceID = v
}
if v, ok := raw["mount_mode"].(string); ok {
opts.MountMode = v
}
if v, ok := raw["mount_path"].(string); ok {
opts.MountPath = v
}
if v, ok := raw["labels"].(map[string]interface{}); ok {
labels := make(map[string]string, len(v))
for k, val := range v {
if s, ok := val.(string); ok {
labels[k] = s
}
}
opts.Labels = labels
}
if v, ok := raw["ports"].([]interface{}); ok {
for _, p := range v {
pm, ok := p.(map[string]interface{})
if !ok {
continue
}
mapping := sandbox.PortMapping{}
if cp, ok := pm["container_port"].(float64); ok {
mapping.ContainerPort = int(cp)
}
if hp, ok := pm["host_port"].(float64); ok {
mapping.HostPort = int(hp)
}
if hi, ok := pm["host_ip"].(string); ok {
mapping.HostIP = hi
}
if pr, ok := pm["protocol"].(string); ok {
mapping.Protocol = pr
}
opts.Ports = append(opts.Ports, mapping)
}
}
m := sandbox.M()
var box *sandbox.Box
if opts.ID != "" {
box, err = m.GetOrCreate(ctx, opts)
} else {
box, err = m.Create(ctx, opts)
}
if err != nil {
return throwError(info, err.Error())
}
val, err := NewComputerObject(v8ctx, "box", box.ID())
if err != nil {
return throwError(info, err.Error())
}
return val
}
// sbGet: `sandbox.Get(id)` → Computer (kind="box") | null
func sbGet(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. id = info.Args()[0].String()
// 2. box, err := sandbox.M().Get(ctx, id)
// 3. Return NewComputerObject(v8ctx, "box", id) or null
return v8go.Undefined(info.Context().Isolate())
iso := info.Context().Isolate()
v8ctx := info.Context()
ctx := context.Background()
args := info.Args()
if len(args) < 1 || !args[0].IsString() {
return throwError(info, "Get requires id (string)")
}
id := args[0].String()
_, err := sandbox.M().Get(ctx, id)
if err != nil {
return v8go.Null(iso)
}
val, err := NewComputerObject(v8ctx, "box", id)
if err != nil {
return throwError(info, err.Error())
}
return val
}
// sbList: `sandbox.List(filter?)` → BoxInfo[]
//
// Go: Manager.List(ctx, ListOptions) ([]*Box, error)
//
// then Box.Info(ctx) for each → BoxInfo
//
// JS filter → Go ListOptions mapping:
//
// {
// owner: string → ListOptions.Owner // filter by owner; empty = all
// pool: string → ListOptions.Pool // filter by pool; empty = all
// labels: object → ListOptions.Labels // filter by labels
// }
//
// Returns: BoxInfo[] — each element:
//
// {
// id: string ← BoxInfo.ID
// container_id: string ← BoxInfo.ContainerID
// pool: string ← BoxInfo.Pool
// owner: string ← BoxInfo.Owner
// status: string ← BoxInfo.Status
// image: string ← BoxInfo.Image
// vnc: boolean ← BoxInfo.VNC
// policy: string ← BoxInfo.Policy
// labels: object ← BoxInfo.Labels
// created_at: string ← BoxInfo.CreatedAt (ISO 8601)
// last_active: string ← BoxInfo.LastActive (ISO 8601)
// process_count: number ← BoxInfo.ProcessCount
// }
func sbList(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. Parse optional filter from info.Args()[0]
// 2. boxes := sandbox.M().List(ctx, opts)
// 3. For each box: box.Info(ctx) → BoxInfo → JS object
// 4. Return JS array of BoxInfo objects
return v8go.Undefined(info.Context().Isolate())
v8ctx := info.Context()
ctx := context.Background()
args := info.Args()
opts := sandbox.ListOptions{}
if len(args) > 0 && args[0].IsObject() {
jsonStr, _ := v8go.JSONStringify(v8ctx, args[0])
var raw map[string]interface{}
if json.Unmarshal([]byte(jsonStr), &raw) == nil {
if v, ok := raw["owner"].(string); ok {
opts.Owner = v
}
if v, ok := raw["pool"].(string); ok {
opts.Pool = v
}
if v, ok := raw["labels"].(map[string]interface{}); ok {
labels := make(map[string]string, len(v))
for k, val := range v {
if s, ok := val.(string); ok {
labels[k] = s
}
}
opts.Labels = labels
}
}
}
boxes, err := sandbox.M().List(ctx, opts)
if err != nil {
return throwError(info, err.Error())
}
items := make([]interface{}, 0, len(boxes))
for _, b := range boxes {
bi, err := b.Info(ctx)
if err != nil {
continue
}
items = append(items, map[string]interface{}{
"id": bi.ID,
"container_id": bi.ContainerID,
"pool": bi.Pool,
"owner": bi.Owner,
"status": bi.Status,
"image": bi.Image,
"vnc": bi.VNC,
"policy": string(bi.Policy),
"labels": bi.Labels,
"created_at": bi.CreatedAt.Format(time.RFC3339),
"last_active": bi.LastActive.Format(time.RFC3339),
"process_count": bi.ProcessCount,
})
}
data, _ := json.Marshal(items)
val, _ := v8go.JSONParse(v8ctx, string(data))
return val
}
// sbDelete: `sandbox.Delete(id)` → void
//
// Go: Manager.Remove(ctx, id) error
//
// Args:
//
// id: string — sandbox ID to remove
func sbDelete(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. id = info.Args()[0].String()
// 2. sandbox.M().Remove(ctx, id)
return v8go.Undefined(info.Context().Isolate())
iso := info.Context().Isolate()
args := info.Args()
if len(args) < 1 || !args[0].IsString() {
return throwError(info, "Delete requires id (string)")
}
ctx := context.Background()
id := args[0].String()
if err := sandbox.M().Remove(ctx, id); err != nil {
return throwError(info, err.Error())
}
return v8go.Undefined(iso)
}

View file

@ -0,0 +1,418 @@
package jsapi_test
import (
"fmt"
"os"
"strings"
"testing"
"time"
v8runtime "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/yao/config"
sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/test"
_ "github.com/yaoapp/yao/sandbox/v2/jsapi"
)
type testMode struct {
Name string
Addr string
TaiID string // filled by setupSandbox
Options []tai.Option
}
func testModes() []testMode {
modes := []testMode{{Name: "local", Addr: "local"}}
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
modes = append(modes, testMode{Name: "remote", Addr: addr})
}
return modes
}
func testImage() string {
if img := os.Getenv("SANDBOX_TEST_IMAGE"); img != "" {
return img
}
return "alpine:latest"
}
func setupSandbox(t *testing.T, m *testMode) {
t.Helper()
test.Prepare(t, config.Conf)
reg := registry.Global()
if reg == nil {
registry.Init(nil)
}
client, err := tai.New(m.Addr, m.Options...)
if err != nil {
t.Fatalf("tai.New: %v", err)
}
m.TaiID = client.TaiID()
sandbox.Init()
mgr := sandbox.M()
t.Cleanup(func() { mgr.Close() })
}
func runJS(t *testing.T, source string) interface{} {
t.Helper()
res, err := v8runtime.Call(v8runtime.CallOptions{
Sid: "test",
Timeout: 60 * time.Second,
}, source)
if err != nil {
t.Fatalf("JS error: %v", err)
}
return res
}
func runJSExpectError(t *testing.T, source string) string {
t.Helper()
_, err := v8runtime.Call(v8runtime.CallOptions{
Sid: "test",
Timeout: 30 * time.Second,
}, source)
if err == nil {
t.Fatal("expected JS error, got nil")
}
return err.Error()
}
func skipIfNoDocker(t *testing.T) {
t.Helper()
addr := os.Getenv("SANDBOX_TEST_LOCAL_ADDR")
if addr == "" {
addr = "local"
}
_ = addr
}
// ---------------------------------------------------------------------------
// sandbox.Create / sandbox.Get / sandbox.Delete
// ---------------------------------------------------------------------------
func TestCreate(t *testing.T) {
skipIfNoDocker(t)
for _, m := range testModes() {
t.Run(m.Name, func(t *testing.T) {
setupSandbox(t, &m)
img := testImage()
res := runJS(t, fmt.Sprintf(`function TestCreate() {
var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" });
if (pc.kind !== "box") throw new Error("kind=" + pc.kind);
if (!pc.id) throw new Error("no id");
var id = pc.id;
sandbox.Delete(id);
return id;
}`, img, m.TaiID))
if res == nil || res == "" {
t.Error("expected box id")
}
})
}
}
func TestGet(t *testing.T) {
skipIfNoDocker(t)
for _, m := range testModes() {
t.Run(m.Name, func(t *testing.T) {
setupSandbox(t, &m)
img := testImage()
res := runJS(t, fmt.Sprintf(`function TestGet() {
var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" });
var id = pc.id;
var got = sandbox.Get(id);
if (!got) throw new Error("Get returned null");
if (got.kind !== "box") throw new Error("kind=" + got.kind);
sandbox.Delete(id);
return id;
}`, img, m.TaiID))
if res == nil || res == "" {
t.Error("expected box id")
}
})
}
}
func TestGetNotFound(t *testing.T) {
for _, m := range testModes() {
t.Run(m.Name, func(t *testing.T) {
setupSandbox(t, &m)
res := runJS(t, `function TestGetNotFound() {
var got = sandbox.Get("sb-nonexistent-id");
return got === null ? "null" : "found";
}`)
if res != "null" {
t.Errorf("expected null, got %v", res)
}
})
}
}
func TestDelete(t *testing.T) {
skipIfNoDocker(t)
for _, m := range testModes() {
t.Run(m.Name, func(t *testing.T) {
setupSandbox(t, &m)
img := testImage()
res := runJS(t, fmt.Sprintf(`function TestDelete() {
var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" });
var id = pc.id;
sandbox.Delete(id);
var got = sandbox.Get(id);
return got === null ? "deleted" : "still exists";
}`, img, m.TaiID))
if res != "deleted" {
t.Errorf("expected deleted, got %v", res)
}
})
}
}
// ---------------------------------------------------------------------------
// sandbox.List
// ---------------------------------------------------------------------------
func TestList(t *testing.T) {
skipIfNoDocker(t)
for _, m := range testModes() {
t.Run(m.Name, func(t *testing.T) {
setupSandbox(t, &m)
img := testImage()
res := runJS(t, fmt.Sprintf(`function TestList() {
var a = sandbox.Create({ image: "%s", owner: "list-user", pool: "%s" });
var b = sandbox.Create({ image: "%s", owner: "list-user", pool: "%s" });
var list = sandbox.List({ owner: "list-user" });
var count = list.length;
sandbox.Delete(a.id);
sandbox.Delete(b.id);
return count;
}`, img, m.TaiID, img, m.TaiID))
n := toInt(res)
if n < 2 {
t.Errorf("expected >= 2, got %d", n)
}
})
}
}
// ---------------------------------------------------------------------------
// Computer.Exec
// ---------------------------------------------------------------------------
func TestExec(t *testing.T) {
skipIfNoDocker(t)
for _, m := range testModes() {
t.Run(m.Name, func(t *testing.T) {
setupSandbox(t, &m)
img := testImage()
res := runJS(t, fmt.Sprintf(`function TestExec() {
var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" });
var r = pc.Exec(["echo", "hello-jsapi"]);
sandbox.Delete(pc.id);
return r.stdout;
}`, img, m.TaiID))
s := fmt.Sprintf("%v", res)
if !strings.Contains(s, "hello-jsapi") {
t.Errorf("stdout = %q, want contain 'hello-jsapi'", s)
}
})
}
}
func TestExecWithOptions(t *testing.T) {
skipIfNoDocker(t)
for _, m := range testModes() {
t.Run(m.Name, func(t *testing.T) {
setupSandbox(t, &m)
img := testImage()
res := runJS(t, fmt.Sprintf(`function TestExecWithOptions() {
var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" });
var r = pc.Exec(["pwd"], { workdir: "/tmp" });
sandbox.Delete(pc.id);
return r.stdout;
}`, img, m.TaiID))
s := fmt.Sprintf("%v", res)
if !strings.Contains(s, "/tmp") {
t.Errorf("stdout = %q, want contain '/tmp'", s)
}
})
}
}
// ---------------------------------------------------------------------------
// Computer.Stream
// ---------------------------------------------------------------------------
func TestStream(t *testing.T) {
skipIfNoDocker(t)
for _, m := range testModes() {
t.Run(m.Name, func(t *testing.T) {
setupSandbox(t, &m)
img := testImage()
res := runJS(t, fmt.Sprintf(`function TestStream() {
var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" });
var chunks = [];
var exitCode = -1;
pc.Stream(["echo", "streaming"], function(type, data) {
if (type === "stdout") chunks.push(data);
if (type === "exit") exitCode = data;
});
sandbox.Delete(pc.id);
return chunks.join("").trim() + "|" + exitCode;
}`, img, m.TaiID))
s := fmt.Sprintf("%v", res)
if !strings.Contains(s, "streaming|0") {
t.Errorf("result = %q, want contain 'streaming|0'", s)
}
})
}
}
// ---------------------------------------------------------------------------
// Computer.ComputerInfo
// ---------------------------------------------------------------------------
func TestComputerInfo(t *testing.T) {
skipIfNoDocker(t)
for _, m := range testModes() {
t.Run(m.Name, func(t *testing.T) {
setupSandbox(t, &m)
img := testImage()
res := runJS(t, fmt.Sprintf(`function TestComputerInfo() {
var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" });
var info = pc.ComputerInfo();
sandbox.Delete(pc.id);
return info.kind;
}`, img, m.TaiID))
if res != "box" {
t.Errorf("kind = %q, want 'box'", res)
}
})
}
}
// ---------------------------------------------------------------------------
// Computer.Info (box-only)
// ---------------------------------------------------------------------------
func TestBoxInfo(t *testing.T) {
skipIfNoDocker(t)
for _, m := range testModes() {
t.Run(m.Name, func(t *testing.T) {
setupSandbox(t, &m)
img := testImage()
res := runJS(t, fmt.Sprintf(`function TestBoxInfo() {
var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" });
var info = pc.Info();
sandbox.Delete(pc.id);
return info.id ? "ok" : "no-id";
}`, img, m.TaiID))
if res != "ok" {
t.Errorf("expected ok, got %v", res)
}
})
}
}
// ---------------------------------------------------------------------------
// Box-only method on host → error
// ---------------------------------------------------------------------------
func TestHostBoxMethodsThrow(t *testing.T) {
if os.Getenv("SANDBOX_TEST_REMOTE_ADDR") == "" {
t.Skip("no remote host configured")
}
for _, m := range testModes() {
if m.Name == "local" {
continue
}
t.Run(m.Name, func(t *testing.T) {
setupSandbox(t, &m)
errMsg := runJSExpectError(t, fmt.Sprintf(`function TestHostBoxMethodsThrow() {
var host = sandbox.Host("%s");
host.Info();
}`, m.TaiID))
if !strings.Contains(errMsg, "not supported") {
t.Errorf("expected 'not supported' error, got: %s", errMsg)
}
})
}
}
// ---------------------------------------------------------------------------
// Computer.kind property
// ---------------------------------------------------------------------------
func TestComputerKind(t *testing.T) {
skipIfNoDocker(t)
for _, m := range testModes() {
t.Run(m.Name, func(t *testing.T) {
setupSandbox(t, &m)
img := testImage()
res := runJS(t, fmt.Sprintf(`function TestComputerKind() {
var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" });
var k = pc.kind;
sandbox.Delete(pc.id);
return k;
}`, img, m.TaiID))
if res != "box" {
t.Errorf("kind = %q, want 'box'", res)
}
})
}
}
// ---------------------------------------------------------------------------
// sandbox.Nodes (requires registry)
// ---------------------------------------------------------------------------
func TestNodes(t *testing.T) {
test.Prepare(t, config.Conf)
registry.Init(nil)
res := runJS(t, `function TestNodes() {
var nodes = sandbox.Nodes();
return Array.isArray(nodes) ? "array" : typeof nodes;
}`)
if res != "array" {
t.Errorf("expected array, got %v", res)
}
}
func TestGetNodeNotFound(t *testing.T) {
test.Prepare(t, config.Conf)
registry.Init(nil)
res := runJS(t, `function TestGetNodeNotFound() {
var node = sandbox.GetNode("tai-nonexistent");
return node === null ? "null" : "found";
}`)
if res != "null" {
t.Errorf("expected null, got %v", res)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func toInt(v interface{}) int {
switch n := v.(type) {
case int:
return n
case int32:
return int(n)
case int64:
return int(n)
case float64:
return int(n)
case float32:
return int(n)
default:
return 0
}
}

View file

@ -1,105 +1,142 @@
package jsapi
import (
"encoding/json"
"time"
"github.com/yaoapp/yao/tai/registry"
"rogchap.com/v8go"
)
// sbGetNode: `sandbox.GetNode(taiID)` → NodeInfo | null
//
// Go: registry.Global().Get(taiID) (*NodeSnapshot, bool)
//
// Args:
//
// taiID: string — Tai node ID
//
// Returns: NodeInfo object if found, null if not found.
// Auth and YaoBase fields are excluded for security.
func sbGetNode(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. taiID = info.Args()[0].String()
// 2. snap, ok := registry.Global().Get(taiID)
// 3. if !ok { return v8go.Null }
// 4. Return snapshotToJS(v8ctx, snap)
return v8go.Undefined(info.Context().Isolate())
iso := info.Context().Isolate()
args := info.Args()
if len(args) < 1 || !args[0].IsString() {
return throwError(info, "GetNode requires taiID (string)")
}
reg := registry.Global()
if reg == nil {
return throwError(info, "registry not initialized")
}
snap, ok := reg.Get(args[0].String())
if !ok {
return v8go.Null(iso)
}
val, err := snapshotToJS(info.Context(), snap)
if err != nil {
return throwError(info, err.Error())
}
return val
}
// sbNodes: `sandbox.Nodes()` → NodeInfo[]
//
// Go: registry.Global().List() []NodeSnapshot
//
// Returns: array of NodeInfo objects for all registered Tai nodes.
func sbNodes(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. snaps := registry.Global().List()
// 2. Build JS array, for each: snapshotToJS(v8ctx, snap)
// 3. Return JS array
return v8go.Undefined(info.Context().Isolate())
v8ctx := info.Context()
reg := registry.Global()
if reg == nil {
return throwError(info, "registry not initialized")
}
snaps := reg.List()
return snapshotsToJSArray(v8ctx, snaps)
}
// sbNodesByTeam: `sandbox.NodesByTeam(teamID)` → NodeInfo[]
//
// Go: registry.Global().ListByTeam(teamID) []NodeSnapshot
//
// Args:
//
// teamID: string — team ID to filter by
//
// Returns: array of NodeInfo objects belonging to the given team.
func sbNodesByTeam(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. teamID = info.Args()[0].String()
// 2. snaps := registry.Global().ListByTeam(teamID)
// 3. Build JS array, for each: snapshotToJS(v8ctx, snap)
// 4. Return JS array
return v8go.Undefined(info.Context().Isolate())
v8ctx := info.Context()
args := info.Args()
if len(args) < 1 || !args[0].IsString() {
return throwError(info, "NodesByTeam requires teamID (string)")
}
reg := registry.Global()
if reg == nil {
return throwError(info, "registry not initialized")
}
snaps := reg.ListByTeam(args[0].String())
return snapshotsToJSArray(v8ctx, snaps)
}
// snapshotToJS converts a NodeSnapshot to a JS NodeInfo object.
//
// Excluded: Auth (sensitive), YaoBase (internal URL).
//
// NodeInfo JS object shape:
//
// {
// tai_id: string, ← NodeSnapshot.TaiID
// machine_id: string, ← NodeSnapshot.MachineID
// version: string, ← NodeSnapshot.Version
// mode: string, ← NodeSnapshot.Mode ("direct"|"tunnel")
// addr: string, ← NodeSnapshot.Addr
// status: string, ← NodeSnapshot.Status ("online"|"offline"|"connecting")
// pool: string, ← NodeSnapshot.PoolName
// connected_at: string, ← NodeSnapshot.ConnectedAt (ISO 8601)
// last_ping: string, ← NodeSnapshot.LastPing (ISO 8601)
// ports: { ← NodeSnapshot.Ports
// grpc: number,
// http: number,
// vnc: number,
// docker: number,
// k8s: number,
// },
// capabilities: { ← NodeSnapshot.Capabilities
// docker: boolean,
// k8s: boolean,
// host_exec: boolean,
// },
// system: { ← NodeSnapshot.System (SystemInfo)
// os: string,
// arch: string,
// hostname: string,
// num_cpu: number,
// total_mem: number,
// }
// }
//
//nolint:unused // placeholder for Phase 2
func snapshotToJS(v8ctx *v8go.Context, snap interface{}) (*v8go.Value, error) {
// TODO: Phase 2 implementation
// 1. Create JS object via v8go.NewObjectTemplate
// 2. Set scalar fields: tai_id, machine_id, version, mode, addr, status, pool
// 3. Set time fields: connected_at, last_ping → snap.ConnectedAt.Format(time.RFC3339)
// 4. Build ports sub-object from snap.Ports map
// 5. Build capabilities sub-object from snap.Capabilities map
// 6. Build system sub-object from snap.System (OS, Arch, Hostname, NumCPU, TotalMem)
// 7. Return the JS object
return nil, nil
// Auth and YaoBase are excluded for security.
func snapshotToJS(v8ctx *v8go.Context, snap *registry.NodeSnapshot) (*v8go.Value, error) {
ports := make(map[string]interface{}, len(snap.Ports))
for k, v := range snap.Ports {
ports[k] = v
}
caps := make(map[string]interface{}, len(snap.Capabilities))
for k, v := range snap.Capabilities {
caps[k] = v
}
data, err := json.Marshal(map[string]interface{}{
"tai_id": snap.TaiID,
"machine_id": snap.MachineID,
"version": snap.Version,
"mode": snap.Mode,
"addr": snap.Addr,
"status": snap.Status,
"pool": snap.PoolName,
"connected_at": snap.ConnectedAt.Format(time.RFC3339),
"last_ping": snap.LastPing.Format(time.RFC3339),
"ports": ports,
"capabilities": caps,
"system": map[string]interface{}{
"os": snap.System.OS,
"arch": snap.System.Arch,
"hostname": snap.System.Hostname,
"num_cpu": snap.System.NumCPU,
"total_mem": snap.System.TotalMem,
},
})
if err != nil {
return nil, err
}
return v8go.JSONParse(v8ctx, string(data))
}
func snapshotsToJSArray(v8ctx *v8go.Context, snaps []registry.NodeSnapshot) *v8go.Value {
items := make([]interface{}, 0, len(snaps))
for i := range snaps {
snap := &snaps[i]
ports := make(map[string]interface{}, len(snap.Ports))
for k, v := range snap.Ports {
ports[k] = v
}
caps := make(map[string]interface{}, len(snap.Capabilities))
for k, v := range snap.Capabilities {
caps[k] = v
}
items = append(items, map[string]interface{}{
"tai_id": snap.TaiID,
"machine_id": snap.MachineID,
"version": snap.Version,
"mode": snap.Mode,
"addr": snap.Addr,
"status": snap.Status,
"pool": snap.PoolName,
"connected_at": snap.ConnectedAt.Format(time.RFC3339),
"last_ping": snap.LastPing.Format(time.RFC3339),
"ports": ports,
"capabilities": caps,
"system": map[string]interface{}{
"os": snap.System.OS,
"arch": snap.System.Arch,
"hostname": snap.System.Hostname,
"num_cpu": snap.System.NumCPU,
"total_mem": snap.System.TotalMem,
},
})
}
data, _ := json.Marshal(items)
val, _ := v8go.JSONParse(v8ctx, string(data))
return val
}

View file

@ -7,49 +7,36 @@ import (
"time"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
taisandbox "github.com/yaoapp/yao/tai/sandbox"
"github.com/yaoapp/yao/workspace"
)
// Manager manages a pool of tai.Client connections and sandbox lifecycle.
// Manager manages sandbox lifecycle. Node connections are delegated to tai/registry.
type Manager struct {
pool map[string]*tai.Client
poolDefs []Pool
defaultPool string
config Config
boxes sync.Map
mu sync.Mutex
cancel context.CancelFunc
grpcPort int
wsManager *workspace.Manager
}
func newManager(cfg Config) (*Manager, error) {
m := &Manager{
pool: make(map[string]*tai.Client),
poolDefs: cfg.Pool,
config: cfg,
grpcPort: 9099,
}
if len(cfg.Pool) > 0 {
m.defaultPool = cfg.Pool[0].Name
}
return m, nil
func newManager() *Manager {
return &Manager{}
}
// Start discovers existing containers from all pools, rebuilds the boxes map,
// and starts the cleanup loop.
// Start discovers existing containers from all registered nodes, rebuilds
// the boxes map, and starts the cleanup loop.
func (m *Manager) Start(ctx context.Context) error {
if len(m.poolDefs) == 0 {
reg := registry.Global()
if reg == nil {
return nil
}
for _, pd := range m.poolDefs {
client, err := m.getPool(pd.Name)
for _, snap := range reg.List() {
client, err := m.getPool(snap.TaiID)
if err != nil {
continue
}
m.recoverBoxes(ctx, &pd, client)
m.recoverBoxes(ctx, snap.TaiID, client)
}
loopCtx, cancel := context.WithCancel(ctx)
@ -58,96 +45,13 @@ func (m *Manager) Start(ctx context.Context) error {
return nil
}
// AddPool registers a new pool at runtime.
func (m *Manager) AddPool(_ context.Context, p Pool) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, pd := range m.poolDefs {
if pd.Name == p.Name {
return fmt.Errorf("sandbox: pool %q already exists", p.Name)
}
}
m.poolDefs = append(m.poolDefs, p)
if m.defaultPool == "" {
m.defaultPool = p.Name
}
// Pools returns the list of registered Tai nodes from the registry.
func (m *Manager) Pools() []registry.NodeSnapshot {
reg := registry.Global()
if reg == nil {
return nil
}
// RemovePool removes a pool by name.
func (m *Manager) RemovePool(ctx context.Context, name string, force bool) error {
m.mu.Lock()
defer m.mu.Unlock()
idx := -1
for i, pd := range m.poolDefs {
if pd.Name == name {
idx = i
break
}
}
if idx < 0 {
return ErrPoolNotFound
}
count := 0
m.boxes.Range(func(_, value any) bool {
if value.(*Box).pool == name {
count++
}
return true
})
if count > 0 && !force {
return ErrPoolInUse
}
if count > 0 {
m.boxes.Range(func(key, value any) bool {
b := value.(*Box)
if b.pool == name {
b.Remove(ctx)
}
return true
})
}
m.poolDefs = append(m.poolDefs[:idx], m.poolDefs[idx+1:]...)
if client, ok := m.pool[name]; ok {
client.Close()
delete(m.pool, name)
}
return nil
}
// Pools returns all registered pool names and their status.
func (m *Manager) Pools() []PoolInfo {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]PoolInfo, 0, len(m.poolDefs))
for _, pd := range m.poolDefs {
_, connected := m.pool[pd.Name]
count := 0
m.boxes.Range(func(_, value any) bool {
if value.(*Box).pool == pd.Name {
count++
}
return true
})
result = append(result, PoolInfo{
Name: pd.Name,
Addr: pd.Addr,
Connected: connected,
Boxes: count,
MaxPerUser: pd.MaxPerUser,
MaxTotal: pd.MaxTotal,
IdleTimeout: pd.IdleTimeout,
MaxLifetime: pd.MaxLifetime,
})
}
return result
return reg.List()
}
// Heartbeat updates the box's last heartbeat timestamp.
@ -165,17 +69,9 @@ func (m *Manager) Heartbeat(sandboxID string, active bool, processCount int) err
}
// Host returns a Host handle for executing commands on the Tai host machine.
// The pool must be connected to a Tai server with host_exec capability.
// Unlike Create/Box, Host does not create a container — it is available
// immediately as long as the pool is reachable.
func (m *Manager) Host(_ context.Context, pool string) (*Host, error) {
if pool == "" {
pool = m.defaultPool
}
pd := m.findPoolDef(pool)
if pd == nil {
return nil, ErrPoolNotFound
return nil, ErrPoolMissing
}
client, err := m.getPool(pool)
@ -192,35 +88,24 @@ func (m *Manager) Host(_ context.Context, pool string) (*Host, error) {
// Create creates and starts a new sandbox.
func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) {
if len(m.poolDefs) == 0 {
return nil, ErrNotAvailable
}
if opts.Image == "" {
return nil, fmt.Errorf("sandbox: image is required")
}
poolName := opts.Pool
if poolName == "" {
poolName = m.defaultPool
}
// Workspace node binding: when WorkspaceID is set, resolve the workspace's
// bound node and force the container onto that pool.
if opts.WorkspaceID != "" && m.wsManager != nil {
node, err := m.wsManager.NodeForWorkspace(ctx, opts.WorkspaceID)
if opts.WorkspaceID != "" {
if wsm := workspace.M(); wsm != nil {
node, err := wsm.NodeForWorkspace(ctx, opts.WorkspaceID)
if err != nil {
return nil, fmt.Errorf("sandbox: resolve workspace %q: %w", opts.WorkspaceID, err)
}
poolName = node
}
pd := m.findPoolDef(poolName)
if pd == nil {
return nil, ErrPoolNotFound
}
if err := m.checkLimits(pd, opts.Owner); err != nil {
return nil, err
if poolName == "" {
return nil, ErrPoolMissing
}
id := opts.ID
@ -237,12 +122,7 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
return nil, fmt.Errorf("sandbox: pool %q has no container runtime", poolName)
}
access, refresh, err := CreateContainerTokens(id, opts.Owner, nil)
if err != nil {
return nil, fmt.Errorf("sandbox: create tokens: %w", err)
}
taiOpts := m.buildTaiCreateOptions(opts, pd, id, access, refresh)
taiOpts := m.buildTaiCreateOptions(opts, poolName, id)
containerID, err := client.Sandbox().Create(ctx, taiOpts)
if err != nil {
@ -267,9 +147,9 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
policy: policy,
labels: opts.Labels,
idleTimeoutD: opts.IdleTimeout,
maxLifetimeD: opts.MaxLifetime,
stopTimeoutD: opts.StopTimeout,
createdAt: time.Now(),
refreshToken: refresh,
manager: m,
vnc: opts.VNC,
image: opts.Image,
@ -337,10 +217,6 @@ func (m *Manager) Remove(ctx context.Context, id string) error {
client.Sandbox().Remove(ctx, b.containerID, true)
}
if b.refreshToken != "" {
RevokeContainerTokens(b.refreshToken)
}
m.boxes.Delete(id)
return nil
}
@ -376,32 +252,14 @@ func (m *Manager) Cleanup(ctx context.Context) error {
return nil
}
// Close stops the cleanup loop and releases all pool connections.
// Close stops the cleanup loop. Node connections are managed by the registry.
func (m *Manager) Close() error {
if m.cancel != nil {
m.cancel()
}
m.mu.Lock()
defer m.mu.Unlock()
for name, client := range m.pool {
client.Close()
delete(m.pool, name)
}
return nil
}
// SetGRPCPort sets the local gRPC port for container env injection.
func (m *Manager) SetGRPCPort(port int) {
m.grpcPort = port
}
// SetWorkspaceManager links the workspace manager for workspace-aware container creation.
// When CreateOptions.WorkspaceID is set, the sandbox Manager uses the workspace Manager
// to resolve the workspace's bound node and force container routing.
func (m *Manager) SetWorkspaceManager(wm *workspace.Manager) {
m.wsManager = wm
}
func (m *Manager) cleanupLoop(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
@ -416,78 +274,27 @@ func (m *Manager) cleanupLoop(ctx context.Context) {
}
func (m *Manager) getPool(name string) (*tai.Client, error) {
m.mu.Lock()
defer m.mu.Unlock()
if client, ok := m.pool[name]; ok {
return client, nil
}
pd := m.findPoolDefLocked(name)
if pd == nil {
client, ok := tai.GetClient(name)
if !ok {
return nil, ErrPoolNotFound
}
client, err := tai.New(pd.Addr, pd.Options...)
if err != nil {
return nil, err
}
m.pool[name] = client
return client, nil
}
func (m *Manager) findPoolDef(name string) *Pool {
m.mu.Lock()
defer m.mu.Unlock()
return m.findPoolDefLocked(name)
}
func (m *Manager) findPoolDefLocked(name string) *Pool {
for i := range m.poolDefs {
if m.poolDefs[i].Name == name {
return &m.poolDefs[i]
}
}
return nil
}
func (m *Manager) checkLimits(pd *Pool, owner string) error {
if pd.MaxTotal > 0 {
count := 0
m.boxes.Range(func(_, value any) bool {
if value.(*Box).pool == pd.Name {
count++
}
return true
})
if count >= pd.MaxTotal {
return ErrLimitExceeded
}
}
if pd.MaxPerUser > 0 && owner != "" {
count := 0
m.boxes.Range(func(_, value any) bool {
b := value.(*Box)
if b.pool == pd.Name && b.owner == owner {
count++
}
return true
})
if count >= pd.MaxPerUser {
return ErrLimitExceeded
}
}
return nil
}
func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID, access, refresh string) taisandbox.CreateOptions {
func (m *Manager) buildTaiCreateOptions(opts CreateOptions, poolName, sandboxID string) taisandbox.CreateOptions {
env := make(map[string]string)
for k, v := range opts.Env {
reg := registry.Global()
if reg != nil {
if snap, ok := reg.Get(poolName); ok {
grpcEnv := BuildGRPCEnv(snap.Mode, snap.Addr, sandboxID)
for k, v := range grpcEnv {
env[k] = v
}
grpcEnv := BuildGRPCEnv(pd, sandboxID, access, refresh, m.grpcPort)
for k, v := range grpcEnv {
}
}
for k, v := range opts.Env {
env[k] = v
}
@ -495,7 +302,7 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID,
"managed-by": "yao-sandbox",
"sandbox-id": sandboxID,
"sandbox-owner": opts.Owner,
"sandbox-pool": pd.Name,
"sandbox-pool": poolName,
"sandbox-policy": string(opts.Policy),
}
if opts.WorkspaceID != "" {
@ -522,9 +329,9 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID,
})
}
// Workspace bind mount
var binds []string
if opts.WorkspaceID != "" && m.wsManager != nil {
if opts.WorkspaceID != "" {
if wsm := workspace.M(); wsm != nil {
mountPath := opts.MountPath
if mountPath == "" {
mountPath = "/workspace"
@ -533,11 +340,12 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID,
if mode == "" {
mode = "rw"
}
hostPath, _ := m.wsManager.MountPath(context.Background(), opts.WorkspaceID)
hostPath, _ := wsm.MountPath(context.Background(), opts.WorkspaceID)
if hostPath != "" {
binds = append(binds, fmt.Sprintf("%s:%s:%s", hostPath, mountPath, mode))
}
}
}
return taisandbox.CreateOptions{
Name: sandboxID,
@ -555,7 +363,7 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID,
}
}
func (m *Manager) recoverBoxes(ctx context.Context, pd *Pool, client *tai.Client) {
func (m *Manager) recoverBoxes(ctx context.Context, poolName string, client *tai.Client) {
if client.Sandbox() == nil {
return
}
@ -598,8 +406,6 @@ func (m *Manager) recoverBoxes(ctx context.Context, pd *Pool, client *tai.Client
}
// ImageExists reports whether the given image ref exists on the target pool node.
// Returns (true, nil) when the pool has no image service (e.g. K8s — kubelet
// handles image pulls transparently).
func (m *Manager) ImageExists(ctx context.Context, pool, ref string) (bool, error) {
client, err := m.getPool(pool)
if err != nil {
@ -613,7 +419,7 @@ func (m *Manager) ImageExists(ctx context.Context, pool, ref string) (bool, erro
}
// PullImage pulls an image to the target pool node, returning a channel of
// real-time progress events. The channel is nil when no pull is needed (e.g. K8s mode).
// real-time progress events.
func (m *Manager) PullImage(ctx context.Context, pool, ref string, opts ImagePullOptions) (<-chan taisandbox.PullProgress, error) {
client, err := m.getPool(pool)
if err != nil {
@ -635,8 +441,7 @@ func (m *Manager) PullImage(ctx context.Context, pool, ref string, opts ImagePul
}
// EnsureImage checks whether the image exists on the pool node; if not, it
// pulls the image and blocks until the pull completes. Returns the first
// error encountered during pull. For K8s pools this is a no-op.
// pulls the image and blocks until the pull completes.
func (m *Manager) EnsureImage(ctx context.Context, pool, ref string, opts ImagePullOptions) error {
exists, err := m.ImageExists(ctx, pool, ref)
if err != nil {

View file

@ -12,9 +12,10 @@ func TestHeartbeatUpdates(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc)
err := m.Heartbeat(box.ID(), true, 5)
if err != nil {
@ -34,8 +35,9 @@ func TestHeartbeatUpdates(t *testing.T) {
func TestHeartbeatUnknownBox(t *testing.T) {
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
m := setupManagerForPool(t, &pc)
err := m.Heartbeat("nonexistent", true, 1)
if err != sandbox.ErrNotFound {
t.Errorf("err = %v, want ErrNotFound", err)
@ -48,17 +50,18 @@ func TestIdleCleanup(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) {
p.IdleTimeout = 1 * time.Second
})
ensureTestImage(t, m, pc.Name)
m := setupManagerForPool(t, &pc)
ensureTestImage(t, m, pc.TaiID)
ctx := context.Background()
box, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "test-user",
Pool: pc.TaiID,
Policy: sandbox.Session,
IdleTimeout: 1 * time.Second,
})
if err != nil {
t.Fatalf("Create: %v", err)
@ -83,17 +86,13 @@ func TestStartRecovery(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options}
m1 := setupManager(t, pool)
box := createTestBox(t, m1)
m1 := setupManagerForPool(t, &pc)
box := createTestBox(t, m1, pc)
boxID := box.ID()
cfg := sandbox.Config{Pool: []sandbox.Pool{pool}}
if err := sandbox.Init(cfg); err != nil {
t.Fatalf("Init2: %v", err)
}
sandbox.Init()
m2 := sandbox.M()
defer m2.Close()
@ -119,13 +118,13 @@ func TestPersistentNotCleaned(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) {
p.IdleTimeout = 1 * time.Second
})
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.Policy = sandbox.Persistent
co.IdleTimeout = 1 * time.Second
})
time.Sleep(2 * time.Second)

View file

@ -12,9 +12,10 @@ func TestCreateAndExec(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@ -37,9 +38,10 @@ func TestCreateWithLabels(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.Labels = map[string]string{"app": "test-app"}
})
@ -59,9 +61,10 @@ func TestGet(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc)
got, err := m.Get(context.Background(), box.ID())
if err != nil {
@ -76,8 +79,9 @@ func TestGet(t *testing.T) {
func TestGetNotFound(t *testing.T) {
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
m := setupManagerForPool(t, &pc)
_, err := m.Get(context.Background(), "nonexistent")
if err != sandbox.ErrNotFound {
t.Errorf("err = %v, want ErrNotFound", err)
@ -90,9 +94,10 @@ func TestList(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.Owner = "user-list"
})
@ -125,13 +130,15 @@ func TestRemove(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ensureTestImage(t, m, pc.Name)
m := setupManagerForPool(t, &pc)
ensureTestImage(t, m, pc.TaiID)
ctx := context.Background()
box, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "test-user",
Pool: pc.TaiID,
})
if err != nil {
t.Fatalf("Create: %v", err)
@ -149,81 +156,26 @@ func TestRemove(t *testing.T) {
}
}
func TestPoolLimits_MaxTotal(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) {
p.MaxTotal = 1
})
ensureTestImage(t, m, pc.Name)
box1 := createTestBox(t, m)
_ = box1
ctx := context.Background()
_, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "test-user",
})
if err != sandbox.ErrLimitExceeded {
t.Errorf("second Create err = %v, want ErrLimitExceeded", err)
}
})
}
}
func TestAddPool(t *testing.T) {
m := setupManager(t, sandbox.Pool{
Name: "default",
Addr: testLocalAddr(),
})
err := m.AddPool(context.Background(), sandbox.Pool{
Name: "extra",
Addr: testLocalAddr(),
})
if err != nil {
t.Fatalf("AddPool: %v", err)
}
pools := m.Pools()
if len(pools) != 2 {
t.Fatalf("Pools() = %d, want 2", len(pools))
}
err = m.AddPool(context.Background(), sandbox.Pool{
Name: "extra",
Addr: testLocalAddr(),
})
if err == nil {
t.Error("expected error for duplicate pool name")
}
}
func TestCreateNoImage(t *testing.T) {
m := setupManager(t, sandbox.Pool{
Name: "local",
Addr: testLocalAddr(),
})
m, pools := setupManager(t, poolConfig{Name: "local", Addr: testLocalAddr()})
_, err := m.Create(context.Background(), sandbox.CreateOptions{
Owner: "test",
Pool: pools[0].TaiID,
})
if err == nil {
t.Error("expected error for missing image")
}
}
func TestCreateNoPools(t *testing.T) {
m := setupManager(t)
func TestCreateNoPool(t *testing.T) {
m, _ := setupManager(t, poolConfig{Name: "local", Addr: testLocalAddr()})
_, err := m.Create(context.Background(), sandbox.CreateOptions{
Image: testImage(),
})
if err != sandbox.ErrNotAvailable {
t.Errorf("err = %v, want ErrNotAvailable", err)
if err != sandbox.ErrPoolMissing {
t.Errorf("err = %v, want ErrPoolMissing", err)
}
}
@ -236,14 +188,10 @@ func TestMultiPool(t *testing.T) {
t.Skip("need at least 2 pools (local + remote) for multi-pool test")
}
var sps []sandbox.Pool
for _, pc := range pools {
sps = append(sps, sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options})
}
m := setupManager(t, sps...)
m, registered := setupManager(t, pools...)
for _, pc := range pools {
ensureTestImage(t, m, pc.Name)
for _, pc := range registered {
ensureTestImage(t, m, pc.TaiID)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
@ -252,7 +200,7 @@ func TestMultiPool(t *testing.T) {
localBox, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "test-user",
Pool: "local",
Pool: registered[0].TaiID,
})
if err != nil {
t.Fatalf("Create on local: %v", err)
@ -262,7 +210,7 @@ func TestMultiPool(t *testing.T) {
remoteBox, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "test-user",
Pool: "remote",
Pool: registered[1].TaiID,
})
if err != nil {
t.Fatalf("Create on remote: %v", err)

View file

@ -3,15 +3,9 @@ package sandbox
var mgr *Manager
// Init initializes the global sandbox Manager.
// Config contains pool definitions. At least one Pool entry is required.
// Pass empty Pool list to disable sandbox (methods return ErrNotAvailable).
func Init(cfg Config) error {
m, err := newManager(cfg)
if err != nil {
return err
}
mgr = m
return nil
// Node discovery is handled by the tai/registry; no configuration is needed.
func Init() {
mgr = newManager()
}
// M returns the global Manager. Panics if Init was not called.

View file

@ -7,14 +7,7 @@ import (
)
func TestInit(t *testing.T) {
cfg := sandbox.Config{
Pool: []sandbox.Pool{
{Name: "test", Addr: "local"},
},
}
if err := sandbox.Init(cfg); err != nil {
t.Fatalf("Init: %v", err)
}
sandbox.Init()
m := sandbox.M()
if m == nil {
t.Fatal("M() returned nil")
@ -22,14 +15,6 @@ func TestInit(t *testing.T) {
m.Close()
}
func TestInitEmpty(t *testing.T) {
cfg := sandbox.Config{}
if err := sandbox.Init(cfg); err != nil {
t.Fatalf("Init with empty config: %v", err)
}
sandbox.M().Close()
}
func TestMPanicWithoutInit(t *testing.T) {
sandbox.ResetForTest()
defer func() {

View file

@ -13,8 +13,8 @@ import (
sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
taisandbox "github.com/yaoapp/yao/tai/sandbox"
"github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/workspace"
)
@ -99,16 +99,13 @@ func purgeStaleContainers() {
}
type poolConfig struct {
Name string
Name string // human-readable label for t.Run (e.g. "remote", "k8s")
Addr string
TaiID string // actual registry key, filled after tai.New
Options []tai.Option
}
// testPools returns all available pool configurations for multi-mode testing.
// - local: always present (direct Docker daemon)
// - remote: when SANDBOX_TEST_REMOTE_ADDR is set (Tai on host → Docker)
// - containerized: when TAI_TEST_CONTAINERIZED_HOST is set (Tai in container → Docker)
// - k8s: when TAI_TEST_K8S_HOST + TAI_TEST_KUBECONFIG are set (Tai → K8s)
func testPools() []poolConfig {
pools := []poolConfig{
{Name: "local", Addr: testLocalAddr()},
@ -119,8 +116,6 @@ func testPools() []poolConfig {
if host := os.Getenv("TAI_TEST_CONTAINERIZED_HOST"); host != "" {
grpcPort := envPort("TAI_TEST_CONTAINERIZED_GRPC_PORT", 9200)
addr := fmt.Sprintf("tai://%s:%d", host, grpcPort)
// No WithPorts for HTTP/VNC — Tai self-inspects its container
// and returns host-mapped ports via ServerInfo automatically.
pools = append(pools, poolConfig{Name: "containerized", Addr: addr})
}
if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" {
@ -164,11 +159,10 @@ func skipIfNoTai(t *testing.T) {
type hostExecTarget struct {
Name string
Addr string // host:port (without tai:// prefix)
TaiID string // filled after registration
IsWinNative bool
}
// hostExecTargets returns all Tai instances that support HostExec gRPC.
// No container creation needed — these are direct gRPC connections.
func hostExecTargets() []hostExecTarget {
var targets []hostExecTarget
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
@ -195,8 +189,6 @@ func skipIfNoHostExec(t *testing.T) {
}
}
// linuxCmd adapts a Linux command to the equivalent Windows command for
// Windows native Tai targets.
func linuxCmd(tgt hostExecTarget, cmd string, args ...string) (string, []string) {
if tgt.IsWinNative {
switch cmd {
@ -245,55 +237,64 @@ func envPort(key string, fallback int) int {
return fallback
}
func setupManager(t *testing.T, pools ...sandbox.Pool) *sandbox.Manager {
// registerPool creates a tai.Client and registers it in the global registry.
// It fills pc.TaiID with the actual registry key returned by tai.New.
func registerPool(t *testing.T, pc *poolConfig) {
t.Helper()
cfg := sandbox.Config{Pool: pools}
if err := sandbox.Init(cfg); err != nil {
t.Fatalf("Init: %v", err)
reg := registry.Global()
if reg == nil {
registry.Init(nil)
}
client, err := tai.New(pc.Addr, pc.Options...)
if err != nil {
t.Fatalf("tai.New(%s): %v", pc.Addr, err)
}
pc.TaiID = client.TaiID()
t.Cleanup(func() { client.Close() })
}
func setupManager(t *testing.T, pools ...poolConfig) (*sandbox.Manager, []poolConfig) {
t.Helper()
reg := registry.Global()
if reg == nil {
registry.Init(nil)
}
_ = reg
out := make([]poolConfig, len(pools))
copy(out, pools)
for i := range out {
client, err := tai.New(out[i].Addr, out[i].Options...)
if err != nil {
t.Fatalf("tai.New(%s): %v", out[i].Addr, err)
}
out[i].TaiID = client.TaiID()
}
sandbox.Init()
m := sandbox.M()
t.Cleanup(func() {
m.Close()
})
t.Cleanup(func() { m.Close() })
return m, out
}
func setupManagerForPool(t *testing.T, pc *poolConfig) *sandbox.Manager {
t.Helper()
m, registered := setupManager(t, *pc)
*pc = registered[0]
return m
}
func setupManagerForPool(t *testing.T, pc poolConfig, mutators ...func(*sandbox.Pool)) *sandbox.Manager {
t.Helper()
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options}
for _, fn := range mutators {
fn(&pool)
}
return setupManager(t, pool)
}
// setupManagerWithWorkspace creates a sandbox Manager with a linked workspace Manager.
// Returns both managers and a helper to create workspaces on the given pool's node.
func setupManagerWithWorkspace(t *testing.T, pc poolConfig) (*sandbox.Manager, *workspace.Manager) {
// setupManagerWithWorkspace creates a sandbox Manager and returns
// the global workspace.Manager (which uses the registry for client lookups).
func setupManagerWithWorkspace(t *testing.T, pc *poolConfig) (*sandbox.Manager, *workspace.Manager) {
t.Helper()
sbm := setupManagerForPool(t, pc)
var wsClient *tai.Client
var err error
if pc.Addr == "local" || pc.Addr == "" {
dataDir := t.TempDir()
vol := volume.NewLocal(dataDir)
wsClient, err = tai.New("local", tai.WithVolume(vol), tai.WithDataDir(dataDir))
} else {
wsClient, err = tai.New(pc.Addr, pc.Options...)
}
if err != nil {
t.Fatalf("tai.New for workspace: %v", err)
}
t.Cleanup(func() { wsClient.Close() })
wsm := workspace.NewManager(map[string]*tai.Client{pc.Name: wsClient})
sbm.SetWorkspaceManager(wsm)
return sbm, wsm
return sbm, workspace.M()
}
// ensureTestImage guarantees testImage() is available on the given pool before
// container creation. Safe for all modes (Docker pull; K8s no-op).
func ensureTestImage(t *testing.T, m *sandbox.Manager, pool string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
@ -303,11 +304,12 @@ func ensureTestImage(t *testing.T, m *sandbox.Manager, pool string) {
}
}
func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.CreateOptions)) *sandbox.Box {
func createTestBox(t *testing.T, m *sandbox.Manager, pc poolConfig, opts ...func(*sandbox.CreateOptions)) *sandbox.Box {
t.Helper()
co := sandbox.CreateOptions{
Image: testImage(),
Owner: "test-user",
Pool: pc.TaiID,
}
for _, fn := range opts {
fn(&co)
@ -317,11 +319,12 @@ func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.Creat
if pool == "" {
pools := m.Pools()
if len(pools) > 0 {
pool = pools[0].Name
pool = pools[0].TaiID
co.Pool = pool
}
}
isK8s := pool == "k8s"
isK8s := pc.Name == "k8s"
if isK8s {
k8sSem <- struct{}{}
}

View file

@ -5,7 +5,6 @@ import (
"io"
"time"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/workspace"
)
@ -56,7 +55,7 @@ type SystemInfo struct {
}
// ---------------------------------------------------------------------------
// Lifecycle & Pool
// Lifecycle
// ---------------------------------------------------------------------------
type LifecyclePolicy string
@ -70,28 +69,6 @@ const (
const DefaultStopTimeout = 2 * time.Second
type Pool struct {
Name string
Addr string
Options []tai.Option
MaxPerUser int
MaxTotal int
IdleTimeout time.Duration
MaxLifetime time.Duration
StopTimeout time.Duration
}
type PoolInfo struct {
Name string
Addr string
Connected bool
Boxes int
MaxPerUser int
MaxTotal int
IdleTimeout time.Duration
MaxLifetime time.Duration
}
// ---------------------------------------------------------------------------
// Create / List options
// ---------------------------------------------------------------------------
@ -118,6 +95,7 @@ type CreateOptions struct {
Ports []PortMapping
Policy LifecyclePolicy
IdleTimeout time.Duration
MaxLifetime time.Duration
StopTimeout time.Duration
WorkspaceID string

View file

@ -44,6 +44,8 @@ type TaiNode struct {
LastPing time.Time
PoolName string
client any // *tai.Client; stored as any to avoid import cycle
localListeners map[int]*tunnelListener
}
@ -63,6 +65,7 @@ type NodeSnapshot struct {
ConnectedAt time.Time
LastPing time.Time
PoolName string
client any
}
func (n *TaiNode) snapshot() NodeSnapshot {
@ -81,9 +84,14 @@ func (n *TaiNode) snapshot() NodeSnapshot {
Ports: ports, Capabilities: caps,
Status: n.Status, ConnectedAt: n.ConnectedAt, LastPing: n.LastPing,
PoolName: n.PoolName,
client: n.client,
}
}
// Client returns the associated *tai.Client (as any to avoid import cycle).
// Callers should type-assert: snap.Client().(*tai.Client).
func (s *NodeSnapshot) Client() any { return s.client }
// AuthInfo holds Yao user authorization extracted from OAuth token.
type AuthInfo struct {
Subject string
@ -234,6 +242,16 @@ func (r *Registry) UpdatePing(taiID string) {
}
}
// SetClient associates a *tai.Client with a registered node.
// Called by tai.New() after successful initialization.
func (r *Registry) SetClient(taiID string, c any) {
r.mu.Lock()
defer r.mu.Unlock()
if n, ok := r.nodes[taiID]; ok {
n.client = c
}
}
// ListByTeam returns snapshots of all nodes belonging to the given team.
func (r *Registry) ListByTeam(teamID string) []NodeSnapshot {
r.mu.RLock()

View file

@ -130,6 +130,7 @@ type Client struct {
scheme string // "tai", "docker", or "tunnel"
host string
addr string
taiID string // registry key — set by initLocal/initRemote/initTunnel
ports Ports
dataDir string // host-side data directory for local volume
vol volume.Volume
@ -209,6 +210,23 @@ func (c *Client) initLocal(cfg *config) (*Client, error) {
c.dataDir = dataDir
c.vol = volume.NewLocal(dataDir)
}
if reg := registry.Global(); reg != nil {
id := c.host
if id == "" {
id = c.addr
}
if id == "" {
id = "local"
}
c.taiID = id
reg.Register(&registry.TaiNode{
TaiID: id,
Mode: "local",
Addr: c.addr,
})
reg.SetClient(id, c)
}
return c, nil
}
@ -276,10 +294,12 @@ func (c *Client) initRemote(cfg *config) (*Client, error) {
}
if reg := registry.Global(); reg != nil {
id := fmt.Sprintf("%s-%d", c.host, c.ports.GRPC)
c.taiID = id
reg.Register(&registry.TaiNode{
TaiID: c.host,
TaiID: id,
Mode: "direct",
Addr: c.host,
Addr: fmt.Sprintf("tai://%s:%d", c.host, c.ports.GRPC),
Ports: map[string]int{
"grpc": c.ports.GRPC,
"http": c.ports.HTTP,
@ -288,6 +308,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) {
"k8s": c.ports.K8s,
},
})
reg.SetClient(id, c)
}
return c, nil
@ -300,6 +321,7 @@ func (c *Client) initTunnel(cfg *config) (*Client, error) {
}
taiID := c.host // for tunnel:// scheme, host stores the taiID
c.taiID = taiID
node, ok := reg.Get(taiID)
if !ok || node.Status != "online" {
return nil, fmt.Errorf("tai node %s not online", taiID)
@ -360,6 +382,7 @@ func (c *Client) initTunnel(cfg *config) (*Client, error) {
c.prx = proxy.NewTunnel(taiID, node.YaoBase)
c.vc = vnc.NewTunnel(taiID, node.YaoBase)
}
reg.SetClient(taiID, c)
return c, nil
}
@ -396,9 +419,9 @@ func (c *Client) Close() error {
}
}
c.closeTunnelListeners()
if c.scheme == "tai" {
if c.taiID != "" {
if reg := registry.Global(); reg != nil {
reg.Unregister(c.host)
reg.Unregister(c.taiID)
}
}
if len(errs) > 0 {
@ -414,6 +437,12 @@ func (c *Client) Volume() volume.Volume { return c.vol }
// Empty for remote (Tai gRPC) connections — the Tai server manages paths.
func (c *Client) DataDir() string { return c.dataDir }
// Host returns the raw host parsed from the address (IP or hostname).
func (c *Client) Host() string { return c.host }
// TaiID returns the registry key for this client.
func (c *Client) TaiID() string { return c.taiID }
// Workspace returns an fs.FS-compatible filesystem for the given session.
func (c *Client) Workspace(sessionID string) workspace.FS {
return workspace.New(c.vol, sessionID)
@ -549,3 +578,20 @@ func (c *Client) discoverServerInfo(conn *grpc.ClientConn, cfg *config) (map[str
}
return caps, nil
}
// GetClient returns a registered *Client by taiID from the global registry.
func GetClient(taiID string) (*Client, bool) {
reg := registry.Global()
if reg == nil {
return nil, false
}
snap, ok := reg.Get(taiID)
if !ok {
return nil, false
}
c, ok := snap.Client().(*Client)
if !ok || c == nil {
return nil, false
}
return c, true
}

View file

@ -9,9 +9,9 @@ import (
v8runtime "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/test"
"github.com/yaoapp/yao/workspace"
_ "github.com/yaoapp/yao/workspace/jsapi"
)
@ -32,6 +32,8 @@ func testModes() []testMode {
func setupForMode(t *testing.T, m testMode) {
t.Helper()
test.Prepare(t, config.Conf)
registry.Init(nil)
var client *tai.Client
var err error
if m.Addr == "local" {
@ -45,7 +47,6 @@ func setupForMode(t *testing.T, m testMode) {
t.Fatalf("tai.New(%s): %v", m.Addr, err)
}
t.Cleanup(func() { client.Close() })
workspace.Init(map[string]*tai.Client{"default": client})
}
func setupGlobal(t *testing.T) {
@ -88,7 +89,7 @@ func TestWSCreateAndDelete(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSCreateAndDelete() {
var ws = workspace.Create({ name: "test-proj", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "test-proj", owner: "u1", node: "local" });
var id = ws.id;
workspace.Delete(id);
return id;
@ -105,7 +106,7 @@ func TestWSGet(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSGet() {
var ws = workspace.Create({ name: "get-test", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "get-test", owner: "u1", node: "local" });
var got = workspace.Get(ws.id);
var result = got ? got.id : "null";
workspace.Delete(ws.id);
@ -138,8 +139,8 @@ func TestWSList(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSList() {
var ws1 = workspace.Create({ name: "list-a", owner: "u1", node: "default" });
var ws2 = workspace.Create({ name: "list-b", owner: "u1", node: "default" });
var ws1 = workspace.Create({ name: "list-a", owner: "u1", node: "local" });
var ws2 = workspace.Create({ name: "list-b", owner: "u1", node: "local" });
var list = workspace.List({ owner: "u1" });
var count = list.length;
workspace.Delete(ws1.id);
@ -158,7 +159,7 @@ func TestWSReadWriteFile(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSReadWriteFile() {
var ws = workspace.Create({ name: "rw-test", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "rw-test", owner: "u1", node: "local" });
ws.WriteFile("hello.txt", "Hello, World!");
var content = ws.ReadFile("hello.txt");
workspace.Delete(ws.id);
@ -176,7 +177,7 @@ func TestWSReadDir(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSReadDir() {
var ws = workspace.Create({ name: "readdir", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "readdir", owner: "u1", node: "local" });
ws.WriteFile("a.txt", "aaa");
ws.MkdirAll("sub");
ws.WriteFile("sub/b.txt", "bbb");
@ -196,7 +197,7 @@ func TestWSReadDirRecursive(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSReadDirRecursive() {
var ws = workspace.Create({ name: "readdir-r", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "readdir-r", owner: "u1", node: "local" });
ws.WriteFile("a.txt", "aaa");
ws.MkdirAll("sub/deep");
ws.WriteFile("sub/b.txt", "bbb");
@ -217,7 +218,7 @@ func TestWSStat(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSStat() {
var ws = workspace.Create({ name: "stat-test", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "stat-test", owner: "u1", node: "local" });
ws.WriteFile("file.txt", "12345");
var info = ws.Stat("file.txt");
workspace.Delete(ws.id);
@ -235,7 +236,7 @@ func TestWSExistsIsDirIsFile(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSExistsIsDirIsFile() {
var ws = workspace.Create({ name: "checks", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "checks", owner: "u1", node: "local" });
ws.WriteFile("f.txt", "data");
ws.MkdirAll("d");
var r = [
@ -261,7 +262,7 @@ func TestWSRemoveAndRename(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSRemoveAndRename() {
var ws = workspace.Create({ name: "ops", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "ops", owner: "u1", node: "local" });
ws.WriteFile("del.txt", "x");
ws.Remove("del.txt");
var a = ws.Exists("del.txt");
@ -291,7 +292,7 @@ func TestWSBase64(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSBase64() {
var ws = workspace.Create({ name: "b64", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "b64", owner: "u1", node: "local" });
ws.WriteFile("src.txt", "base64 test");
var b64 = ws.ReadFileBase64("src.txt");
ws.WriteFileBase64("dst.txt", b64);
@ -311,7 +312,7 @@ func TestWSCopyInternal(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSCopyInternal() {
var ws = workspace.Create({ name: "copy", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "copy", owner: "u1", node: "local" });
ws.WriteFile("src.txt", "copy me");
ws.Copy("src.txt", "dst.txt");
var content = ws.ReadFile("dst.txt");
@ -336,7 +337,7 @@ func TestWSCopyLocalToLocal(t *testing.T) {
dstRel := dstDir[len(os.TempDir()):]
runJS(t, `function TestWSCopyLocalToLocal() {
var ws = workspace.Create({ name: "l2l", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "l2l", owner: "u1", node: "local" });
ws.Copy("tmp://`+srcRel+`", "tmp://`+dstRel+`");
workspace.Delete(ws.id);
return "ok";
@ -356,7 +357,7 @@ func TestWSZipUnzip(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSZipUnzip() {
var ws = workspace.Create({ name: "zip", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "zip", owner: "u1", node: "local" });
ws.MkdirAll("src");
ws.WriteFile("src/a.txt", "zip content");
ws.WriteFile("src/b.txt", "more");
@ -378,7 +379,7 @@ func TestWSGzipGunzip(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSGzipGunzip() {
var ws = workspace.Create({ name: "gzip", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "gzip", owner: "u1", node: "local" });
ws.WriteFile("data.txt", "gzip test");
ws.Gzip("data.txt", "data.txt.gz");
ws.Gunzip("data.txt.gz", "restored.txt");
@ -398,7 +399,7 @@ func TestWSTarUntar(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSTarUntar() {
var ws = workspace.Create({ name: "tar", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "tar", owner: "u1", node: "local" });
ws.MkdirAll("src");
ws.WriteFile("src/a.txt", "tar a");
ws.Tar("src", "out.tar");
@ -419,7 +420,7 @@ func TestWSTgzUntgz(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSTgzUntgz() {
var ws = workspace.Create({ name: "tgz", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "tgz", owner: "u1", node: "local" });
ws.MkdirAll("src");
ws.WriteFile("src/x.txt", "tgz x");
ws.Tgz("src", "out.tgz");
@ -440,7 +441,7 @@ func TestWSZipExcludes(t *testing.T) {
t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m)
res := runJS(t, `function TestWSZipExcludes() {
var ws = workspace.Create({ name: "zip-exc", owner: "u1", node: "default" });
var ws = workspace.Create({ name: "zip-exc", owner: "u1", node: "local" });
ws.MkdirAll("src");
ws.WriteFile("src/keep.txt", "keep");
ws.WriteFile("src/skip.log", "skip");

View file

@ -4,42 +4,28 @@ import (
"context"
"fmt"
"os"
"sync"
"time"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/volume"
taiworkspace "github.com/yaoapp/yao/tai/workspace"
)
var mgr *Manager
var mgr = NewManager()
// Init initializes the global workspace Manager with the given pools.
func Init(pools map[string]*tai.Client) {
mgr = NewManager(pools)
}
// M returns the global Manager. Panics if Init was not called.
// M returns the global Manager.
func M() *Manager {
if mgr == nil {
panic("workspace.Init not called")
}
return mgr
}
// Manager owns workspace CRUD, file I/O, and node management.
// Pools are shared with sandbox.Manager — both reference the same tai.Client instances.
type Manager struct {
pools map[string]*tai.Client
mu sync.RWMutex
}
// All node/client lookups go through tai.GetClient → registry.
type Manager struct{}
// NewManager creates a workspace manager with the given pools.
func NewManager(pools map[string]*tai.Client) *Manager {
if pools == nil {
pools = make(map[string]*tai.Client)
}
return &Manager{pools: pools}
// NewManager creates a workspace manager.
func NewManager() *Manager {
return &Manager{}
}
// Create allocates storage on the target node and persists metadata.
@ -48,9 +34,9 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, e
return nil, ErrNodeMissing
}
client, err := m.getClient(opts.Node)
if err != nil {
return nil, err
client, ok := tai.GetClient(opts.Node)
if !ok {
return nil, ErrNodeOffline
}
id := opts.ID
@ -87,18 +73,19 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, e
}
// Get returns a workspace by ID.
// If the node is unknown, scans all pools.
// Scans all registered nodes.
func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for nodeName, client := range m.pools {
ws, err := m.readMeta(ctx, client, id)
for _, snap := range listNodes() {
client, ok := tai.GetClient(snap.TaiID)
if !ok {
continue
}
ws, err := readMeta(ctx, client, id)
if err != nil {
continue
}
if ws.Node == "" {
ws.Node = nodeName
ws.Node = snap.TaiID
}
return ws, nil
}
@ -107,12 +94,13 @@ func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) {
// List returns workspaces, optionally filtered by owner and/or node.
func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*Workspace
for nodeName, client := range m.pools {
if opts.Node != "" && nodeName != opts.Node {
for _, snap := range listNodes() {
if opts.Node != "" && snap.TaiID != opts.Node {
continue
}
client, ok := tai.GetClient(snap.TaiID)
if !ok {
continue
}
entries, err := client.Volume().ListDir(ctx, "", ".")
@ -123,12 +111,12 @@ func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, err
if !e.IsDir {
continue
}
ws, err := m.readMeta(ctx, client, e.Path)
ws, err := readMeta(ctx, client, e.Path)
if err != nil {
continue
}
if ws.Node == "" {
ws.Node = nodeName
ws.Node = snap.TaiID
}
if opts.Owner != "" && ws.Owner != opts.Owner {
continue
@ -179,28 +167,25 @@ func (m *Manager) Delete(ctx context.Context, id string, force bool) error {
return nil
}
// Nodes returns all configured Tai nodes with their online status.
// Nodes returns all registered Tai nodes with their online status.
func (m *Manager) Nodes() []NodeInfo {
m.mu.RLock()
defer m.mu.RUnlock()
nodes := make([]NodeInfo, 0, len(m.pools))
for name := range m.pools {
nodes = append(nodes, NodeInfo{
Name: name,
Online: true,
nodes := listNodes()
result := make([]NodeInfo, 0, len(nodes))
for _, snap := range nodes {
result = append(result, NodeInfo{
Name: snap.TaiID,
Online: snap.Status == "online" || snap.Status == "",
})
}
return nodes
return result
}
// FS returns an fs.FS-compatible filesystem for the given workspace.
func (m *Manager) FS(ctx context.Context, id string) (taiworkspace.FS, error) {
ws, client, err := m.resolve(ctx, id)
_, client, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
_ = ws
return client.Workspace(id), nil
}
@ -280,20 +265,6 @@ func (m *Manager) Volume(ctx context.Context, id string) (volume.Volume, string,
return client.Volume(), id, nil
}
// AddPool registers a new Tai node.
func (m *Manager) AddPool(name string, client *tai.Client) {
m.mu.Lock()
defer m.mu.Unlock()
m.pools[name] = client
}
// RemovePool unregisters a Tai node.
func (m *Manager) RemovePool(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.pools, name)
}
// NodeForWorkspace returns the node name for a given workspace ID.
// Used by sandbox.Manager to route container creation to the correct pool.
func (m *Manager) NodeForWorkspace(ctx context.Context, id string) (string, error) {
@ -306,7 +277,6 @@ func (m *Manager) NodeForWorkspace(ctx context.Context, id string) (string, erro
// MountPath returns the host-side directory path for a workspace,
// suitable for use as a Docker bind mount source.
// For local volumes this is dataDir/{id}; for remote (Tai) the server handles mounts.
func (m *Manager) MountPath(ctx context.Context, id string) (string, error) {
_, client, err := m.resolve(ctx, id)
if err != nil {
@ -321,23 +291,14 @@ func (m *Manager) MountPath(ctx context.Context, id string) (string, error) {
// --- internal ---
func (m *Manager) getClient(node string) (*tai.Client, error) {
m.mu.RLock()
defer m.mu.RUnlock()
client, ok := m.pools[node]
if !ok {
return nil, ErrNodeOffline
}
return client, nil
}
// resolve finds the workspace and its tai.Client by scanning pools.
// resolve finds the workspace and its tai.Client by scanning all registered nodes.
func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, *tai.Client, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, client := range m.pools {
ws, err := m.readMeta(ctx, client, id)
for _, snap := range listNodes() {
client, ok := tai.GetClient(snap.TaiID)
if !ok {
continue
}
ws, err := readMeta(ctx, client, id)
if err != nil {
continue
}
@ -346,7 +307,7 @@ func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, *tai.Clie
return nil, nil, ErrNotFound
}
func (m *Manager) readMeta(ctx context.Context, client *tai.Client, id string) (*Workspace, error) {
func readMeta(ctx context.Context, client *tai.Client, id string) (*Workspace, error) {
data, _, err := client.Volume().ReadFile(ctx, id, metadataFile)
if err != nil {
return nil, err
@ -354,6 +315,14 @@ func (m *Manager) readMeta(ctx context.Context, client *tai.Client, id string) (
return unmarshalMeta(data)
}
func listNodes() []registry.NodeSnapshot {
reg := registry.Global()
if reg == nil {
return nil
}
return reg.List()
}
// DirEntry represents a file or directory entry in a workspace listing.
type DirEntry struct {
Name string `json:"name"`

View file

@ -2,11 +2,14 @@ package workspace_test
import (
"context"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/workspace"
)
@ -21,19 +24,47 @@ func testPools() []poolConfig {
{Name: "local", Addr: "local"},
}
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
pools = append(pools, poolConfig{Name: "remote", Addr: addr})
name := taiIDFromAddr(addr)
pools = append(pools, poolConfig{Name: name, Addr: addr})
}
return pools
}
func setupManagerForPool(tb testing.TB, pc poolConfig) *workspace.Manager {
tb.Helper()
client := clientForPool(tb, pc)
pools := map[string]*tai.Client{pc.Name: client}
return workspace.NewManager(pools)
func taiIDFromAddr(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "local" || addr == "" {
return "local"
}
if !strings.Contains(addr, "://") {
addr = "tai://" + addr
}
u, err := url.Parse(addr)
if err != nil {
return addr
}
h := u.Hostname()
if h == "" {
return addr
}
if p := u.Port(); p != "" {
return h + "-" + p
}
return h
}
func clientForPool(tb testing.TB, pc poolConfig) *tai.Client {
func ensureRegistry(tb testing.TB) {
tb.Helper()
registry.Init(nil)
}
func setupManagerForPool(tb testing.TB, pc poolConfig) *workspace.Manager {
tb.Helper()
ensureRegistry(tb)
registerClient(tb, pc)
return workspace.NewManager()
}
func registerClient(tb testing.TB, pc poolConfig) *tai.Client {
tb.Helper()
if pc.Addr == "local" {
return localClient(tb, tb.TempDir())
@ -57,13 +88,25 @@ func localClient(tb testing.TB, dataDir string) *tai.Client {
return client
}
func setupManagerMultiNode(t *testing.T) *workspace.Manager {
func setupManagerMultiNode(t *testing.T) (*workspace.Manager, string, string) {
t.Helper()
pools := map[string]*tai.Client{
"node-a": localClient(t, t.TempDir()),
"node-b": localClient(t, t.TempDir()),
ensureRegistry(t)
dir1 := t.TempDir()
vol1 := volume.NewLocal(dir1)
_, err := tai.New("docker://node-a", tai.WithVolume(vol1), tai.WithDataDir(dir1))
if err != nil {
t.Fatalf("tai.New node-a: %v", err)
}
return workspace.NewManager(pools)
dir2 := t.TempDir()
vol2 := volume.NewLocal(dir2)
_, err = tai.New("docker://node-b", tai.WithVolume(vol2), tai.WithDataDir(dir2))
if err != nil {
t.Fatalf("tai.New node-b: %v", err)
}
return workspace.NewManager(), "docker://node-a", "docker://node-b"
}
func createWorkspace(tb testing.TB, m *workspace.Manager, node string, opts ...func(*workspace.CreateOptions)) *workspace.Workspace {

View file

@ -7,8 +7,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/workspace"
)
@ -157,18 +155,18 @@ func TestList_FilterOwner(t *testing.T) {
}
func TestList_FilterNode(t *testing.T) {
m := setupManagerMultiNode(t)
m, nodeA, nodeB := setupManagerMultiNode(t)
ctx := context.Background()
_, err := m.Create(ctx, workspace.CreateOptions{Name: "a", Owner: "u", Node: "node-a"})
_, err := m.Create(ctx, workspace.CreateOptions{Name: "a", Owner: "u", Node: nodeA})
require.NoError(t, err)
_, err = m.Create(ctx, workspace.CreateOptions{Name: "b", Owner: "u", Node: "node-b"})
_, err = m.Create(ctx, workspace.CreateOptions{Name: "b", Owner: "u", Node: nodeB})
require.NoError(t, err)
list, err := m.List(ctx, workspace.ListOptions{Node: "node-a"})
list, err := m.List(ctx, workspace.ListOptions{Node: nodeA})
require.NoError(t, err)
assert.Len(t, list, 1)
assert.Equal(t, "node-a", list[0].Node)
assert.Equal(t, nodeA, list[0].Node)
}
func TestUpdate_Name(t *testing.T) {
@ -246,17 +244,16 @@ func TestDelete_NotFound(t *testing.T) {
}
func TestNodes(t *testing.T) {
m := setupManagerMultiNode(t)
m, nodeA, nodeB := setupManagerMultiNode(t)
nodes := m.Nodes()
assert.Len(t, nodes, 2)
assert.GreaterOrEqual(t, len(nodes), 2)
names := make(map[string]bool)
for _, n := range nodes {
names[n.Name] = true
assert.True(t, n.Online)
}
assert.True(t, names["node-a"])
assert.True(t, names["node-b"])
assert.True(t, names[nodeA])
assert.True(t, names[nodeB])
}
func TestNodeForWorkspace(t *testing.T) {
@ -282,29 +279,17 @@ func TestNodeForWorkspace_NotFound(t *testing.T) {
}
}
func TestAddPool(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
assert.Len(t, m.Nodes(), 1)
func TestRegistryDrivenNodes(t *testing.T) {
m, nodeA, nodeB := setupManagerMultiNode(t)
nodes := m.Nodes()
assert.GreaterOrEqual(t, len(nodes), 2)
vol := volume.NewLocal(t.TempDir())
client, err := tai.New("local", tai.WithVolume(vol))
require.NoError(t, err)
defer client.Close()
m.AddPool("new-node", client)
assert.Len(t, m.Nodes(), 2)
})
names := make(map[string]bool)
for _, n := range nodes {
names[n.Name] = true
}
}
func TestRemovePool(t *testing.T) {
m := setupManagerMultiNode(t)
assert.Len(t, m.Nodes(), 2)
m.RemovePool("node-b")
assert.Len(t, m.Nodes(), 1)
assert.True(t, names[nodeA])
assert.True(t, names[nodeB])
}
func TestMountPath(t *testing.T) {