From 43fd532357e856c88f03128a0dc13ab6dcfca956 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 8 Mar 2026 22:52:11 +0800 Subject: [PATCH] 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 --- .github/workflows/unit-test.yml | 6 +- sandbox/v2/DESIGN.md | 4 +- sandbox/v2/jsapi/API.md | 496 +++++++++++++++++--------------- sandbox/v2/jsapi/box.go | 134 --------- sandbox/v2/jsapi/computer.go | 138 +++++++++ sandbox/v2/jsapi/host.go | 102 ------- sandbox/v2/jsapi/jsapi.go | 36 ++- sandbox/v2/types.go | 2 +- 8 files changed, 426 insertions(+), 492 deletions(-) delete mode 100644 sandbox/v2/jsapi/box.go create mode 100644 sandbox/v2/jsapi/computer.go delete mode 100644 sandbox/v2/jsapi/host.go diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 661b0dad..c6765878 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -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 diff --git a/sandbox/v2/DESIGN.md b/sandbox/v2/DESIGN.md index 11f89e82..feef38d1 100644 --- a/sandbox/v2/DESIGN.md +++ b/sandbox/v2/DESIGN.md @@ -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 diff --git a/sandbox/v2/jsapi/API.md b/sandbox/v2/jsapi/API.md index 97d90561..f22dc48c 100644 --- a/sandbox/v2/jsapi/API.md +++ b/sandbox/v2/jsapi/API.md @@ -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"]) ``` diff --git a/sandbox/v2/jsapi/box.go b/sandbox/v2/jsapi/box.go deleted file mode 100644 index da224b8f..00000000 --- a/sandbox/v2/jsapi/box.go +++ /dev/null @@ -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 -} diff --git a/sandbox/v2/jsapi/computer.go b/sandbox/v2/jsapi/computer.go new file mode 100644 index 00000000..b0be6de9 --- /dev/null +++ b/sandbox/v2/jsapi/computer.go @@ -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 +} diff --git a/sandbox/v2/jsapi/host.go b/sandbox/v2/jsapi/host.go deleted file mode 100644 index c4396cc4..00000000 --- a/sandbox/v2/jsapi/host.go +++ /dev/null @@ -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 -} diff --git a/sandbox/v2/jsapi/jsapi.go b/sandbox/v2/jsapi/jsapi.go index 22cc8850..13e0b1e6 100644 --- a/sandbox/v2/jsapi/jsapi.go +++ b/sandbox/v2/jsapi/jsapi.go @@ -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()) } diff --git a/sandbox/v2/types.go b/sandbox/v2/types.go index 9860070f..67f9a46f 100644 --- a/sandbox/v2/types.go +++ b/sandbox/v2/types.go @@ -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