chore: update unit-test workflow to use latest Tai image

- Change Docker image references in the unit-test workflow from yaoapp/tai:1.2.0 to yaoapp/tai:latest for consistency and to ensure the latest features and fixes are utilized.
- Update related documentation to reflect the unified Computer interface in the sandbox, replacing Box and Host references with Computer.

Made-with: Cursor
This commit is contained in:
Max 2026-03-08 22:52:11 +08:00
parent 4d4b03be96
commit 43fd532357
8 changed files with 426 additions and 492 deletions

View file

@ -782,7 +782,7 @@ jobs:
- name: Pull Test Images
run: |
docker pull yaoapp/tai-sandbox-test:latest || true
docker pull yaoapp/tai:1.2.0
docker pull yaoapp/tai:latest
docker pull alpine:latest
- name: Install k3d
@ -799,7 +799,7 @@ jobs:
docker run -d --name tai-docker \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 19100:19100 -p 8099:8099 -p 12375:12375 -p 16080:16080 \
yaoapp/tai:1.2.0 server \
yaoapp/tai:latest server \
-grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -docker 0.0.0.0:12375
for i in $(seq 1 30); do
@ -853,7 +853,7 @@ jobs:
-v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
-e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \
yaoapp/tai:1.2.0 server \
yaoapp/tai:latest server \
-grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -k8s 0.0.0.0:16443
for i in $(seq 1 30); do

View file

@ -649,8 +649,8 @@ sandbox/v2/
├── grpc.go // token creation/revocation, gRPC env var injection
├── jsapi/ // (Phase 2) V8 JSAPI sandbox.* namespace
│ ├── jsapi.go // RegisterObject("sandbox"), Create/Get/List/Delete/Host
│ ├── box.go // Box JS object: Computer + Attach/Info/Start/Stop/Remove
│ └── host.go // Host JS object: Computer (unified with Box)
│ ├── computer.go // Unified Computer JS object (box + host), sbHost()
│ └── node.go // GetNode/Nodes/NodesByTeam JS bindings
├── export_test.go // ResetForTest() for test isolation
├── testutils_test.go // shared test helpers (multi-pool setup)
├── sandbox_test.go // Init/M singleton tests

View file

@ -5,27 +5,29 @@ All methods are available on the global `sandbox` object. No constructor needed.
## Quick Start
```javascript
// Create a sandbox container
const box = sandbox.Create({ image: "node:20", owner: "user-123" })
// Execute a command
const result = box.Exec(["node", "-e", "console.log('hello')"])
// Create a container computer
const pc = sandbox.Create({ image: "node:20", owner: "user-123" })
const result = pc.Exec(["node", "-e", "console.log('hello')"])
console.log(result.stdout) // "hello\n"
pc.Remove()
// Clean up
box.Remove()
// Or use the host directly (no container)
const host = sandbox.Host()
host.Exec(["ls", "-la", "/workspace"])
```
Both `sandbox.Create()` and `sandbox.Host()` return a **Computer** object with the same interface. The `kind` property tells you which type it is.
---
## Static Methods
### sandbox.Create(options) → Box
### sandbox.Create(options) → Computer
Create a new sandbox container. If `options.id` is set and a sandbox with that ID already exists, returns the existing one (GetOrCreate semantics).
Create a new sandbox container. Returns a Computer (`kind = "box"`). If `options.id` is set and a sandbox with that ID already exists, returns the existing one (GetOrCreate semantics).
```javascript
const box = sandbox.Create({
const pc = sandbox.Create({
image: "node:20", // required — container image
owner: "user-123", // required — owner identifier
pool: "gpu", // optional — pool name (default: first pool)
@ -34,7 +36,7 @@ const box = sandbox.Create({
user: "1000:1000", // optional — UID:GID
env: { NODE_ENV: "dev" },// optional — environment variables
memory: 536870912, // optional — memory limit in bytes (512MB)
cpus: 1.5, // optional — CPU limit
cpus: 1.5, // optional — CPU limit
vnc: true, // optional — enable VNC desktop
ports: [ // optional — port mappings
{ container_port: 3000, host_port: 3000, host_ip: "", protocol: "tcp" }
@ -49,14 +51,14 @@ const box = sandbox.Create({
})
```
### sandbox.Get(id) → Box | null
### sandbox.Get(id) → Computer | null
Get an existing sandbox by ID. Returns `null` if not found.
Get an existing sandbox by ID. Returns a Computer (`kind = "box"`) or `null` if not found.
```javascript
const box = sandbox.Get("my-sandbox")
if (box) {
console.log(box.id, box.owner, box.pool)
const pc = sandbox.Get("my-sandbox")
if (pc) {
console.log(pc.kind, pc.id, pc.owner, pc.pool)
}
```
@ -102,9 +104,9 @@ Remove a sandbox and its container.
sandbox.Delete("my-sandbox")
```
### sandbox.Host(pool?) → Host
### sandbox.Host(pool?) → Computer
Get a Host object 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 pool's Tai server has `host_exec` capability.
```javascript
const host = sandbox.Host() // default pool
@ -143,196 +145,35 @@ const nodes = sandbox.NodesByTeam("team-001")
---
## Box Object
## Computer Object
Returned by `sandbox.Create()` and `sandbox.Get()`. Holds a sandbox ID internally; all operations delegate to the backend.
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.
### Properties (read-only)
| Property | Type | Description |
|----------|------|-------------|
| `box.id` | string | Sandbox ID |
| `box.owner` | string | Owner identifier |
| `box.pool` | string | Pool name |
| `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 |
### box.Exec(cmd, options?) → ExecResult
### pc.Exec(cmd, options?) → ExecResult
Execute a command in the container and wait for it to finish.
Execute a command and wait for it to finish.
```javascript
const result = box.Exec(["ls", "-la", "/app"])
const result = pc.Exec(["ls", "-la", "/app"])
console.log(result.exit_code) // 0
console.log(result.stdout) // file listing
console.log(result.stderr) // empty string
```
Options:
```javascript
box.Exec(["npm", "test"], {
workdir: "/app",
env: { CI: "true" },
timeout: 60000 // ms
})
```
Return value:
```javascript
{
exit_code: 0, // process exit code
stdout: "...", // captured stdout (string)
stderr: "..." // captured stderr (string)
}
```
### box.Stream(cmd, callback) / box.Stream(cmd, options, callback)
Execute a command with streaming output via callback. The call blocks until the process exits.
Callback signature: `function(type, data)`
- `type = "stdout"``data` is a string chunk from stdout
- `type = "stderr"``data` is a string chunk from stderr
- `type = "exit"``data` is the exit code (number)
```javascript
// Basic
box.Stream(["npm", "run", "dev"], function(type, data) {
if (type === "stdout") console.log(data)
if (type === "stderr") console.log("[ERR]", data)
if (type === "exit") console.log("exited:", data)
})
// With options
box.Stream(["npm", "test"], {
workdir: "/app",
env: { CI: "true" },
timeout: 60000
}, function(type, data) {
console.log(type, data)
})
```
### box.Attach(port, options?) → string
Get a WebSocket or SSE endpoint URL for a service running inside the container. Use this for persistent connections (WS/SSE). For plain HTTP requests, use `box.Proxy()` instead.
```javascript
// Get WebSocket URL
const wsURL = box.Attach(3000, { protocol: "ws", path: "/ws" })
// "ws://host:8099/container-id:3000/ws"
// Get SSE URL
const sseURL = box.Attach(8080, { protocol: "sse", path: "/events" })
// "http://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
}
```
### box.VNC() → string
Get the VNC WebSocket URL for a VNC-enabled sandbox.
```javascript
const url = box.VNC()
// "ws://host:16080/vnc/sb-xxx"
```
### box.Proxy(port, path?) → string
Get an HTTP proxy URL for a port inside the container. Use this for plain HTTP requests. For WebSocket/SSE connections, use `box.Attach()` instead.
```javascript
const url = box.Proxy(3000)
// "http://host:8099/proxy/sb-xxx/3000/"
const url = box.Proxy(8080, "/api/v1")
// "http://host:8099/proxy/sb-xxx/8080/api/v1"
```
### box.Workspace() → WorkspaceFS
Access the workspace filesystem bound to this sandbox. The WorkspaceFS object is implemented in the `workspace/jsapi` package; this method returns it directly by calling `workspace.NewFSObject(v8ctx, box.WorkspaceID())`.
```javascript
const ws = box.Workspace()
const content = ws.ReadFile("src/main.go")
ws.WriteFile("src/main.go", "package main\n...")
ws.MkdirAll("src/utils")
const entries = ws.ReadDir("src/")
```
See [WorkspaceFS Object](#workspacefs-object) for the full method list.
### box.Info() → BoxInfo
Get current status information.
```javascript
const info = box.Info()
console.log(info.status, info.process_count, info.last_active)
```
Returns the same structure as elements in `sandbox.List()`.
### box.Start() → void
Start a stopped sandbox.
```javascript
box.Start()
```
### box.Stop() → void
Stop a running sandbox.
```javascript
box.Stop()
```
### box.Remove() → void
Remove the sandbox and its container.
```javascript
box.Remove()
```
---
## Host Object
Returned by `sandbox.Host()`. Executes commands directly on the Tai host machine without a container. Requires the pool to have `host_exec` capability.
### Properties (read-only)
| Property | Type | Description |
|----------|------|-------------|
| `host.pool` | string | Pool name |
### host.Exec(cmd, args, options?) → HostExecResult
Execute a command on the host and wait for it to finish.
```javascript
const result = host.Exec("ls", ["-la", "/workspace"])
console.log(result.exit_code) // 0
console.log(result.stdout) // file listing
console.log(result.duration_ms) // execution time
```
Options:
```javascript
host.Exec("python3", ["train.py"], {
pc.Exec(["python3", "train.py"], {
workdir: "/workspace/ml",
env: { CUDA_VISIBLE_DEVICES: "0" },
stdin: "input data",
@ -354,41 +195,189 @@ Return value:
}
```
### host.Stream(cmd, args, callback) / host.Stream(cmd, args, options, callback)
### pc.Stream(cmd, callback) / pc.Stream(cmd, options, callback)
Execute a command on the host with streaming output via callback. The call blocks until the process exits.
Execute a command with streaming output via callback. The call blocks until the process exits.
Callback signature: same as `box.Stream``function(type, data)`.
Callback signature: `function(type, data)`
- `type = "stdout"``data` is a string chunk from stdout
- `type = "stderr"``data` is a string chunk from stderr
- `type = "exit"``data` is the exit code (number)
```javascript
// Basic
host.Stream("tail", ["-f", "/var/log/app.log"], function(type, data) {
pc.Stream(["npm", "run", "dev"], function(type, data) {
if (type === "stdout") console.log(data)
if (type === "stderr") console.log("[ERR]", data)
if (type === "exit") console.log("exited:", data)
})
// With options
host.Stream("python3", ["train.py"], {
workdir: "/workspace/ml",
timeout: 3600000
pc.Stream(["npm", "test"], {
workdir: "/app",
env: { CI: "true" },
timeout: 60000
}, function(type, data) {
if (type === "stderr") console.log("[WARN]", data)
if (type === "exit") console.log("done, code:", data)
console.log(type, data)
})
```
### host.Workspace(sessionID) → WorkspaceFS
### pc.VNC() → string
Access a workspace on the host by session ID. Same as `box.Workspace()`, the WorkspaceFS object is implemented in the `workspace/jsapi` package; this method calls `workspace.NewFSObject(v8ctx, sessionID)`.
Get the VNC WebSocket URL.
- **Box**: routes to the container's VNC server (`:5900`)
- **Host**: routes to the Tai host via `__host__` identifier (configurable via `host_vnc_port`)
```javascript
const ws = host.Workspace("my-session")
const url = pc.VNC()
// Box: "ws://tai-host:16080/vnc/container-id/ws"
// Host: "ws://tai-host:16080/vnc/__host__/ws"
```
If no VNC server is running, the WebSocket connection will fail — handle this in the caller.
### pc.Proxy(port, path?) → string
Get an HTTP proxy URL for a service port.
- **Box**: routes to `container-ip:{port}`
- **Host**: routes to `127.0.0.1:{port}` on the Tai machine via `__host__`
```javascript
const url = pc.Proxy(3000)
// Box: "http://tai-host:8099/container-id:3000/"
// Host: "http://tai-host:8099/__host__:3000/"
const url = pc.Proxy(8080, "/api/v1")
// Box: "http://tai-host:8099/container-id:8080/api/v1"
// Host: "http://tai-host:8099/__host__:8080/api/v1"
```
### pc.ComputerInfo() → ComputerInfo
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.system.os) // "linux" | "windows" | "darwin"
console.log(info.status) // "running" | "stopped" | ...
```
Returns a [ComputerInfo](#computerinfo-object) object.
### pc.BindWorkplace(workspaceID) → void
Bind a workspace to this computer for the current session.
```javascript
pc.BindWorkplace("ws-project-abc")
```
### pc.Workplace() → WorkspaceFS | null
Access the workspace bound via `BindWorkplace()`. Returns `null` if no workspace is bound.
```javascript
pc.BindWorkplace("ws-project-abc")
const ws = pc.Workplace()
ws.ReadFile("config.yml")
ws.WriteFile("output.json", JSON.stringify(data))
ws.ReadDir("results/")
```
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.
```javascript
const info = pc.Info()
console.log(info.status, info.process_count, info.last_active)
```
Returns the same structure as elements in `sandbox.List()`.
### pc.Start() → void — box-only
Start a stopped container. Throws on host computers.
```javascript
pc.Start()
```
### pc.Stop() → void — box-only
Stop a running container. Throws on host computers.
```javascript
pc.Stop()
```
### pc.Remove() → void — box-only
Remove the container. Throws on host computers.
```javascript
pc.Remove()
```
---
## ComputerInfo Object
Returned by `pc.ComputerInfo()`. Read-only snapshot of a Computer's identity and state.
```javascript
{
kind: "box", // "box" | "host"
pool: "default",
tai_id: "tai-abc123",
machine_id: "m-xyz",
version: "1.2.3",
mode: "direct", // "direct" | "tunnel"
status: "running",
capabilities: { docker: true, k8s: false, host_exec: true },
system: {
os: "linux",
arch: "amd64",
hostname: "gpu-server-01",
num_cpu: 16,
total_mem: 68719476736
},
// Box-only fields (empty/zero for host)
box_id: "sb-xxx",
container_id: "abc123...",
owner: "user-123",
image: "node:20",
policy: "session",
labels: { team: "backend" }
}
```
---
## NodeInfo Object
@ -399,7 +388,7 @@ Returned by `sandbox.GetNode()`, `sandbox.Nodes()`, `sandbox.NodesByTeam()`. Rea
{
tai_id: "tai-abc123",
machine_id: "m-xyz",
version: "1.2.0",
version: "1.2.3",
mode: "direct", // "direct" | "tunnel"
addr: "192.168.1.100",
status: "online", // "online" | "offline" | "connecting"
@ -407,11 +396,12 @@ Returned by `sandbox.GetNode()`, `sandbox.Nodes()`, `sandbox.NodesByTeam()`. Rea
connected_at: "2026-03-07T08:00:00Z",
last_ping: "2026-03-07T10:05:00Z",
ports: {
grpc: 19100,
http: 8099,
vnc: 16080,
docker: 12375,
k8s: 16443
grpc: 19100,
http: 8099,
vnc: 16080,
docker: 12375,
k8s: 16443,
host_vnc: 5900 // VNC port on host for __host__ routing
},
capabilities: {
docker: true,
@ -423,7 +413,7 @@ Returned by `sandbox.GetNode()`, `sandbox.Nodes()`, `sandbox.NodesByTeam()`. Rea
arch: "amd64",
hostname: "gpu-server-01",
num_cpu: 16,
total_mem: 68719476736 // bytes (64GB)
total_mem: 68719476736 // bytes (64GB)
}
}
```
@ -432,7 +422,7 @@ Returned by `sandbox.GetNode()`, `sandbox.Nodes()`, `sandbox.NodesByTeam()`. Rea
## WorkspaceFS Object
Returned by `box.Workspace()`, `host.Workspace()`, `workspace.Get()`, and `workspace.Create()`.
Returned by `pc.Workplace()`, `workspace.Get()`, and `workspace.Create()`.
### Properties (read-only)
@ -472,44 +462,44 @@ Return types:
### Run a build and check output
```javascript
const box = sandbox.Create({
const pc = sandbox.Create({
image: "golang:1.23",
owner: "ci-bot",
workspace_id: "ws-project-abc"
})
const build = box.Exec(["go", "build", "./..."], {
const build = pc.Exec(["go", "build", "./..."], {
workdir: "/workspace",
timeout: 120000
})
if (build.exit_code !== 0) {
console.log("Build failed:", build.stderr)
box.Remove()
pc.Remove()
throw new Error("build failed")
}
const test = box.Exec(["go", "test", "./..."], {
const test = pc.Exec(["go", "test", "./..."], {
workdir: "/workspace",
env: { CGO_ENABLED: "0" }
})
console.log("Tests:", test.exit_code === 0 ? "PASS" : "FAIL")
box.Remove()
pc.Remove()
```
### Stream a long-running process
```javascript
const box = sandbox.Create({
const pc = sandbox.Create({
image: "node:20",
owner: "user-123",
policy: "session"
})
box.Exec(["npm", "install"], { workdir: "/app" })
pc.Exec(["npm", "install"], { workdir: "/app" })
box.Stream(["npm", "run", "dev"], { workdir: "/app" }, function(type, data) {
pc.Stream(["npm", "run", "dev"], { workdir: "/app" }, function(type, data) {
if (type === "stdout") console.log(data)
if (type === "stderr") console.log("[ERR]", data)
if (type === "exit") console.log("dev server exited:", data)
@ -521,16 +511,58 @@ box.Stream(["npm", "run", "dev"], { workdir: "/app" }, function(type, data) {
```javascript
const host = sandbox.Host("gpu")
const result = host.Exec("nvidia-smi", [])
const result = host.Exec(["nvidia-smi"])
console.log(result.stdout)
host.Exec("python3", ["train.py", "--epochs=10"], {
host.Exec(["python3", "train.py", "--epochs=10"], {
workdir: "/workspace/ml",
env: { CUDA_VISIBLE_DEVICES: "0,1" },
timeout: 3600000
})
```
### Uniform interface — same code for box and host
```javascript
function runTask(pc, cmd, opts) {
const result = pc.Exec(cmd, opts)
if (result.exit_code !== 0) {
throw new Error(pc.kind + " exec failed: " + result.stderr)
}
return result.stdout
}
// Works the same for both
const box = sandbox.Create({ image: "node:20", owner: "u1" })
const host = sandbox.Host("gpu")
runTask(box, ["node", "-e", "console.log('hi')"])
runTask(host, ["echo", "hello"])
```
### VNC and HTTP proxy
```javascript
const pc = sandbox.Create({
image: "kasmweb/chrome:latest",
owner: "user-123",
vnc: true
})
// Get VNC desktop URL
const vncURL = pc.VNC()
// "ws://tai-host:16080/vnc/container-id/ws"
// Get HTTP proxy to a web service inside the container
const appURL = pc.Proxy(3000)
// "http://tai-host:8099/container-id:3000/"
// Same methods work on host
const host = sandbox.Host()
const hostVNC = host.VNC()
// "ws://tai-host:16080/vnc/__host__/ws"
```
### Query cluster nodes
```javascript
@ -555,12 +587,14 @@ gpuNodes.forEach(function(n) {
### Workspace file operations
```javascript
const ws = workspace.Create({
name: "my-project",
owner: "user-123",
node: "default"
const pc = sandbox.Create({
image: "node:20",
owner: "user-123"
})
pc.BindWorkplace("ws-my-project")
const ws = pc.Workplace()
ws.MkdirAll("src/utils")
ws.WriteFile("src/main.go", 'package main\n\nfunc main() {\n\tprintln("hello")\n}\n')
ws.WriteFile("go.mod", "module myproject\n\ngo 1.23\n")
@ -580,9 +614,9 @@ console.log(content)
const auth = Authorized()
if (!auth) throw new Error("not authenticated")
const box = sandbox.Get(id)
if (!box) throw new Error("sandbox not found")
if (box.owner !== auth.user_id) throw new Error("permission denied")
const pc = sandbox.Get(id)
if (!pc) throw new Error("sandbox not found")
if (pc.owner !== auth.user_id) throw new Error("permission denied")
box.Exec(["ls", "-la"])
pc.Exec(["ls", "-la"])
```

View file

@ -1,134 +0,0 @@
package jsapi
import (
"rogchap.com/v8go"
)
// NewBoxObject creates a JS Box object backed by a sandbox ID string.
// All methods delegate to the Go sandbox.M() singleton — no Go object is
// passed to V8, no bridge registration, no Release() needed.
//
// Box implements the Computer interface, so it shares the unified Exec/Stream/
// VNC/Proxy/ComputerInfo/BindWorkplace/Workplace methods with Host. It also
// has Box-specific methods (Attach, Info, Start, Stop, Remove).
//
// # Properties (read-only)
//
// box.id → string // sandbox ID ← Box.ID()
// box.owner → string // owner user ID ← Box.Owner()
// box.pool → string // pool name ← Box.Pool()
//
// # Methods — Computer interface (unified with Host)
//
// box.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 (Host fills; Box = 0)
// error: string, ← ExecResult.Error (Host fills; Box = "")
// truncated: boolean ← ExecResult.Truncated (Host fills; Box = false)
// }
//
// box.Stream(cmd, callback) / box.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)
//
// box.VNC() → string
//
// Go: Computer.VNC(ctx) (string, error)
// Returns: VNC WebSocket URL
//
// box.Proxy(port, path?) → string
//
// Go: Computer.Proxy(ctx, port int, path string) (string, error)
// Returns: HTTP proxy URL
//
// box.ComputerInfo() → ComputerInfo
//
// Go: Computer.ComputerInfo() ComputerInfo
// JS returns: {
// kind: "box", pool, status,
// box_id, container_id, owner, image, policy, labels, ...
// }
//
// box.BindWorkplace(workspaceID) → void
//
// Go: Computer.BindWorkplace(workspaceID string)
//
// box.Workplace() → WorkspaceFS | null
//
// Go: Computer.Workplace() workspace.FS
//
// # Methods — Box-specific
//
// box.Attach(port, options?) → string
//
// Go: Proxy.URL(ctx, containerID, port, path) (string, error)
//
// Returns the service URL string.
// JS args:
// port: number → port int
// options: { → AttachOption
// protocol: "ws"|"sse", → affects URL scheme
// path: string, → URL path suffix
// }
// JS returns: string (URL)
//
// box.Workspace() → WorkspaceFS
//
// Implemented in workspace/jsapi package. Calls:
// workspace.NewFSObject(v8ctx, box.WorkspaceID())
//
// box.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
// }
//
// box.Start() → void
//
// Go: Box.Start(ctx) error
//
// box.Stop() → void
//
// Go: Box.Stop(ctx) error
//
// box.Remove() → void
//
// Go: Box.Remove(ctx) error
func NewBoxObject(v8ctx *v8go.Context, boxID string) (*v8go.Value, error) {
// TODO: Phase 2 implementation
// 1. Create JS object via v8go.NewObjectTemplate
// 2. Set read-only properties: id, owner, pool (from sandbox.M().Get(boxID))
// 3. Bind Computer interface methods:
// - Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, Workplace
// 4. Bind Box-specific methods:
// - Attach → client.Proxy().URL(ctx, containerID, port, path) → string
// - Workspace → NewFSObject(v8ctx, sandbox.M().Get(id).WorkspaceID())
// - Info → sandbox.M().Get(id).Info(ctx) → JS object
// - Start → sandbox.M().Get(id).Start(ctx)
// - Stop → sandbox.M().Get(id).Stop(ctx)
// - Remove → sandbox.M().Get(id).Remove(ctx)
return nil, nil
}

View file

@ -0,0 +1,138 @@
package jsapi
import (
"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())
}
// 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
}

View file

@ -1,102 +0,0 @@
package jsapi
import (
"rogchap.com/v8go"
)
// sbHost: `sandbox.Host(pool?)` → Computer (Host)
//
// Go: Manager.Host(ctx, pool) (*Host, error)
//
// Args:
//
// pool: string (optional) — pool name; empty = default pool
//
// Returns: Computer object (Host) if the pool has host_exec capability, otherwise throws.
//
// Host executes commands on the Tai host machine (no container). Available only
// when the pool's Tai server exposes HostExec gRPC.
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)
return v8go.Undefined(info.Context().Isolate())
}
// NewHostObject creates a JS Computer object backed by a Host.
// All methods delegate to the Go sandbox.M() singleton — no Go *Host passed to V8.
//
// Host implements the unified Computer interface, so the JS object exposes the
// same methods as a Box Computer object:
//
// # Properties (read-only)
//
// host.pool → string // pool name
//
// # Methods — Go mapping (unified Computer interface)
//
// host.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
// }
//
// host.Stream(cmd, callback) / host.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)
//
// host.VNC() → string
//
// Go: Computer.VNC(ctx) (string, error)
// Returns: VNC WebSocket URL (routes to Tai host via __host__ identifier)
//
// host.Proxy(port, path?) → string
//
// Go: Computer.Proxy(ctx, port int, path string) (string, error)
// Returns: HTTP proxy URL (routes to Tai host via __host__ identifier)
//
// host.ComputerInfo() → ComputerInfo
//
// Go: Computer.ComputerInfo() ComputerInfo
// JS returns: { kind: "host", pool: string, status: string, ... }
//
// host.BindWorkplace(workspaceID) → void
//
// Go: Computer.BindWorkplace(workspaceID string)
//
// host.Workplace() → WorkspaceFS | null
//
// Go: Computer.Workplace() workspace.FS
// Returns WorkspaceFS if a workplace is bound, null otherwise.
func NewHostObject(v8ctx *v8go.Context, pool string) (*v8go.Value, error) {
// TODO: Phase 2 implementation
// 1. Create JS object via v8go.NewObjectTemplate
// 2. Set read-only property: pool
// 3. Bind methods via unified Computer interface:
// - Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, Workplace
return nil, nil
}

View file

@ -1,32 +1,30 @@
// Package jsapi registers the sandbox namespace into the Yao V8 runtime.
//
// All methods are static on the sandbox object — no constructor.
// Both sandbox.Create() and sandbox.Host() return a unified Computer object.
//
// # JavaScript API
//
// const box = sandbox.Create({ image: "node:20", owner: "user1" })
// const result = box.Exec(["node", "-e", "console.log('hi')"])
// console.log(result.stdout)
//
// const box = sandbox.Get(id) // → Box
// const pc = sandbox.Create({ image: "node:20", owner: "user1" }) // → Computer (kind="box")
// const pc = sandbox.Get(id) // → Computer (kind="box") | null
// const list = sandbox.List({ owner: "u1" }) // → BoxInfo[]
// sandbox.Delete(id) // → void
// const host = sandbox.Host("gpu") // → Computer (Host via host_exec on Tai)
// const host = sandbox.Host("gpu") // → Computer (kind="host")
// const node = sandbox.GetNode("tai-abc123") // → NodeInfo | null
// const all = sandbox.Nodes() // → NodeInfo[]
// const team = sandbox.NodesByTeam("t-001") // → NodeInfo[]
//
// # Go mapping
//
// sandbox.Create(opts) → Manager.Create(ctx, CreateOptions) → Box
// sandbox.Create(opts) → Manager.GetOrCreate(ctx, opts) → Box (when opts.id is set)
// sandbox.Get(id) → Manager.Get(ctx, id) → Box
// sandbox.List(filter?) → Manager.List(ctx, ListOptions) → []*Box → BoxInfo[]
// sandbox.Delete(id) → Manager.Remove(ctx, id) → void
// sandbox.Host(pool?) → Manager.Host(ctx, pool) → Computer (Host)
// sandbox.GetNode(id) → registry.Global().Get(id) → NodeInfo | null
// sandbox.Nodes() → registry.Global().List() → NodeInfo[]
// sandbox.NodesByTeam(t)→ registry.Global().ListByTeam(t) → NodeInfo[]
// sandbox.Create(opts) → Manager.Create(ctx, CreateOptions) Computer (Box)
// sandbox.Create(opts) → Manager.GetOrCreate(ctx, opts) Computer (Box) (when opts.id is set)
// sandbox.Get(id) → Manager.Get(ctx, id) Computer (Box)
// sandbox.List(filter?) → Manager.List(ctx, ListOptions) → BoxInfo[]
// sandbox.Delete(id) → Manager.Remove(ctx, id) → void
// sandbox.Host(pool?) → Manager.Host(ctx, pool) → Computer (Host)
// sandbox.GetNode(id) → registry.Global().Get(id) → NodeInfo | null
// sandbox.Nodes() → registry.Global().List() → NodeInfo[]
// sandbox.NodesByTeam(t)→ registry.Global().ListByTeam(t) → NodeInfo[]
//
// Registration happens via init() — import with:
//
@ -85,14 +83,14 @@ func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
// labels: object → CreateOptions.Labels // map[string]string
// }
//
// Returns: Box object (see box.go)
// Returns: Computer object (kind="box") — see computer.go
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 NewBoxObject(v8ctx, box.ID())
// 4. Return NewComputerObject(v8ctx, "box", box.ID())
return v8go.Undefined(info.Context().Isolate())
}
@ -104,12 +102,12 @@ func sbCreate(info *v8go.FunctionCallbackInfo) *v8go.Value {
//
// id: string — sandbox ID
//
// Returns: Box object if found, null if not found
// Returns: Computer object (kind="box") if found, null if not found
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 NewBoxObject(v8ctx, id) or null
// 3. Return NewComputerObject(v8ctx, "box", id) or null
return v8go.Undefined(info.Context().Isolate())
}

View file

@ -27,7 +27,7 @@ type Computer interface {
// ComputerInfo holds identity and registry information for a Computer.
type ComputerInfo struct {
Kind string // "box" | "host"
Kind string // "box" | "host"
Pool string
TaiID string
MachineID string