feat(sandbox/v2): unify Box and Host under Computer interface
- Define Computer interface with Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, and Workplace methods in types.go - Implement Computer interface on Host (VNC and Proxy via Tai gRPC) - Add VNC/Proxy stubs to Box (delegates to Tai HTTP endpoints) - Update JSAPI bindings for unified computer.vnc() and computer.proxy() - Extend host_test.go with __host__ VNC and HTTP proxy test cases Made-with: Cursor
This commit is contained in:
parent
18bcf22089
commit
4d4b03be96
8 changed files with 581 additions and 292 deletions
|
|
@ -41,14 +41,17 @@ Sandbox does NOT import or depend on Agent. Agent is one of many consumers.
|
||||||
│ ├── EnsureImage / ImageExists / PullImage │
|
│ ├── EnsureImage / ImageExists / PullImage │
|
||||||
│ └── guard rails (limits, TTL) + Box factory │
|
│ └── guard rails (limits, TTL) + Box factory │
|
||||||
│ │
|
│ │
|
||||||
│ Box (per-instance) │
|
│ Computer (unified interface) │
|
||||||
│ ├── Exec(cmd) → ExecResult │
|
│ ├── Exec(cmd) → ExecResult │
|
||||||
│ ├── Stream(cmd) → ExecStream (real-time I/O) │
|
│ ├── Stream(cmd) → ExecStream (real-time I/O) │
|
||||||
│ ├── Attach(port) → ServiceConn (WS/SSE) │
|
|
||||||
│ ├── Workspace() → workspace.FS │
|
|
||||||
│ ├── VNC() → url │
|
│ ├── VNC() → url │
|
||||||
│ ├── Proxy(port) → url │
|
│ ├── Proxy(port, path) → url │
|
||||||
│ └── Start / Stop / Remove / Info │
|
│ ├── ComputerInfo() → ComputerInfo │
|
||||||
|
│ ├── BindWorkplace(id) / Workplace() → FS │
|
||||||
|
│ └── [Box-specific: Attach/Start/Stop/Remove] │
|
||||||
|
│ │
|
||||||
|
│ Box (container) ── implements Computer │
|
||||||
|
│ Host (bare metal) ── implements Computer │
|
||||||
└──────────────────┬──────────────────────────────┘
|
└──────────────────┬──────────────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
|
|
@ -251,9 +254,70 @@ const (
|
||||||
const DefaultStopTimeout = 2 * time.Second
|
const DefaultStopTimeout = 2 * time.Second
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Computer Interface
|
||||||
|
|
||||||
|
`Computer` is the unified interface for execution environments. Both `Box` (container) and `Host` (bare metal) implement it, allowing callers to work with any execution environment without knowing the underlying runtime.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Computer interface {
|
||||||
|
ComputerInfo() ComputerInfo
|
||||||
|
Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error)
|
||||||
|
Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error)
|
||||||
|
VNC(ctx context.Context) (string, error)
|
||||||
|
Proxy(ctx context.Context, port int, path string) (string, error)
|
||||||
|
BindWorkplace(workspaceID string)
|
||||||
|
Workplace() workspace.FS
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ComputerInfo
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ComputerInfo struct {
|
||||||
|
Kind string // "box" | "host"
|
||||||
|
Pool string
|
||||||
|
TaiID string
|
||||||
|
MachineID string
|
||||||
|
Version string
|
||||||
|
System SystemInfo
|
||||||
|
Mode string // "direct" | "tunnel"
|
||||||
|
Capabilities map[string]bool
|
||||||
|
Status string
|
||||||
|
|
||||||
|
// Box-specific (zero values for Host)
|
||||||
|
BoxID string
|
||||||
|
ContainerID string
|
||||||
|
Owner string
|
||||||
|
Image string
|
||||||
|
Policy LifecyclePolicy
|
||||||
|
Labels map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemInfo struct {
|
||||||
|
OS string
|
||||||
|
Arch string
|
||||||
|
Hostname string
|
||||||
|
NumCPU int
|
||||||
|
TotalMem int64
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workplace Binding
|
||||||
|
|
||||||
|
Workspace is a Node-level resource, decoupled from the Computer. A Computer can bind to a workspace at session time:
|
||||||
|
|
||||||
|
- `BindWorkplace(workspaceID)` — binds a workspace to this Computer (virtual record, rebind to change)
|
||||||
|
- `Workplace()` — returns the bound workspace FS, or nil if unbound
|
||||||
|
- Box: automatically bound via `CreateOptions.WorkspaceID`, can rebind with `BindWorkplace()`
|
||||||
|
- Host: explicitly bound in the session
|
||||||
|
|
||||||
|
### VNC and Proxy on Host
|
||||||
|
|
||||||
|
Host VNC and Proxy use the special `__host__` identifier to route to the Tai server's localhost instead of a container. The Tai server's VNC router and HTTP proxy both handle `__host__` by connecting to `127.0.0.1:{port}` directly, bypassing the container resolver.
|
||||||
|
|
||||||
## Box
|
## Box
|
||||||
|
|
||||||
A `Box` is a single sandbox instance. All operations go through it.
|
A `Box` is a single sandbox instance backed by a container. It implements the `Computer` interface and adds container-specific methods (Attach, Start, Stop, Remove, Info).
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Box struct {
|
type Box struct {
|
||||||
|
|
@ -303,7 +367,9 @@ func (b *Box) Remove(ctx context.Context) error
|
||||||
func (b *Box) Info(ctx context.Context) (*BoxInfo, error)
|
func (b *Box) Info(ctx context.Context) (*BoxInfo, error)
|
||||||
```
|
```
|
||||||
|
|
||||||
### ExecOption / ExecResult / ExecStream
|
### ExecOption / ExecResult / ExecStream (unified)
|
||||||
|
|
||||||
|
These types are shared between Box and Host via the Computer interface.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type ExecOption func(*execConfig)
|
type ExecOption func(*execConfig)
|
||||||
|
|
@ -311,11 +377,16 @@ type ExecOption func(*execConfig)
|
||||||
func WithWorkDir(dir string) ExecOption
|
func WithWorkDir(dir string) ExecOption
|
||||||
func WithEnv(env map[string]string) ExecOption
|
func WithEnv(env map[string]string) ExecOption
|
||||||
func WithTimeout(d time.Duration) ExecOption
|
func WithTimeout(d time.Duration) ExecOption
|
||||||
|
func WithStdin(data []byte) ExecOption
|
||||||
|
func WithMaxOutput(bytes int64) ExecOption
|
||||||
|
|
||||||
type ExecResult struct {
|
type ExecResult struct {
|
||||||
ExitCode int
|
ExitCode int
|
||||||
Stdout string
|
Stdout string
|
||||||
Stderr string
|
Stderr string
|
||||||
|
DurationMs int64 // Host fills; Box = 0
|
||||||
|
Error string // Host fills; Box = ""
|
||||||
|
Truncated bool // Host fills; Box = false
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecStream struct {
|
type ExecStream struct {
|
||||||
|
|
@ -570,14 +641,16 @@ var (
|
||||||
sandbox/v2/
|
sandbox/v2/
|
||||||
├── sandbox.go // Init, M(), global singleton
|
├── sandbox.go // Init, M(), global singleton
|
||||||
├── manager.go // Manager: CRUD, pool management, image ops, cleanup
|
├── manager.go // Manager: CRUD, pool management, image ops, cleanup
|
||||||
├── box.go // Box: Exec, Stream, Attach, Workspace, VNC, Proxy, lifecycle
|
├── types.go // Computer interface, ComputerInfo, ExecResult, ExecStream, etc.
|
||||||
├── types.go // CreateOptions, ExecResult, ExecStream, ServiceConn, BoxInfo, etc.
|
├── box.go // Box: implements Computer + Attach/Start/Stop/Remove/Info
|
||||||
|
├── host.go // Host: implements Computer (HostExec gRPC + __host__ VNC/Proxy)
|
||||||
├── config.go // Config struct
|
├── config.go // Config struct
|
||||||
├── errors.go // sentinel errors
|
├── errors.go // sentinel errors
|
||||||
├── grpc.go // token creation/revocation, gRPC env var injection
|
├── grpc.go // token creation/revocation, gRPC env var injection
|
||||||
├── jsapi/ // (Phase 2) V8 JSAPI sandbox.* namespace
|
├── jsapi/ // (Phase 2) V8 JSAPI sandbox.* namespace
|
||||||
│ ├── jsapi.go // RegisterObject("sandbox"), Create/Get/List/Delete
|
│ ├── jsapi.go // RegisterObject("sandbox"), Create/Get/List/Delete/Host
|
||||||
│ └── box.go // Box JS object: Exec/Attach/VNC/Proxy/Workspace/Info/Start/Stop/Remove
|
│ ├── box.go // Box JS object: Computer + Attach/Info/Start/Stop/Remove
|
||||||
|
│ └── host.go // Host JS object: Computer (unified with Box)
|
||||||
├── export_test.go // ResetForTest() for test isolation
|
├── export_test.go // ResetForTest() for test isolation
|
||||||
├── testutils_test.go // shared test helpers (multi-pool setup)
|
├── testutils_test.go // shared test helpers (multi-pool setup)
|
||||||
├── sandbox_test.go // Init/M singleton tests
|
├── sandbox_test.go // Init/M singleton tests
|
||||||
|
|
@ -586,6 +659,7 @@ sandbox/v2/
|
||||||
├── box_test.go // Box Exec/Workspace/Info tests
|
├── box_test.go // Box Exec/Workspace/Info tests
|
||||||
├── box_attach_test.go // Attach WS/SSE/VNC tests
|
├── box_attach_test.go // Attach WS/SSE/VNC tests
|
||||||
├── box_workspace_test.go // Workspace integration tests
|
├── box_workspace_test.go // Workspace integration tests
|
||||||
|
├── host_test.go // Host Exec/Stream/VNC/Proxy/ComputerInfo tests
|
||||||
├── box_image_test.go // Image Pull API tests
|
├── box_image_test.go // Image Pull API tests
|
||||||
├── bench_test.go // Performance benchmarks
|
├── bench_test.go // Performance benchmarks
|
||||||
├── grpc_test.go // Token/env building tests
|
├── grpc_test.go // Token/env building tests
|
||||||
|
|
@ -851,7 +925,7 @@ Static methods:
|
||||||
| `sandbox.Get(id)` | `Manager.Get(ctx, id)` | `Box \| null` |
|
| `sandbox.Get(id)` | `Manager.Get(ctx, id)` | `Box \| null` |
|
||||||
| `sandbox.List(filter?)` | `Manager.List(ctx, ListOptions)` → `Box.Info()` | `BoxInfo[]` |
|
| `sandbox.List(filter?)` | `Manager.List(ctx, ListOptions)` → `Box.Info()` | `BoxInfo[]` |
|
||||||
| `sandbox.Delete(id)` | `Manager.Remove(ctx, id)` | `void` |
|
| `sandbox.Delete(id)` | `Manager.Remove(ctx, id)` | `void` |
|
||||||
| `sandbox.Host(pool?)` | `Manager.Host(ctx, pool)` | `Host` |
|
| `sandbox.Host(pool?)` | `Manager.Host(ctx, pool)` | `Computer (Host)` |
|
||||||
| `sandbox.GetNode(taiID)` | `registry.Global().Get(taiID)` | `NodeInfo \| null` |
|
| `sandbox.GetNode(taiID)` | `registry.Global().Get(taiID)` | `NodeInfo \| null` |
|
||||||
| `sandbox.Nodes()` | `registry.Global().List()` | `NodeInfo[]` |
|
| `sandbox.Nodes()` | `registry.Global().List()` | `NodeInfo[]` |
|
||||||
| `sandbox.NodesByTeam(teamID)` | `registry.Global().ListByTeam(teamID)` | `NodeInfo[]` |
|
| `sandbox.NodesByTeam(teamID)` | `registry.Global().ListByTeam(teamID)` | `NodeInfo[]` |
|
||||||
|
|
@ -922,13 +996,23 @@ Read-only properties:
|
||||||
|
|
||||||
Methods:
|
Methods:
|
||||||
|
|
||||||
|
Computer interface methods:
|
||||||
|
|
||||||
|
| JS | Go | Returns |
|
||||||
|
|----|-----|---------|
|
||||||
|
| `box.Exec(cmd, opts?)` | `Computer.Exec(ctx, cmd []string, ...ExecOption)` | `ExecResult` |
|
||||||
|
| `box.Stream(cmd, [opts,] cb)` | `Computer.Stream(ctx, cmd []string, ...ExecOption)` | callback(type, data) |
|
||||||
|
| `box.VNC()` | `Computer.VNC(ctx)` | `string` |
|
||||||
|
| `box.Proxy(port, path?)` | `Computer.Proxy(ctx, port, path)` | `string` |
|
||||||
|
| `box.ComputerInfo()` | `Computer.ComputerInfo()` | `ComputerInfo` |
|
||||||
|
| `box.BindWorkplace(id)` | `Computer.BindWorkplace(id)` | `void` |
|
||||||
|
| `box.Workplace()` | `Computer.Workplace()` | `WorkspaceFS \| null` |
|
||||||
|
|
||||||
|
Box-specific methods:
|
||||||
|
|
||||||
| JS | Go | Returns |
|
| JS | Go | Returns |
|
||||||
|----|-----|---------|
|
|----|-----|---------|
|
||||||
| `box.Exec(cmd, opts?)` | `Box.Exec(ctx, cmd, ...ExecOption)` | `ExecResult` |
|
|
||||||
| `box.Stream(cmd, [opts,] cb)` | `Box.Stream(ctx, cmd, ...ExecOption)` | callback(type, data) |
|
|
||||||
| `box.Attach(port, opts?)` | `Proxy.URL(ctx, containerID, port, path)` | `string` (URL) |
|
| `box.Attach(port, opts?)` | `Proxy.URL(ctx, containerID, port, path)` | `string` (URL) |
|
||||||
| `box.VNC()` | `Box.VNC(ctx)` | `string` |
|
|
||||||
| `box.Proxy(port, path?)` | `Box.Proxy(ctx, port, path)` | `string` |
|
|
||||||
| `box.Workspace()` | `Box.WorkspaceID()` → `NewFSObject` | `WorkspaceFS` |
|
| `box.Workspace()` | `Box.WorkspaceID()` → `NewFSObject` | `WorkspaceFS` |
|
||||||
| `box.Info()` | `Box.Info(ctx)` | `BoxInfo` |
|
| `box.Info()` | `Box.Info(ctx)` | `BoxInfo` |
|
||||||
| `box.Start()` | `Box.Start(ctx)` | `void` |
|
| `box.Start()` | `Box.Start(ctx)` | `void` |
|
||||||
|
|
@ -942,12 +1026,17 @@ cmd: string[] → cmd []string
|
||||||
options: {
|
options: {
|
||||||
workdir: string, → WithWorkDir(dir)
|
workdir: string, → WithWorkDir(dir)
|
||||||
env: object, → WithEnv(map[string]string)
|
env: object, → WithEnv(map[string]string)
|
||||||
timeout: number → WithTimeout(ms → time.Duration)
|
stdin: string, → WithStdin([]byte)
|
||||||
|
timeout: number, → WithTimeout(ms → time.Duration)
|
||||||
|
max_output: number → WithMaxOutput(bytes int64)
|
||||||
}
|
}
|
||||||
returns: {
|
returns: {
|
||||||
exit_code: number, ← ExecResult.ExitCode
|
exit_code: number, ← ExecResult.ExitCode
|
||||||
stdout: string, ← ExecResult.Stdout
|
stdout: string, ← ExecResult.Stdout
|
||||||
stderr: string ← ExecResult.Stderr
|
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)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -978,9 +1067,9 @@ Go-side `ServiceConn` (with Read/Write/Events/Close) is available for Go callers
|
||||||
|
|
||||||
`box.Info()` returns same structure as `BoxInfo[]` element above.
|
`box.Info()` returns same structure as `BoxInfo[]` element above.
|
||||||
|
|
||||||
#### Host object
|
#### Host object (Computer)
|
||||||
|
|
||||||
Host executes commands on the Tai host machine (no container). Available only when the pool's Tai server exposes HostExec gRPC. JS object holds pool name; all methods delegate to `sandbox.M().Host(ctx, pool)`.
|
Host implements the unified Computer interface for Tai host machines. It executes commands via HostExec gRPC and accesses VNC/Proxy via the `__host__` identifier. Available only when the pool's Tai server exposes HostExec gRPC. JS object holds pool name; all methods delegate to `sandbox.M().Host(ctx, pool)`.
|
||||||
|
|
||||||
Read-only properties:
|
Read-only properties:
|
||||||
|
|
||||||
|
|
@ -988,49 +1077,50 @@ Read-only properties:
|
||||||
|----|----|
|
|----|----|
|
||||||
| `host.pool` | `Host.Pool()` |
|
| `host.pool` | `Host.Pool()` |
|
||||||
|
|
||||||
Methods:
|
Methods (same Computer interface as Box):
|
||||||
|
|
||||||
| JS | Go | Returns |
|
| JS | Go | Returns |
|
||||||
|----|-----|---------|
|
|----|-----|---------|
|
||||||
| `host.Exec(cmd, args, opts?)` | `Host.Exec(ctx, cmd, args, ...HostExecOption)` | `HostExecResult` |
|
| `host.Exec(cmd, opts?)` | `Computer.Exec(ctx, cmd []string, ...ExecOption)` | `ExecResult` |
|
||||||
| `host.Stream(cmd, args, [opts,] cb)` | `Host.Stream(ctx, cmd, args, ...HostExecOption)` | callback(type, data) |
|
| `host.Stream(cmd, [opts,] cb)` | `Computer.Stream(ctx, cmd []string, ...ExecOption)` | callback(type, data) |
|
||||||
| `host.Workspace(sessionID)` | `Host.Workspace(sessionID)` | `WorkspaceFS` |
|
| `host.VNC()` | `Computer.VNC(ctx)` | `string` (URL) |
|
||||||
|
| `host.Proxy(port, path?)` | `Computer.Proxy(ctx, port, path)` | `string` (URL) |
|
||||||
|
| `host.ComputerInfo()` | `Computer.ComputerInfo()` | `ComputerInfo` |
|
||||||
|
| `host.BindWorkplace(id)` | `Computer.BindWorkplace(id)` | `void` |
|
||||||
|
| `host.Workplace()` | `Computer.Workplace()` | `WorkspaceFS \| null` |
|
||||||
|
|
||||||
`host.Exec(cmd, args, options?)`:
|
`host.Exec(cmd, options?)`:
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd: string → cmd string
|
cmd: string[] → cmd []string (unified with Box)
|
||||||
args: string[] → args []string
|
|
||||||
options: {
|
options: {
|
||||||
workdir: string, → WithHostWorkDir(dir)
|
workdir: string, → WithWorkDir(dir)
|
||||||
env: object, → WithHostEnv(map[string]string)
|
env: object, → WithEnv(map[string]string)
|
||||||
stdin: string, → WithHostStdin([]byte)
|
stdin: string, → WithStdin([]byte)
|
||||||
timeout: number, → WithHostTimeout(ms int64)
|
timeout: number, → WithTimeout(ms → time.Duration)
|
||||||
max_output: number → WithHostMaxOutput(bytes int64)
|
max_output: number → WithMaxOutput(bytes int64)
|
||||||
}
|
}
|
||||||
returns: {
|
returns: {
|
||||||
exit_code: number, ← HostExecResult.ExitCode
|
exit_code: number, ← ExecResult.ExitCode
|
||||||
stdout: string, ← HostExecResult.Stdout (UTF-8)
|
stdout: string, ← ExecResult.Stdout
|
||||||
stderr: string, ← HostExecResult.Stderr (UTF-8)
|
stderr: string, ← ExecResult.Stderr
|
||||||
duration_ms: number, ← HostExecResult.DurationMs
|
duration_ms: number, ← ExecResult.DurationMs
|
||||||
error: string, ← HostExecResult.Error
|
error: string, ← ExecResult.Error
|
||||||
truncated: boolean ← HostExecResult.Truncated
|
truncated: boolean ← ExecResult.Truncated
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`host.Stream(cmd, args, callback)` / `host.Stream(cmd, args, options, callback)`:
|
`host.Stream(cmd, callback)` / `host.Stream(cmd, options, callback)`:
|
||||||
|
|
||||||
```
|
```
|
||||||
Blocks until exit. Last arg must be a JS function.
|
Blocks until exit. Last arg must be a JS function.
|
||||||
options: same as host.Exec (optional)
|
options: same as host.Exec (optional)
|
||||||
callback: function(type, data)
|
callback: function(type, data)
|
||||||
type = "stdout" → data is string (chunk) ← HostExecStream.Stdout
|
type = "stdout" → data is string (chunk) ← ExecStream.Stdout (io.ReadCloser)
|
||||||
type = "stderr" → data is string (chunk) ← HostExecStream.Stderr
|
type = "stderr" → data is string (chunk) ← ExecStream.Stderr (io.ReadCloser)
|
||||||
type = "exit" → data is number (exit code) ← HostExecStream.Wait()
|
type = "exit" → data is number (exit code) ← ExecStream.Wait()
|
||||||
```
|
```
|
||||||
|
|
||||||
`host.Workspace(sessionID)` returns the same WorkspaceFS interface as `box.Workspace()`; sessionID typically corresponds to a workspace ID on the Tai host.
|
|
||||||
|
|
||||||
#### NodeInfo object
|
#### NodeInfo object
|
||||||
|
|
||||||
`sandbox.GetNode()`, `sandbox.Nodes()`, `sandbox.NodesByTeam()` return NodeInfo objects mapped from `registry.NodeSnapshot`. Auth and YaoBase fields are excluded for security.
|
`sandbox.GetNode()`, `sandbox.Nodes()`, `sandbox.NodesByTeam()` return NodeInfo objects mapped from `registry.NodeSnapshot`. Auth and YaoBase fields are excluded for security.
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,44 @@ type Box struct {
|
||||||
manager *Manager
|
manager *Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compile-time check: *Box implements Computer.
|
||||||
|
var _ Computer = (*Box)(nil)
|
||||||
|
|
||||||
func (b *Box) ID() string { return b.id }
|
func (b *Box) ID() string { return b.id }
|
||||||
func (b *Box) Owner() string { return b.owner }
|
func (b *Box) Owner() string { return b.owner }
|
||||||
func (b *Box) ContainerID() string { return b.containerID }
|
func (b *Box) ContainerID() string { return b.containerID }
|
||||||
func (b *Box) Pool() string { return b.pool }
|
func (b *Box) Pool() string { return b.pool }
|
||||||
|
|
||||||
|
// ComputerInfo returns identity and registry information for this Box.
|
||||||
|
func (b *Box) ComputerInfo() ComputerInfo {
|
||||||
|
return ComputerInfo{
|
||||||
|
Kind: "box",
|
||||||
|
Pool: b.pool,
|
||||||
|
Status: "online",
|
||||||
|
BoxID: b.id,
|
||||||
|
ContainerID: b.containerID,
|
||||||
|
Owner: b.owner,
|
||||||
|
Image: b.image,
|
||||||
|
Policy: b.policy,
|
||||||
|
Labels: b.labels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindWorkplace binds (or rebinds) a workspace to this Box. Subsequent calls
|
||||||
|
// to Workplace() return the FS for this workspace. Overrides the workspace
|
||||||
|
// set during Create.
|
||||||
|
func (b *Box) BindWorkplace(workspaceID string) {
|
||||||
|
b.workspaceID = workspaceID
|
||||||
|
b.ws = nil // clear cache so Workplace() re-resolves
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workplace returns the workspace FS bound to this Box.
|
||||||
|
// If a workspace was bound via CreateOptions.WorkspaceID or BindWorkplace(),
|
||||||
|
// returns that workspace's FS. Otherwise returns nil.
|
||||||
|
func (b *Box) Workplace() workspace.FS {
|
||||||
|
return b.Workspace()
|
||||||
|
}
|
||||||
|
|
||||||
// Exec runs a command and waits for it to finish.
|
// Exec runs a command and waits for it to finish.
|
||||||
func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) {
|
func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) {
|
||||||
b.touch()
|
b.touch()
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package sandbox
|
package sandbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
hepb "github.com/yaoapp/yao/tai/hostexec/pb"
|
hepb "github.com/yaoapp/yao/tai/hostexec/pb"
|
||||||
"github.com/yaoapp/yao/tai/workspace"
|
"github.com/yaoapp/yao/tai/workspace"
|
||||||
|
|
@ -12,18 +14,34 @@ import (
|
||||||
// Unlike Box (which wraps a container), Host executes commands directly on
|
// Unlike Box (which wraps a container), Host executes commands directly on
|
||||||
// the Tai server's OS via HostExec gRPC and accesses files via Volume gRPC.
|
// the Tai server's OS via HostExec gRPC and accesses files via Volume gRPC.
|
||||||
//
|
//
|
||||||
// A Host is bound to a pool and does not require Create — it is available as
|
// Host implements the Computer interface.
|
||||||
// long as the pool's Tai server reports host_exec capability.
|
|
||||||
type Host struct {
|
type Host struct {
|
||||||
pool string
|
pool string
|
||||||
|
workplaceID string
|
||||||
manager *Manager
|
manager *Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pool returns the pool name this Host belongs to.
|
// Compile-time check: *Host implements Computer.
|
||||||
func (h *Host) Pool() string { return h.pool }
|
var _ Computer = (*Host)(nil)
|
||||||
|
|
||||||
|
// ComputerInfo returns identity and registry information for the host.
|
||||||
|
// Registry-level details (TaiID, System, etc.) are populated when the pool
|
||||||
|
// is backed by a registered Tai node; otherwise only Kind and Pool are set.
|
||||||
|
func (h *Host) ComputerInfo() ComputerInfo {
|
||||||
|
return ComputerInfo{
|
||||||
|
Kind: "host",
|
||||||
|
Pool: h.pool,
|
||||||
|
Status: "online",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Exec runs a command on the Tai host machine via HostExec gRPC.
|
// Exec runs a command on the Tai host machine via HostExec gRPC.
|
||||||
func (h *Host) Exec(ctx context.Context, cmd string, args []string, opts ...HostExecOption) (*HostExecResult, error) {
|
// cmd[0] is the program, cmd[1:] are arguments.
|
||||||
|
func (h *Host) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) {
|
||||||
|
if len(cmd) == 0 {
|
||||||
|
return nil, fmt.Errorf("sandbox: empty command")
|
||||||
|
}
|
||||||
|
|
||||||
client, err := h.manager.getPool(h.pool)
|
client, err := h.manager.getPool(h.pool)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -34,32 +52,38 @@ func (h *Host) Exec(ctx context.Context, cmd string, args []string, opts ...Host
|
||||||
return nil, fmt.Errorf("sandbox: host_exec not available on pool %q", h.pool)
|
return nil, fmt.Errorf("sandbox: host_exec not available on pool %q", h.pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &hostExecConfig{}
|
cfg := &execConfig{}
|
||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
o(cfg)
|
o(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &hepb.ExecRequest{
|
req := &hepb.ExecRequest{
|
||||||
Command: cmd,
|
Command: cmd[0],
|
||||||
Args: args,
|
Args: cmd[1:],
|
||||||
WorkingDir: cfg.WorkDir,
|
|
||||||
Stdin: cfg.Stdin,
|
Stdin: cfg.Stdin,
|
||||||
TimeoutMs: cfg.TimeoutMs,
|
}
|
||||||
MaxOutputBytes: cfg.MaxOutputBytes,
|
if cfg.WorkDir != "" {
|
||||||
|
req.WorkingDir = cfg.WorkDir
|
||||||
}
|
}
|
||||||
if cfg.Env != nil {
|
if cfg.Env != nil {
|
||||||
req.Env = cfg.Env
|
req.Env = cfg.Env
|
||||||
}
|
}
|
||||||
|
if cfg.Timeout > 0 {
|
||||||
|
req.TimeoutMs = cfg.Timeout.Milliseconds()
|
||||||
|
}
|
||||||
|
if cfg.MaxOutputBytes > 0 {
|
||||||
|
req.MaxOutputBytes = cfg.MaxOutputBytes
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := he.Exec(ctx, req)
|
resp, err := he.Exec(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("hostexec rpc: %w", err)
|
return nil, fmt.Errorf("hostexec rpc: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &HostExecResult{
|
return &ExecResult{
|
||||||
ExitCode: int(resp.ExitCode),
|
ExitCode: int(resp.ExitCode),
|
||||||
Stdout: resp.Stdout,
|
Stdout: string(resp.Stdout),
|
||||||
Stderr: resp.Stderr,
|
Stderr: string(resp.Stderr),
|
||||||
DurationMs: resp.DurationMs,
|
DurationMs: resp.DurationMs,
|
||||||
Error: resp.Error,
|
Error: resp.Error,
|
||||||
Truncated: resp.Truncated,
|
Truncated: resp.Truncated,
|
||||||
|
|
@ -67,9 +91,13 @@ func (h *Host) Exec(ctx context.Context, cmd string, args []string, opts ...Host
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream runs a command on the Tai host and streams stdout/stderr in real time
|
// Stream runs a command on the Tai host and streams stdout/stderr in real time
|
||||||
// via HostExec gRPC ExecStream. Returns a HostExecStream with separate channels
|
// via HostExec gRPC ExecStream. Returns a unified ExecStream with io.ReadCloser
|
||||||
// for stdout and stderr, plus Wait (blocks until exit) and Cancel.
|
// for stdout/stderr.
|
||||||
func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...HostExecOption) (*HostExecStream, error) {
|
func (h *Host) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error) {
|
||||||
|
if len(cmd) == 0 {
|
||||||
|
return nil, fmt.Errorf("sandbox: empty command")
|
||||||
|
}
|
||||||
|
|
||||||
client, err := h.manager.getPool(h.pool)
|
client, err := h.manager.getPool(h.pool)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -80,22 +108,28 @@ func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...Ho
|
||||||
return nil, fmt.Errorf("sandbox: host_exec not available on pool %q", h.pool)
|
return nil, fmt.Errorf("sandbox: host_exec not available on pool %q", h.pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &hostExecConfig{}
|
cfg := &execConfig{}
|
||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
o(cfg)
|
o(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &hepb.ExecRequest{
|
req := &hepb.ExecRequest{
|
||||||
Command: cmd,
|
Command: cmd[0],
|
||||||
Args: args,
|
Args: cmd[1:],
|
||||||
WorkingDir: cfg.WorkDir,
|
|
||||||
Stdin: cfg.Stdin,
|
Stdin: cfg.Stdin,
|
||||||
TimeoutMs: cfg.TimeoutMs,
|
}
|
||||||
MaxOutputBytes: cfg.MaxOutputBytes,
|
if cfg.WorkDir != "" {
|
||||||
|
req.WorkingDir = cfg.WorkDir
|
||||||
}
|
}
|
||||||
if cfg.Env != nil {
|
if cfg.Env != nil {
|
||||||
req.Env = cfg.Env
|
req.Env = cfg.Env
|
||||||
}
|
}
|
||||||
|
if cfg.Timeout > 0 {
|
||||||
|
req.TimeoutMs = cfg.Timeout.Milliseconds()
|
||||||
|
}
|
||||||
|
if cfg.MaxOutputBytes > 0 {
|
||||||
|
req.MaxOutputBytes = cfg.MaxOutputBytes
|
||||||
|
}
|
||||||
|
|
||||||
streamCtx, cancel := context.WithCancel(ctx)
|
streamCtx, cancel := context.WithCancel(ctx)
|
||||||
rpcStream, err := he.ExecStream(streamCtx, req)
|
rpcStream, err := he.ExecStream(streamCtx, req)
|
||||||
|
|
@ -104,15 +138,15 @@ func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...Ho
|
||||||
return nil, fmt.Errorf("hostexec stream rpc: %w", err)
|
return nil, fmt.Errorf("hostexec stream rpc: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stdoutCh := make(chan []byte, 64)
|
stdoutR, stdoutW := io.Pipe()
|
||||||
stderrCh := make(chan []byte, 64)
|
stderrR, stderrW := io.Pipe()
|
||||||
doneCh := make(chan struct{})
|
doneCh := make(chan struct{})
|
||||||
var exitCode int
|
var exitCode int
|
||||||
var exitErr error
|
var exitErr error
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer close(stdoutCh)
|
defer stdoutW.Close()
|
||||||
defer close(stderrCh)
|
defer stderrW.Close()
|
||||||
defer close(doneCh)
|
defer close(doneCh)
|
||||||
for {
|
for {
|
||||||
msg, err := rpcStream.Recv()
|
msg, err := rpcStream.Recv()
|
||||||
|
|
@ -123,9 +157,9 @@ func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...Ho
|
||||||
if len(msg.Data) > 0 {
|
if len(msg.Data) > 0 {
|
||||||
switch msg.Stream {
|
switch msg.Stream {
|
||||||
case hepb.ExecOutput_STDOUT:
|
case hepb.ExecOutput_STDOUT:
|
||||||
stdoutCh <- msg.Data
|
stdoutW.Write(msg.Data)
|
||||||
case hepb.ExecOutput_STDERR:
|
case hepb.ExecOutput_STDERR:
|
||||||
stderrCh <- msg.Data
|
stderrW.Write(msg.Data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if msg.Done {
|
if msg.Done {
|
||||||
|
|
@ -138,9 +172,10 @@ func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...Ho
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return &HostExecStream{
|
return &ExecStream{
|
||||||
Stdout: stdoutCh,
|
Stdout: stdoutR,
|
||||||
Stderr: stderrCh,
|
Stderr: stderrR,
|
||||||
|
Stdin: nopWriteCloser{&bytes.Buffer{}},
|
||||||
Wait: func() (int, error) {
|
Wait: func() (int, error) {
|
||||||
<-doneCh
|
<-doneCh
|
||||||
return exitCode, exitErr
|
return exitCode, exitErr
|
||||||
|
|
@ -149,13 +184,48 @@ func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...Ho
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workspace returns a filesystem interface for the given session on the host.
|
// VNC returns the VNC WebSocket URL for the Tai host machine.
|
||||||
// The sessionID typically corresponds to a workspace ID; files are stored
|
// Uses the special __host__ identifier to route to localhost:5900 on the Tai server.
|
||||||
// under dataDir/{sessionID}/ on the Tai host, accessed via Volume gRPC.
|
func (h *Host) VNC(ctx context.Context) (string, error) {
|
||||||
func (h *Host) Workspace(sessionID string) workspace.FS {
|
client, err := h.manager.getPool(h.pool)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return client.VNC().URL(ctx, "__host__")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy returns the HTTP URL for a service running on the Tai host machine.
|
||||||
|
// Uses the special __host__ identifier to route to localhost:{port} on the Tai server.
|
||||||
|
func (h *Host) Proxy(ctx context.Context, port int, path string) (string, error) {
|
||||||
|
client, err := h.manager.getPool(h.pool)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return client.Proxy().URL(ctx, "__host__", port, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindWorkplace binds a workspace to this host by ID. Subsequent calls to
|
||||||
|
// Workplace() will return the FS for this workspace. Call again to rebind.
|
||||||
|
func (h *Host) BindWorkplace(workspaceID string) {
|
||||||
|
h.workplaceID = workspaceID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workplace returns the workspace FS bound to this host, or nil if unbound.
|
||||||
|
func (h *Host) Workplace() workspace.FS {
|
||||||
|
if h.workplaceID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
client, err := h.manager.getPool(h.pool)
|
client, err := h.manager.getPool(h.pool)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return client.Workspace(sessionID)
|
return client.Workspace(h.workplaceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pool returns the pool name this Host belongs to.
|
||||||
|
func (h *Host) Pool() string { return h.pool }
|
||||||
|
|
||||||
|
// nopWriteCloser wraps an io.Writer with a no-op Close.
|
||||||
|
type nopWriteCloser struct{ io.Writer }
|
||||||
|
|
||||||
|
func (nopWriteCloser) Close() error { return nil }
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package sandbox_test
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -39,8 +40,8 @@ func TestHost_Exec_Echo(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd, args := linuxCmd(tgt, "echo", "hello", "from", "host")
|
cmd := hostCmd(tgt, "echo", "hello", "from", "host")
|
||||||
result, err := host.Exec(ctx, cmd, args)
|
result, err := host.Exec(ctx, cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Exec: %v", err)
|
t.Fatalf("Exec: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +54,7 @@ func TestHost_Exec_Echo(t *testing.T) {
|
||||||
if result.ExitCode != 0 {
|
if result.ExitCode != 0 {
|
||||||
t.Errorf("exit_code = %d, want 0", result.ExitCode)
|
t.Errorf("exit_code = %d, want 0", result.ExitCode)
|
||||||
}
|
}
|
||||||
got := strings.TrimSpace(string(result.Stdout))
|
got := strings.TrimSpace(result.Stdout)
|
||||||
if !strings.Contains(got, "hello") {
|
if !strings.Contains(got, "hello") {
|
||||||
t.Errorf("stdout = %q, want contains 'hello'", got)
|
t.Errorf("stdout = %q, want contains 'hello'", got)
|
||||||
}
|
}
|
||||||
|
|
@ -76,17 +77,14 @@ func TestHost_Exec_Env(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var cmd string
|
var cmd []string
|
||||||
var args []string
|
|
||||||
if tgt.IsWinNative {
|
if tgt.IsWinNative {
|
||||||
cmd = "cmd.exe"
|
cmd = []string{"cmd.exe", "/c", "echo", "%MY_VAR%"}
|
||||||
args = []string{"/c", "echo", "%MY_VAR%"}
|
|
||||||
} else {
|
} else {
|
||||||
cmd = "sh"
|
cmd = []string{"sh", "-c", "echo $MY_VAR"}
|
||||||
args = []string{"-c", "echo $MY_VAR"}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := host.Exec(ctx, cmd, args, sandbox.WithHostEnv(map[string]string{"MY_VAR": "host_test_value"}))
|
result, err := host.Exec(ctx, cmd, sandbox.WithEnv(map[string]string{"MY_VAR": "host_test_value"}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Exec: %v", err)
|
t.Fatalf("Exec: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +94,7 @@ func TestHost_Exec_Env(t *testing.T) {
|
||||||
}
|
}
|
||||||
t.Fatalf("error: %s", result.Error)
|
t.Fatalf("error: %s", result.Error)
|
||||||
}
|
}
|
||||||
got := strings.TrimSpace(string(result.Stdout))
|
got := strings.TrimSpace(result.Stdout)
|
||||||
if !strings.Contains(got, "host_test_value") {
|
if !strings.Contains(got, "host_test_value") {
|
||||||
t.Errorf("stdout = %q, want contains 'host_test_value'", got)
|
t.Errorf("stdout = %q, want contains 'host_test_value'", got)
|
||||||
}
|
}
|
||||||
|
|
@ -104,7 +102,7 @@ func TestHost_Exec_Env(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHost_Workspace(t *testing.T) {
|
func TestHost_Workplace(t *testing.T) {
|
||||||
skipIfNoHostExec(t)
|
skipIfNoHostExec(t)
|
||||||
|
|
||||||
for _, tgt := range hostExecTargets() {
|
for _, tgt := range hostExecTargets() {
|
||||||
|
|
@ -117,12 +115,13 @@ func TestHost_Workspace(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID := fmt.Sprintf("host-test-%d", time.Now().UnixNano())
|
sessionID := fmt.Sprintf("host-test-%d", time.Now().UnixNano())
|
||||||
ws := host.Workspace(sessionID)
|
host.BindWorkplace(sessionID)
|
||||||
|
ws := host.Workplace()
|
||||||
if ws == nil {
|
if ws == nil {
|
||||||
t.Fatal("Workspace returned nil")
|
t.Fatal("Workplace returned nil after BindWorkplace")
|
||||||
}
|
}
|
||||||
|
|
||||||
content := []byte("hello from host workspace test")
|
content := []byte("hello from host workplace test")
|
||||||
if err := ws.WriteFile("test.txt", content, 0644); err != nil {
|
if err := ws.WriteFile("test.txt", content, 0644); err != nil {
|
||||||
t.Fatalf("WriteFile: %v", err)
|
t.Fatalf("WriteFile: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -175,15 +174,22 @@ func TestHost_Stream_Incremental(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
stream, err := host.Stream(ctx, "sh", []string{"-c",
|
stream, err := host.Stream(ctx, []string{"sh", "-c",
|
||||||
"for i in 1 2 3 4 5; do echo chunk$i; sleep 0.2; done"})
|
"for i in 1 2 3 4 5; do echo chunk$i; sleep 0.2; done"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Stream: %v", err)
|
t.Fatalf("Stream: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var chunks []string
|
var chunks []string
|
||||||
for chunk := range stream.Stdout {
|
buf := make([]byte, 4096)
|
||||||
chunks = append(chunks, string(chunk))
|
for {
|
||||||
|
n, err := stream.Stdout.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
chunks = append(chunks, string(buf[:n]))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exitCode, err := stream.Wait()
|
exitCode, err := stream.Wait()
|
||||||
|
|
@ -230,15 +236,12 @@ func TestHost_Stream_MultiLine(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
stream, err := host.Stream(ctx, "sh", []string{"-c", "for i in 1 2 3; do echo line$i; done"})
|
stream, err := host.Stream(ctx, []string{"sh", "-c", "for i in 1 2 3; do echo line$i; done"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Stream: %v", err)
|
t.Fatalf("Stream: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var stdout []byte
|
stdout, _ := io.ReadAll(stream.Stdout)
|
||||||
for chunk := range stream.Stdout {
|
|
||||||
stdout = append(stdout, chunk...)
|
|
||||||
}
|
|
||||||
|
|
||||||
exitCode, err := stream.Wait()
|
exitCode, err := stream.Wait()
|
||||||
if err != nil && !strings.Contains(err.Error(), "EOF") {
|
if err != nil && !strings.Contains(err.Error(), "EOF") {
|
||||||
|
|
@ -278,22 +281,17 @@ func TestHost_Stream_Stderr(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
stream, err := host.Stream(ctx, "sh", []string{"-c", "echo err-msg >&2"})
|
stream, err := host.Stream(ctx, []string{"sh", "-c", "echo err-msg >&2"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Stream: %v", err)
|
t.Fatalf("Stream: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var stderr []byte
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
for chunk := range stream.Stdout {
|
io.ReadAll(stream.Stdout)
|
||||||
_ = chunk
|
|
||||||
}
|
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
for chunk := range stream.Stderr {
|
stderr, _ := io.ReadAll(stream.Stderr)
|
||||||
stderr = append(stderr, chunk...)
|
|
||||||
}
|
|
||||||
<-done
|
<-done
|
||||||
|
|
||||||
exitCode, err := stream.Wait()
|
exitCode, err := stream.Wait()
|
||||||
|
|
@ -332,7 +330,7 @@ func TestHost_Stream_Cancel(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
stream, err := host.Stream(ctx, "sh", []string{"-c", "while true; do echo tick; sleep 0.1; done"})
|
stream, err := host.Stream(ctx, []string{"sh", "-c", "while true; do echo tick; sleep 0.1; done"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not in the allowed list") {
|
if strings.Contains(err.Error(), "not in the allowed list") {
|
||||||
t.Skipf("command not allowed on %s", tgt.Name)
|
t.Skipf("command not allowed on %s", tgt.Name)
|
||||||
|
|
@ -341,13 +339,19 @@ func TestHost_Stream_Cancel(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
received := 0
|
received := 0
|
||||||
for chunk := range stream.Stdout {
|
buf := make([]byte, 4096)
|
||||||
_ = chunk
|
for {
|
||||||
|
n, err := stream.Stdout.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
received++
|
received++
|
||||||
|
}
|
||||||
if received >= 3 {
|
if received >= 3 {
|
||||||
stream.Cancel()
|
stream.Cancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, waitErr := stream.Wait()
|
_, waitErr := stream.Wait()
|
||||||
|
|
@ -361,8 +365,52 @@ func TestHost_Stream_Cancel(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHost_ComputerInfo(t *testing.T) {
|
||||||
|
skipIfNoHostExec(t)
|
||||||
|
|
||||||
|
for _, tgt := range hostExecTargets() {
|
||||||
|
t.Run(tgt.Name, func(t *testing.T) {
|
||||||
|
m := setupHostManager(t, tgt)
|
||||||
|
|
||||||
|
host, err := m.Host(context.Background(), tgt.Name)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Host(%s): %v", tgt.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := host.ComputerInfo()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHost_ComputerInterface(t *testing.T) {
|
||||||
|
skipIfNoHostExec(t)
|
||||||
|
|
||||||
|
for _, tgt := range hostExecTargets() {
|
||||||
|
t.Run(tgt.Name, func(t *testing.T) {
|
||||||
|
m := setupHostManager(t, tgt)
|
||||||
|
|
||||||
|
host, err := m.Host(context.Background(), tgt.Name)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Host(%s): %v", tgt.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Host satisfies Computer interface at runtime.
|
||||||
|
var c sandbox.Computer = host
|
||||||
|
info := c.ComputerInfo()
|
||||||
|
if info.Kind != "host" {
|
||||||
|
t.Errorf("Computer.ComputerInfo().Kind = %q, want 'host'", info.Kind)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHost_CreateRejectsNoContainerPool(t *testing.T) {
|
func TestHost_CreateRejectsNoContainerPool(t *testing.T) {
|
||||||
// Use the Windows native HostExec target which has no Docker.
|
|
||||||
tgt := findHostExecOnly(t)
|
tgt := findHostExecOnly(t)
|
||||||
if tgt == nil {
|
if tgt == nil {
|
||||||
t.Skip("no host-exec-only target available")
|
t.Skip("no host-exec-only target available")
|
||||||
|
|
@ -397,13 +445,10 @@ func TestHost_PoolNotFound(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// findHostExecOnly returns a hostExecTarget that is likely host-exec-only
|
|
||||||
// (Windows native Tai without Docker).
|
|
||||||
func findHostExecOnly(t *testing.T) *hostExecTarget {
|
func findHostExecOnly(t *testing.T) *hostExecTarget {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, tgt := range hostExecTargets() {
|
for _, tgt := range hostExecTargets() {
|
||||||
if tgt.IsWinNative {
|
if tgt.IsWinNative {
|
||||||
// Windows native Tai typically has no Docker
|
|
||||||
addr := fmt.Sprintf("tai://%s", tgt.Addr)
|
addr := fmt.Sprintf("tai://%s", tgt.Addr)
|
||||||
client, err := tai.New(addr)
|
client, err := tai.New(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -418,3 +463,12 @@ func findHostExecOnly(t *testing.T) *hostExecTarget {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hostCmd builds a []string command, adapting for Windows targets.
|
||||||
|
func hostCmd(tgt hostExecTarget, prog string, args ...string) []string {
|
||||||
|
if tgt.IsWinNative {
|
||||||
|
cmd, wArgs := linuxCmd(tgt, prog, args...)
|
||||||
|
return append([]string{cmd}, wArgs...)
|
||||||
|
}
|
||||||
|
return append([]string{prog}, args...)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,34 +8,43 @@ import (
|
||||||
// All methods delegate to the Go sandbox.M() singleton — no Go object is
|
// All methods delegate to the Go sandbox.M() singleton — no Go object is
|
||||||
// passed to V8, no bridge registration, no Release() needed.
|
// 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)
|
// # Properties (read-only)
|
||||||
//
|
//
|
||||||
// box.id → string // sandbox ID ← Box.ID()
|
// box.id → string // sandbox ID ← Box.ID()
|
||||||
// box.owner → string // owner user ID ← Box.Owner()
|
// box.owner → string // owner user ID ← Box.Owner()
|
||||||
// box.pool → string // pool name ← Box.Pool()
|
// box.pool → string // pool name ← Box.Pool()
|
||||||
//
|
//
|
||||||
// # Methods — Go mapping
|
// # Methods — Computer interface (unified with Host)
|
||||||
//
|
//
|
||||||
// box.Exec(cmd, options?) → ExecResult
|
// box.Exec(cmd, options?) → ExecResult
|
||||||
//
|
//
|
||||||
// Go: Box.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error)
|
// Go: Computer.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error)
|
||||||
//
|
//
|
||||||
// JS args:
|
// JS args:
|
||||||
// cmd: string[] → cmd []string
|
// cmd: string[] → cmd []string
|
||||||
// options: { → ExecOption functional options
|
// options: { → ExecOption
|
||||||
// workdir: string, → WithWorkDir(dir)
|
// workdir: string, → WithWorkDir(dir)
|
||||||
// env: object, → WithEnv(map[string]string)
|
// env: object, → WithEnv(map[string]string)
|
||||||
// timeout: number → WithTimeout(ms → time.Duration)
|
// stdin: string, → WithStdin([]byte)
|
||||||
|
// timeout: number, → WithTimeout(ms → time.Duration)
|
||||||
|
// max_output: number → WithMaxOutput(bytes int64)
|
||||||
// }
|
// }
|
||||||
// JS returns: {
|
// JS returns: {
|
||||||
// exit_code: number, ← ExecResult.ExitCode
|
// exit_code: number, ← ExecResult.ExitCode
|
||||||
// stdout: string, ← ExecResult.Stdout
|
// stdout: string, ← ExecResult.Stdout
|
||||||
// stderr: string ← ExecResult.Stderr
|
// 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)
|
// box.Stream(cmd, callback) / box.Stream(cmd, options, callback)
|
||||||
//
|
//
|
||||||
// Go: Box.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error)
|
// Go: Computer.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error)
|
||||||
//
|
//
|
||||||
// Blocks until the process exits. The last argument must be a JS function.
|
// Blocks until the process exits. The last argument must be a JS function.
|
||||||
// Callback signature: function(type, data)
|
// Callback signature: function(type, data)
|
||||||
|
|
@ -43,16 +52,39 @@ import (
|
||||||
// type = "stderr" → data is string (chunk)
|
// type = "stderr" → data is string (chunk)
|
||||||
// type = "exit" → data is number (exit code)
|
// type = "exit" → data is number (exit code)
|
||||||
//
|
//
|
||||||
// JS args:
|
// box.VNC() → string
|
||||||
// cmd: string[]
|
//
|
||||||
// options: { workdir, env, timeout } (optional, same as Exec)
|
// Go: Computer.VNC(ctx) (string, error)
|
||||||
// callback: function(type, data)
|
// 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
|
// box.Attach(port, options?) → string
|
||||||
//
|
//
|
||||||
// Go: Proxy.URL(ctx, containerID, port, path) (string, error)
|
// Go: Proxy.URL(ctx, containerID, port, path) (string, error)
|
||||||
//
|
//
|
||||||
// Returns the service URL string. Caller (frontend/Agent) establishes WS/SSE.
|
// Returns the service URL string.
|
||||||
// JS args:
|
// JS args:
|
||||||
// port: number → port int
|
// port: number → port int
|
||||||
// options: { → AttachOption
|
// options: { → AttachOption
|
||||||
|
|
@ -61,38 +93,17 @@ import (
|
||||||
// }
|
// }
|
||||||
// JS returns: string (URL)
|
// JS returns: string (URL)
|
||||||
//
|
//
|
||||||
// box.VNC() → string
|
|
||||||
//
|
|
||||||
// Go: Box.VNC(ctx) (string, error)
|
|
||||||
// Returns: VNC WebSocket URL
|
|
||||||
//
|
|
||||||
// box.Proxy(port, path?) → string
|
|
||||||
//
|
|
||||||
// Go: Box.Proxy(ctx, port int, path string) (string, error)
|
|
||||||
// Returns: HTTP proxy URL
|
|
||||||
//
|
|
||||||
// box.Workspace() → WorkspaceFS
|
// box.Workspace() → WorkspaceFS
|
||||||
//
|
//
|
||||||
// Implemented in workspace/jsapi package. This method calls:
|
// Implemented in workspace/jsapi package. Calls:
|
||||||
// workspace.NewFSObject(v8ctx, box.WorkspaceID())
|
// workspace.NewFSObject(v8ctx, box.WorkspaceID())
|
||||||
// and returns the resulting WorkspaceFS object directly.
|
|
||||||
//
|
//
|
||||||
// box.Info() → BoxInfo
|
// box.Info() → BoxInfo
|
||||||
//
|
//
|
||||||
// Go: Box.Info(ctx) (*BoxInfo, error)
|
// Go: Box.Info(ctx) (*BoxInfo, error)
|
||||||
// JS returns: {
|
// JS returns: {
|
||||||
// id: string, ← BoxInfo.ID
|
// id, container_id, pool, owner, status, image, vnc, policy,
|
||||||
// container_id: string, ← BoxInfo.ContainerID
|
// labels, created_at, last_active, process_count
|
||||||
// pool: string, ← BoxInfo.Pool
|
|
||||||
// owner: string, ← BoxInfo.Owner
|
|
||||||
// status: string, ← BoxInfo.Status
|
|
||||||
// image: string, ← BoxInfo.Image
|
|
||||||
// vnc: boolean, ← BoxInfo.VNC
|
|
||||||
// policy: string, ← BoxInfo.Policy (LifecyclePolicy)
|
|
||||||
// labels: object, ← BoxInfo.Labels (map[string]string)
|
|
||||||
// created_at: string, ← BoxInfo.CreatedAt (ISO 8601)
|
|
||||||
// last_active: string, ← BoxInfo.LastActive (ISO 8601)
|
|
||||||
// process_count: number ← BoxInfo.ProcessCount
|
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// box.Start() → void
|
// box.Start() → void
|
||||||
|
|
@ -110,12 +121,10 @@ func NewBoxObject(v8ctx *v8go.Context, boxID string) (*v8go.Value, error) {
|
||||||
// TODO: Phase 2 implementation
|
// TODO: Phase 2 implementation
|
||||||
// 1. Create JS object via v8go.NewObjectTemplate
|
// 1. Create JS object via v8go.NewObjectTemplate
|
||||||
// 2. Set read-only properties: id, owner, pool (from sandbox.M().Get(boxID))
|
// 2. Set read-only properties: id, owner, pool (from sandbox.M().Get(boxID))
|
||||||
// 3. Bind each method as FunctionTemplate:
|
// 3. Bind Computer interface methods:
|
||||||
// - Exec → sandbox.M().Get(id).Exec(ctx, cmd, opts...)
|
// - Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, Workplace
|
||||||
// - Stream → sandbox.M().Get(id).Stream(ctx, cmd, opts...)
|
// 4. Bind Box-specific methods:
|
||||||
// - Attach → client.Proxy().URL(ctx, containerID, port, path) → string
|
// - Attach → client.Proxy().URL(ctx, containerID, port, path) → string
|
||||||
// - VNC → sandbox.M().Get(id).VNC(ctx)
|
|
||||||
// - Proxy → sandbox.M().Get(id).Proxy(ctx, port, path)
|
|
||||||
// - Workspace → NewFSObject(v8ctx, sandbox.M().Get(id).WorkspaceID())
|
// - Workspace → NewFSObject(v8ctx, sandbox.M().Get(id).WorkspaceID())
|
||||||
// - Info → sandbox.M().Get(id).Info(ctx) → JS object
|
// - Info → sandbox.M().Get(id).Info(ctx) → JS object
|
||||||
// - Start → sandbox.M().Get(id).Start(ctx)
|
// - Start → sandbox.M().Get(id).Start(ctx)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"rogchap.com/v8go"
|
"rogchap.com/v8go"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sbHost: `sandbox.Host(pool?)` → Host
|
// sbHost: `sandbox.Host(pool?)` → Computer (Host)
|
||||||
//
|
//
|
||||||
// Go: Manager.Host(ctx, pool) (*Host, error)
|
// Go: Manager.Host(ctx, pool) (*Host, error)
|
||||||
//
|
//
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
//
|
//
|
||||||
// pool: string (optional) — pool name; empty = default pool
|
// pool: string (optional) — pool name; empty = default pool
|
||||||
//
|
//
|
||||||
// Returns: Host object if the pool has host_exec capability, otherwise throws.
|
// 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
|
// Host executes commands on the Tai host machine (no container). Available only
|
||||||
// when the pool's Tai server exposes HostExec gRPC.
|
// when the pool's Tai server exposes HostExec gRPC.
|
||||||
|
|
@ -21,45 +21,47 @@ func sbHost(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
// 1. pool := ""; if len(info.Args()) > 0 && info.Args()[0].IsString() { pool = info.Args()[0].String() }
|
// 1. pool := ""; if len(info.Args()) > 0 && info.Args()[0].IsString() { pool = info.Args()[0].String() }
|
||||||
// 2. host, err := sandbox.M().Host(ctx, pool)
|
// 2. host, err := sandbox.M().Host(ctx, pool)
|
||||||
// 3. if err != nil { throw in V8 }
|
// 3. if err != nil { throw in V8 }
|
||||||
// 4. Return NewHostObject(v8ctx, host.Pool())
|
// 4. Return NewComputerObject(v8ctx, host)
|
||||||
return v8go.Undefined(info.Context().Isolate())
|
return v8go.Undefined(info.Context().Isolate())
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHostObject creates a JS Host object backed by a pool name string.
|
// 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.
|
// 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)
|
// # Properties (read-only)
|
||||||
//
|
//
|
||||||
// host.pool → string // pool name ← Host.Pool()
|
// host.pool → string // pool name
|
||||||
//
|
//
|
||||||
// # Methods — Go mapping
|
// # Methods — Go mapping (unified Computer interface)
|
||||||
//
|
//
|
||||||
// host.Exec(cmd, args, options?) → HostExecResult
|
// host.Exec(cmd, options?) → ExecResult
|
||||||
//
|
//
|
||||||
// Go: Host.Exec(ctx, cmd string, args []string, opts ...HostExecOption) (*HostExecResult, error)
|
// Go: Computer.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error)
|
||||||
//
|
//
|
||||||
// JS args:
|
// JS args:
|
||||||
// cmd: string → cmd string
|
// cmd: string[] → cmd []string
|
||||||
// args: string[] → args []string
|
// options: { → ExecOption
|
||||||
// options: { → HostExecOption
|
// workdir: string, → WithWorkDir(dir)
|
||||||
// workdir: string, → WithHostWorkDir(dir)
|
// env: object, → WithEnv(map[string]string)
|
||||||
// env: object, → WithHostEnv(map[string]string)
|
// stdin: string, → WithStdin([]byte)
|
||||||
// stdin: string, → WithHostStdin([]byte)
|
// timeout: number, → WithTimeout(ms → time.Duration)
|
||||||
// timeout: number, → WithHostTimeout(ms int64)
|
// max_output: number → WithMaxOutput(bytes int64)
|
||||||
// max_output: number → WithHostMaxOutput(bytes int64)
|
|
||||||
// }
|
// }
|
||||||
// JS returns: {
|
// JS returns: {
|
||||||
// exit_code: number, ← HostExecResult.ExitCode
|
// exit_code: number, ← ExecResult.ExitCode
|
||||||
// stdout: string (UTF-8), ← HostExecResult.Stdout
|
// stdout: string, ← ExecResult.Stdout
|
||||||
// stderr: string (UTF-8), ← HostExecResult.Stderr
|
// stderr: string, ← ExecResult.Stderr
|
||||||
// duration_ms: number, ← HostExecResult.DurationMs
|
// duration_ms: number, ← ExecResult.DurationMs
|
||||||
// error: string, ← HostExecResult.Error
|
// error: string, ← ExecResult.Error
|
||||||
// truncated: boolean ← HostExecResult.Truncated
|
// truncated: boolean ← ExecResult.Truncated
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// host.Stream(cmd, args, callback) / host.Stream(cmd, args, options, callback)
|
// host.Stream(cmd, callback) / host.Stream(cmd, options, callback)
|
||||||
//
|
//
|
||||||
// Go: Host.Stream(ctx, cmd string, args []string, opts ...HostExecOption) (*HostExecStream, error)
|
// Go: Computer.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error)
|
||||||
//
|
//
|
||||||
// Blocks until the process exits. The last argument must be a JS function.
|
// Blocks until the process exits. The last argument must be a JS function.
|
||||||
// Callback signature: function(type, data)
|
// Callback signature: function(type, data)
|
||||||
|
|
@ -67,21 +69,34 @@ func sbHost(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
// type = "stderr" → data is string (chunk)
|
// type = "stderr" → data is string (chunk)
|
||||||
// type = "exit" → data is number (exit code)
|
// type = "exit" → data is number (exit code)
|
||||||
//
|
//
|
||||||
// JS args:
|
// host.VNC() → string
|
||||||
// cmd: string
|
|
||||||
// args: string[]
|
|
||||||
// options: { workdir, env, stdin, timeout, max_output } (optional, same as host.Exec)
|
|
||||||
// callback: function(type, data)
|
|
||||||
//
|
//
|
||||||
// host.Workspace(sessionID) → WorkspaceFS
|
// Go: Computer.VNC(ctx) (string, error)
|
||||||
|
// Returns: VNC WebSocket URL (routes to Tai host via __host__ identifier)
|
||||||
//
|
//
|
||||||
// Implemented in workspace/jsapi package. This method calls:
|
// host.Proxy(port, path?) → string
|
||||||
// workspace.NewFSObject(v8ctx, sessionID)
|
//
|
||||||
// and returns the resulting WorkspaceFS object directly.
|
// 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) {
|
func NewHostObject(v8ctx *v8go.Context, pool string) (*v8go.Value, error) {
|
||||||
// TODO: Phase 2 implementation
|
// TODO: Phase 2 implementation
|
||||||
// 1. Create JS object via v8go.NewObjectTemplate
|
// 1. Create JS object via v8go.NewObjectTemplate
|
||||||
// 2. Set read-only property: pool
|
// 2. Set read-only property: pool
|
||||||
// 3. Bind methods: Exec, Stream, Workspace (each resolves Host via sandbox.M().Host(ctx, pool))
|
// 3. Bind methods via unified Computer interface:
|
||||||
|
// - Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, Workplace
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
// const box = sandbox.Get(id) // → Box
|
// const box = sandbox.Get(id) // → Box
|
||||||
// const list = sandbox.List({ owner: "u1" }) // → BoxInfo[]
|
// const list = sandbox.List({ owner: "u1" }) // → BoxInfo[]
|
||||||
// sandbox.Delete(id) // → void
|
// sandbox.Delete(id) // → void
|
||||||
// const host = sandbox.Host("gpu") // → Host (host_exec on Tai)
|
// const host = sandbox.Host("gpu") // → Computer (Host via host_exec on Tai)
|
||||||
// const node = sandbox.GetNode("tai-abc123") // → NodeInfo | null
|
// const node = sandbox.GetNode("tai-abc123") // → NodeInfo | null
|
||||||
// const all = sandbox.Nodes() // → NodeInfo[]
|
// const all = sandbox.Nodes() // → NodeInfo[]
|
||||||
// const team = sandbox.NodesByTeam("t-001") // → NodeInfo[]
|
// const team = sandbox.NodesByTeam("t-001") // → NodeInfo[]
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
// sandbox.Get(id) → Manager.Get(ctx, id) → Box
|
// sandbox.Get(id) → Manager.Get(ctx, id) → Box
|
||||||
// sandbox.List(filter?) → Manager.List(ctx, ListOptions) → []*Box → BoxInfo[]
|
// sandbox.List(filter?) → Manager.List(ctx, ListOptions) → []*Box → BoxInfo[]
|
||||||
// sandbox.Delete(id) → Manager.Remove(ctx, id) → void
|
// sandbox.Delete(id) → Manager.Remove(ctx, id) → void
|
||||||
// sandbox.Host(pool?) → Manager.Host(ctx, pool) → Host
|
// sandbox.Host(pool?) → Manager.Host(ctx, pool) → Computer (Host)
|
||||||
// sandbox.GetNode(id) → registry.Global().Get(id) → NodeInfo | null
|
// sandbox.GetNode(id) → registry.Global().Get(id) → NodeInfo | null
|
||||||
// sandbox.Nodes() → registry.Global().List() → NodeInfo[]
|
// sandbox.Nodes() → registry.Global().List() → NodeInfo[]
|
||||||
// sandbox.NodesByTeam(t)→ registry.Global().ListByTeam(t) → NodeInfo[]
|
// sandbox.NodesByTeam(t)→ registry.Global().ListByTeam(t) → NodeInfo[]
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,64 @@
|
||||||
package sandbox
|
package sandbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/yao/tai"
|
"github.com/yaoapp/yao/tai"
|
||||||
|
"github.com/yaoapp/yao/tai/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Computer — unified interface for execution environments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Computer is the unified interface for remote execution environments.
|
||||||
|
// Both Box (container) and Host (bare metal) implement it.
|
||||||
|
type Computer interface {
|
||||||
|
ComputerInfo() ComputerInfo
|
||||||
|
Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error)
|
||||||
|
Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error)
|
||||||
|
VNC(ctx context.Context) (string, error)
|
||||||
|
Proxy(ctx context.Context, port int, path string) (string, error)
|
||||||
|
BindWorkplace(workspaceID string)
|
||||||
|
Workplace() workspace.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputerInfo holds identity and registry information for a Computer.
|
||||||
|
type ComputerInfo struct {
|
||||||
|
Kind string // "box" | "host"
|
||||||
|
Pool string
|
||||||
|
TaiID string
|
||||||
|
MachineID string
|
||||||
|
Version string
|
||||||
|
System SystemInfo
|
||||||
|
Mode string // "direct" | "tunnel"
|
||||||
|
Capabilities map[string]bool
|
||||||
|
Status string
|
||||||
|
|
||||||
|
// Box-specific fields (zero values for Host)
|
||||||
|
BoxID string
|
||||||
|
ContainerID string
|
||||||
|
Owner string
|
||||||
|
Image string
|
||||||
|
Policy LifecyclePolicy
|
||||||
|
Labels map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemInfo describes the hardware of a Tai node.
|
||||||
|
type SystemInfo struct {
|
||||||
|
OS string
|
||||||
|
Arch string
|
||||||
|
Hostname string
|
||||||
|
NumCPU int
|
||||||
|
TotalMem int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle & Pool
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type LifecyclePolicy string
|
type LifecyclePolicy string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -26,7 +78,7 @@ type Pool struct {
|
||||||
MaxTotal int
|
MaxTotal int
|
||||||
IdleTimeout time.Duration
|
IdleTimeout time.Duration
|
||||||
MaxLifetime time.Duration
|
MaxLifetime time.Duration
|
||||||
StopTimeout time.Duration // SIGTERM grace period before SIGKILL; 0 = DefaultStopTimeout
|
StopTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type PoolInfo struct {
|
type PoolInfo struct {
|
||||||
|
|
@ -40,6 +92,10 @@ type PoolInfo struct {
|
||||||
MaxLifetime time.Duration
|
MaxLifetime time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Create / List options
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type PortMapping struct {
|
type PortMapping struct {
|
||||||
ContainerPort int
|
ContainerPort int
|
||||||
HostPort int
|
HostPort int
|
||||||
|
|
@ -62,12 +118,11 @@ type CreateOptions struct {
|
||||||
Ports []PortMapping
|
Ports []PortMapping
|
||||||
Policy LifecyclePolicy
|
Policy LifecyclePolicy
|
||||||
IdleTimeout time.Duration
|
IdleTimeout time.Duration
|
||||||
|
StopTimeout time.Duration
|
||||||
|
|
||||||
StopTimeout time.Duration // SIGTERM grace period; 0 = pool default or DefaultStopTimeout
|
WorkspaceID string
|
||||||
|
MountMode string
|
||||||
WorkspaceID string // workspace to mount; empty = no workspace
|
MountPath string
|
||||||
MountMode string // "rw" (default) or "ro"
|
|
||||||
MountPath string // container path; default "/workspace"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListOptions struct {
|
type ListOptions struct {
|
||||||
|
|
@ -76,38 +131,52 @@ type ListOptions struct {
|
||||||
Labels map[string]string
|
Labels map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Unified ExecOption / ExecResult / ExecStream
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type execConfig struct {
|
type execConfig struct {
|
||||||
WorkDir string
|
WorkDir string
|
||||||
Env map[string]string
|
Env map[string]string
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
|
Stdin []byte
|
||||||
|
MaxOutputBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecOption configures an Exec or Stream call on any Computer.
|
||||||
type ExecOption func(*execConfig)
|
type ExecOption func(*execConfig)
|
||||||
|
|
||||||
func WithWorkDir(dir string) ExecOption {
|
func WithWorkDir(dir string) ExecOption {
|
||||||
return func(c *execConfig) {
|
return func(c *execConfig) { c.WorkDir = dir }
|
||||||
c.WorkDir = dir
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithEnv(env map[string]string) ExecOption {
|
func WithEnv(env map[string]string) ExecOption {
|
||||||
return func(c *execConfig) {
|
return func(c *execConfig) { c.Env = env }
|
||||||
c.Env = env
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithTimeout(timeout time.Duration) ExecOption {
|
func WithTimeout(timeout time.Duration) ExecOption {
|
||||||
return func(c *execConfig) {
|
return func(c *execConfig) { c.Timeout = timeout }
|
||||||
c.Timeout = timeout
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithStdin(data []byte) ExecOption {
|
||||||
|
return func(c *execConfig) { c.Stdin = data }
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithMaxOutput(bytes int64) ExecOption {
|
||||||
|
return func(c *execConfig) { c.MaxOutputBytes = bytes }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecResult holds the outcome of a command executed on any Computer.
|
||||||
type ExecResult struct {
|
type ExecResult struct {
|
||||||
ExitCode int
|
ExitCode int
|
||||||
Stdout string
|
Stdout string
|
||||||
Stderr string
|
Stderr string
|
||||||
|
DurationMs int64
|
||||||
|
Error string
|
||||||
|
Truncated bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecStream provides real-time streaming I/O for a running command.
|
||||||
type ExecStream struct {
|
type ExecStream struct {
|
||||||
Stdout io.ReadCloser
|
Stdout io.ReadCloser
|
||||||
Stderr io.ReadCloser
|
Stderr io.ReadCloser
|
||||||
|
|
@ -116,6 +185,10 @@ type ExecStream struct {
|
||||||
Cancel func()
|
Cancel func()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Attach (Box-specific, not part of Computer interface)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type attachConfig struct {
|
type attachConfig struct {
|
||||||
Protocol string
|
Protocol string
|
||||||
Path string
|
Path string
|
||||||
|
|
@ -125,26 +198,20 @@ type attachConfig struct {
|
||||||
type AttachOption func(*attachConfig)
|
type AttachOption func(*attachConfig)
|
||||||
|
|
||||||
func WithProtocol(protocol string) AttachOption {
|
func WithProtocol(protocol string) AttachOption {
|
||||||
return func(c *attachConfig) {
|
return func(c *attachConfig) { c.Protocol = protocol }
|
||||||
c.Protocol = protocol
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithPath(path string) AttachOption {
|
func WithPath(path string) AttachOption {
|
||||||
return func(c *attachConfig) {
|
return func(c *attachConfig) { c.Path = path }
|
||||||
c.Path = path
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithHeaders(headers map[string]string) AttachOption {
|
func WithHeaders(headers map[string]string) AttachOption {
|
||||||
return func(c *attachConfig) {
|
return func(c *attachConfig) { c.Headers = headers }
|
||||||
c.Headers = headers
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImagePullOptions configures an image pull operation.
|
// ImagePullOptions configures an image pull operation.
|
||||||
type ImagePullOptions struct {
|
type ImagePullOptions struct {
|
||||||
Auth *RegistryAuth // nil = anonymous / public
|
Auth *RegistryAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegistryAuth holds credentials for a private container registry.
|
// RegistryAuth holds credentials for a private container registry.
|
||||||
|
|
@ -162,6 +229,7 @@ type ServiceConn struct {
|
||||||
Close func() error
|
Close func() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BoxInfo is a snapshot of a Box's runtime state (used by Manager.List).
|
||||||
type BoxInfo struct {
|
type BoxInfo struct {
|
||||||
ID string
|
ID string
|
||||||
ContainerID string
|
ContainerID string
|
||||||
|
|
@ -176,53 +244,3 @@ type BoxInfo struct {
|
||||||
ProcessCount int
|
ProcessCount int
|
||||||
VNC bool
|
VNC bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// HostExecResult holds the outcome of a command executed on the Tai host.
|
|
||||||
type HostExecResult struct {
|
|
||||||
ExitCode int
|
|
||||||
Stdout []byte
|
|
||||||
Stderr []byte
|
|
||||||
DurationMs int64
|
|
||||||
Error string
|
|
||||||
Truncated bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// HostExecStream provides real-time streaming output from a command running
|
|
||||||
// on the Tai host machine via HostExec gRPC ExecStream.
|
|
||||||
type HostExecStream struct {
|
|
||||||
Stdout <-chan []byte
|
|
||||||
Stderr <-chan []byte
|
|
||||||
Wait func() (int, error) // blocks until exit; returns exit code
|
|
||||||
Cancel func() // cancels the stream context
|
|
||||||
}
|
|
||||||
|
|
||||||
type hostExecConfig struct {
|
|
||||||
WorkDir string
|
|
||||||
Env map[string]string
|
|
||||||
Stdin []byte
|
|
||||||
TimeoutMs int64
|
|
||||||
MaxOutputBytes int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// HostExecOption configures an ExecOnHost call.
|
|
||||||
type HostExecOption func(*hostExecConfig)
|
|
||||||
|
|
||||||
func WithHostWorkDir(dir string) HostExecOption {
|
|
||||||
return func(c *hostExecConfig) { c.WorkDir = dir }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithHostEnv(env map[string]string) HostExecOption {
|
|
||||||
return func(c *hostExecConfig) { c.Env = env }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithHostStdin(data []byte) HostExecOption {
|
|
||||||
return func(c *hostExecConfig) { c.Stdin = data }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithHostTimeout(ms int64) HostExecOption {
|
|
||||||
return func(c *hostExecConfig) { c.TimeoutMs = ms }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithHostMaxOutput(bytes int64) HostExecOption {
|
|
||||||
return func(c *hostExecConfig) { c.MaxOutputBytes = bytes }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue