diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 63fd4e9a..9b671a0e 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -1112,6 +1112,25 @@ jobs: echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1 } + - name: Generate kubeconfig for Tai K8s + run: | + K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress') + echo "k3d server IP: ${K3D_IP}" + + k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml + + # Kubeconfig for tai-k8s container (uses k3d-internal IP) + sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d.yml \ + > /tmp/kubeconfig-tai-k8s.yml + echo "Container kubeconfig server:" + grep server: /tmp/kubeconfig-tai-k8s.yml + + # Kubeconfig for test runner (uses localhost via port-mapped 6443) + sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \ + > ${{ runner.temp }}/kubeconfig-tai.yml + echo "Test runner kubeconfig server:" + grep server: ${{ runner.temp }}/kubeconfig-tai.yml + - name: Start Tai K8s instance run: | K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress') @@ -1120,7 +1139,9 @@ jobs: docker run -d --name tai-k8s \ --network k3d-tai-test \ -p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \ + -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:latest for i in $(seq 1 30); do @@ -1143,14 +1164,6 @@ jobs: echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1 } - - name: Generate kubeconfig for Tai K8s proxy - run: | - k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml - sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \ - > ${{ runner.temp }}/kubeconfig-tai.yml - echo "Generated kubeconfig:" - grep server: ${{ runner.temp }}/kubeconfig-tai.yml - - name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace) env: TAI_TEST_HOST: "127.0.0.1" diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index a6d24efd..2173f606 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -820,6 +820,25 @@ jobs: echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1 } + - name: Generate kubeconfig for Tai K8s + run: | + K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress') + echo "k3d server IP: ${K3D_IP}" + + k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml + + # Kubeconfig for tai-k8s container (uses k3d-internal IP) + sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d.yml \ + > /tmp/kubeconfig-tai-k8s.yml + echo "Container kubeconfig server:" + grep server: /tmp/kubeconfig-tai-k8s.yml + + # Kubeconfig for test runner (uses localhost via port-mapped 6443) + sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \ + > ${{ runner.temp }}/kubeconfig-tai.yml + echo "Test runner kubeconfig server:" + grep server: ${{ runner.temp }}/kubeconfig-tai.yml + - name: Start Tai K8s instance run: | K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress') @@ -828,7 +847,9 @@ jobs: docker run -d --name tai-k8s \ --network k3d-tai-test \ -p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \ + -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:latest for i in $(seq 1 30); do @@ -851,14 +872,6 @@ jobs: echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1 } - - name: Generate kubeconfig for Tai K8s proxy - run: | - k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml - sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \ - > ${{ runner.temp }}/kubeconfig-tai.yml - echo "Generated kubeconfig:" - grep server: ${{ runner.temp }}/kubeconfig-tai.yml - - name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace) env: TAI_TEST_HOST: "127.0.0.1" diff --git a/sandbox/v2/DESIGN.md b/sandbox/v2/DESIGN.md index 1fa40423..43508486 100644 --- a/sandbox/v2/DESIGN.md +++ b/sandbox/v2/DESIGN.md @@ -21,7 +21,8 @@ Sandbox does NOT import or depend on Agent. Agent is one of many consumers. ``` ┌─────────────────────────────────────────────────┐ │ Consumers (know nothing about tai/Docker/K8s) │ -│ ├── JSAPI: Sandbox("my-app") │ +│ ├── JSAPI: sandbox.Create/Get/List/Delete │ +│ ├── JSAPI: workspace.Create/Get/List/Delete │ │ ├── Process: sandbox.Create, sandbox.Exec │ │ ├── Agent: uses sandbox via interface │ │ └── API: /api/__yao/sandbox/* │ @@ -576,8 +577,9 @@ sandbox/v2/ ├── config.go // Config struct ├── errors.go // sentinel errors ├── grpc.go // token creation/revocation, gRPC env var injection -├── jsapi/ // (Phase 2) V8 JSAPI Sandbox() constructor -│ └── sandbox.go +├── jsapi/ // (Phase 2) V8 JSAPI sandbox.* namespace +│ ├── jsapi.go // RegisterObject("sandbox"), Create/Get/List/Delete +│ └── box.go // Box JS object: Exec/Attach/VNC/Proxy/Workspace/Info/Start/Stop/Remove ├── export_test.go // ResetForTest() for test isolation ├── testutils_test.go // shared test helpers (multi-pool setup) ├── sandbox_test.go // Init/M singleton tests @@ -729,6 +731,9 @@ workspace/ ├── workspace.go // types, metadata marshal/unmarshal ├── manager.go // Manager: CRUD, file I/O, node management ├── errors.go // sentinel errors +├── jsapi/ // (Phase 2) V8 JSAPI workspace.* namespace +│ ├── jsapi.go // RegisterObject("workspace"), Create/Get/List/Delete +│ └── fs.go // WorkspaceFS JS object: ReadFile/WriteFile/ReadDir/Stat/MkdirAll/Remove/RemoveAll/Rename ├── testutils_test.go // shared test helpers ├── workspace_test.go // CRUD tests (Create/Get/List/Update/Delete/Nodes) ├── fileio_test.go // File I/O + fs.FS tests @@ -824,12 +829,269 @@ Docker `StopStart` ~2.2s is expected: `DefaultStopTimeout = 2s` and Docker waits - Tests: unit + integration + benchmarks - CI: consolidated SandboxV2Test + BenchmarkSandboxV2 -## Phase 2: JSAPI + OAuth (PENDING) +## Phase 2: JSAPI + OAuth + Auth (PENDING) + +### Prerequisites | Task | Detail | |------|--------| -| `sandbox/v2/jsapi/` | V8 `Sandbox()` / `Workspace()` constructors (registered in gou runtime) | | Wire `openapi/oauth` | `grpc.go` currently uses random token placeholders; replace with real OAuth issue/revoke | + +### JSAPI Design + +All JSAPI methods are static — no constructors, no Go objects in V8, no bridge/Release. +JS objects only hold string IDs, delegate everything to Go singletons (`sandbox.M()`, `workspace.M()`). + +#### sandbox namespace (`RegisterObject("sandbox")`) + +Static methods: + +| JS | Go | Returns | +|----|-----|---------| +| `sandbox.Create(opts)` | `Manager.Create(ctx, CreateOptions)` | `Box` | +| `sandbox.Create(opts)` (opts.id set) | `Manager.GetOrCreate(ctx, CreateOptions)` | `Box` | +| `sandbox.Get(id)` | `Manager.Get(ctx, id)` | `Box \| null` | +| `sandbox.List(filter?)` | `Manager.List(ctx, ListOptions)` → `Box.Info()` | `BoxInfo[]` | +| `sandbox.Delete(id)` | `Manager.Remove(ctx, id)` | `void` | + +`sandbox.Create(options)` — JS options → Go `CreateOptions`: + +``` +{ + id: string → CreateOptions.ID // optional; triggers GetOrCreate + owner: string → CreateOptions.Owner // required + pool: string → CreateOptions.Pool // default: first pool + image: string → CreateOptions.Image // required + workdir: string → CreateOptions.WorkDir + user: string → CreateOptions.User // e.g. "1000:1000" + env: object → CreateOptions.Env // map[string]string + memory: number → CreateOptions.Memory // bytes (int64) + cpus: number → CreateOptions.CPUs // float64 + vnc: boolean → CreateOptions.VNC + ports: array → CreateOptions.Ports // [{container, host, host_ip, protocol}] → []PortMapping + policy: string → CreateOptions.Policy // "oneshot"|"session"|"longrunning"|"persistent" + idle_timeout: number → CreateOptions.IdleTimeout // ms → time.Duration + stop_timeout: number → CreateOptions.StopTimeout // ms → time.Duration + workspace_id: string → CreateOptions.WorkspaceID + mount_mode: string → CreateOptions.MountMode // "rw"|"ro" + mount_path: string → CreateOptions.MountPath + labels: object → CreateOptions.Labels // map[string]string +} +``` + +`sandbox.List(filter?)` — JS filter → Go `ListOptions`: + +``` +{ + owner: string → ListOptions.Owner // empty = all + pool: string → ListOptions.Pool // empty = all + labels: object → ListOptions.Labels +} +``` + +Returns `BoxInfo[]` — each element: + +``` +{ + id: string ← BoxInfo.ID + container_id: string ← BoxInfo.ContainerID + pool: string ← BoxInfo.Pool + owner: string ← BoxInfo.Owner + status: string ← BoxInfo.Status + image: string ← BoxInfo.Image + vnc: boolean ← BoxInfo.VNC + policy: string ← BoxInfo.Policy + labels: object ← BoxInfo.Labels + created_at: string ← BoxInfo.CreatedAt (ISO 8601) + last_active: string ← BoxInfo.LastActive (ISO 8601) + process_count: number ← BoxInfo.ProcessCount +} +``` + +#### Box object + +Read-only properties: + +| JS | Go | +|----|----| +| `box.id` | `Box.ID()` | +| `box.owner` | `Box.Owner()` | +| `box.pool` | `Box.Pool()` | + +Methods: + +| JS | Go | Returns | +|----|-----|---------| +| `box.Exec(cmd, opts?)` | `Box.Exec(ctx, cmd, ...ExecOption)` | `ExecResult` | +| `box.Stream(cmd, opts?)` | `Box.Stream(ctx, cmd, ...ExecOption)` | `ExecStream` | +| `box.Attach(port, opts?)` | `Box.Attach(ctx, port, ...AttachOption)` | `ServiceConn` | +| `box.VNC()` | `Box.VNC(ctx)` | `string` | +| `box.Proxy(port, path?)` | `Box.Proxy(ctx, port, path)` | `string` | +| `box.Workspace()` | `Box.WorkspaceID()` → `NewFSObject` | `WorkspaceFS` | +| `box.Info()` | `Box.Info(ctx)` | `BoxInfo` | +| `box.Start()` | `Box.Start(ctx)` | `void` | +| `box.Stop()` | `Box.Stop(ctx)` | `void` | +| `box.Remove()` | `Box.Remove(ctx)` | `void` | + +`box.Exec(cmd, options?)`: + +``` +cmd: string[] → cmd []string +options: { + workdir: string, → WithWorkDir(dir) + env: object, → WithEnv(map[string]string) + timeout: number → WithTimeout(ms → time.Duration) +} +returns: { + exit_code: number, ← ExecResult.ExitCode + stdout: string, ← ExecResult.Stdout + stderr: string ← ExecResult.Stderr +} +``` + +`box.Stream(cmd, options?)`: + +``` +options: same as Exec +returns: { + stdout: ReadableStream, ← ExecStream.Stdout + stderr: ReadableStream, ← ExecStream.Stderr + stdin: WritableStream, ← ExecStream.Stdin + wait: function() → number, ← ExecStream.Wait() (int, error) + cancel: function() → void ← ExecStream.Cancel() +} +``` + +`box.Attach(port, options?)`: + +``` +port: number → port int +options: { + protocol: "ws"|"sse", → WithProtocol(protocol) + path: string, → WithPath(path) + headers: object → WithHeaders(map[string]string) +} +returns: { + url: string, ← ServiceConn.URL + read: function() → Uint8Array, ← ServiceConn.Read() + write: function(data) → void, ← ServiceConn.Write(data) + events: AsyncIterable, ← ServiceConn.Events + close: function() → void ← ServiceConn.Close() +} +``` + +`box.Info()` returns same structure as `BoxInfo[]` element above. + +#### workspace namespace (`RegisterObject("workspace")`) + +Static methods: + +| JS | Go | Returns | +|----|-----|---------| +| `workspace.Create(opts)` | `Manager.Create(ctx, CreateOptions)` | `WorkspaceFS` | +| `workspace.Get(id)` | `Manager.Get(ctx, id)` | `WorkspaceFS \| null` | +| `workspace.List(filter?)` | `Manager.List(ctx, ListOptions)` | `WorkspaceInfo[]` | +| `workspace.Delete(id)` | `Manager.Delete(ctx, id, false)` | `void` | + +`workspace.Create(options)` — JS options → Go `CreateOptions`: + +``` +{ + id: string → CreateOptions.ID // optional; auto-generated if empty + name: string → CreateOptions.Name // required + owner: string → CreateOptions.Owner // required + node: string → CreateOptions.Node // required + labels: object → CreateOptions.Labels // map[string]string +} +``` + +`workspace.List(filter?)` — JS filter → Go `ListOptions`: + +``` +{ + owner: string → ListOptions.Owner // empty = all + node: string → ListOptions.Node // empty = all +} +``` + +Returns `WorkspaceInfo[]` — each element: + +``` +{ + id: string ← Workspace.ID + name: string ← Workspace.Name + owner: string ← Workspace.Owner + node: string ← Workspace.Node + labels: object ← Workspace.Labels + created_at: string ← Workspace.CreatedAt (ISO 8601) + updated_at: string ← Workspace.UpdatedAt (ISO 8601) +} +``` + +#### WorkspaceFS object + +Read-only properties: + +| JS | Go | +|----|----| +| `ws.id` | workspace ID | +| `ws.name` | `Workspace.Name` | +| `ws.node` | `Workspace.Node` | + +Methods (1:1 to Go `taiworkspace.FS` + `Manager` shortcuts): + +| JS | Go | Returns | +|----|-----|---------| +| `ws.ReadFile(path)` | `FS.ReadFile(name)` / `Manager.ReadFile(ctx, id, path)` | `string` | +| `ws.WriteFile(path, data, perm?)` | `FS.WriteFile(name, data, perm)` / `Manager.WriteFile(ctx, id, path, data, perm)` | `void` | +| `ws.ReadDir(path?)` | `FS.ReadDir(name)` / `Manager.ListDir(ctx, id, path)` | `DirEntry[]` | +| `ws.Stat(path)` | `FS.Stat(name)` | `FileInfo` | +| `ws.MkdirAll(path, perm?)` | `FS.MkdirAll(name, perm)` | `void` | +| `ws.Remove(path)` | `FS.Remove(name)` / `Manager.Remove(ctx, id, path)` | `void` | +| `ws.RemoveAll(path)` | `FS.RemoveAll(name)` | `void` | +| `ws.Rename(from, to)` | `FS.Rename(old, new)` | `void` | + +Planned (not yet implemented): + +| JS | Go | Returns | Note | +|----|-----|---------|------| +| `ws.ReadFileBase64(path)` | `FS.ReadFile` → `base64.StdEncoding.EncodeToString` | `string` | Avoids V8↔Go binary bridge overhead for images, archives, etc. | +| `ws.WriteFileBase64(path, b64, perm?)` | `base64.StdEncoding.DecodeString` → `FS.WriteFile` | `void` | Same — base64 string transfer is far more efficient than Uint8Array across the bridge | +| `ws.CopyFromHost(hostPath, destPath?)` | Host `os.Read` → `FS.WriteFile` / `FS.MkdirAll` per entry | `void` | Copy file/dir from Yao host into workspace; `destPath` defaults to basename | +| `ws.CopyFromHostArchive(hostPath, destPath?)` | Zip on host → Tai Volume upload → Tai-side unarchive | `void` | For large directory trees; requires Tai server-side unarchive support | + +Return types: + +``` +DirEntry: { name: string, is_dir: boolean, size: number } +FileInfo: { name: string, size: number, is_dir: boolean, mod_time: string (ISO 8601) } +``` + +### Auth + +JSAPI does not enforce permissions internally. The Go Manager methods execute operations directly without owner/admin checks. + +Developers retrieve the current caller identity via the gou global `Authorized()` function (registered by `gou/runtime/v8/functions/authorized`, reads from `bridge.Share.Authorized` / `__yao_data.AUTHORIZED`) and implement permission logic in their JS scripts. + +`Authorized()` returns `map[string]interface{}` (or null if not set). The exact fields depend on what the caller sets via `Context.WithAuthorized()`. There is no fixed schema — typical fields include `user_id`, `team_id`, `scope`, etc. + +```javascript +const auth = Authorized() // gou global — returns caller info or null +const box = sandbox.Get(id) +// Developer decides permission logic — fields depend on application's auth setup +if (box.owner !== auth.user_id) { + throw new Error("permission denied") +} +``` + +Permission control is the responsibility of the caller (JS scripts, Agent hooks, API middleware, etc.). + +### Implementation Tasks + +| Task | Detail | +|------|--------| +| `sandbox/v2/jsapi/` | `RegisterObject("sandbox")` with Create/Get/List/Delete + Box object | +| `workspace/jsapi/` | `RegisterObject("workspace")` with Create/Get/List/Delete + FS object | | Integration with `cmd/start.go` | Call `sandbox.Init()` + `sandbox.M().Start()` | ## Phase 3: Agent Integration (PENDING) diff --git a/sandbox/v2/jsapi/box.go b/sandbox/v2/jsapi/box.go index 37efa81e..e93b5d84 100644 --- a/sandbox/v2/jsapi/box.go +++ b/sandbox/v2/jsapi/box.go @@ -4,49 +4,126 @@ import ( "rogchap.com/v8go" ) -// NewBoxObject creates a JS Box object with the following methods: +// 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.ID() → string // sandbox ID -// box.Owner() → string // owner -// box.ContainerID() → string // underlying container/pod ID -// box.Pool() → string // pool name -// box.WorkspaceID() → string // mounted workspace ID (empty if none) +// # Properties (read-only) // -// box.Exec(cmd, options?) → ExecResult // run command, wait for completion -// cmd: string[] // command + args -// options: { workdir, env, timeout } -// returns: { exit_code: number, stdout: string, stderr: string } +// box.id → string // sandbox ID ← Box.ID() +// box.owner → string // owner user ID ← Box.Owner() +// box.pool → string // pool name ← Box.Pool() // -// box.Stream(cmd, options?) → ExecStream // streaming I/O -// returns: { stdout: ReadableStream, stderr: ReadableStream, -// stdin: WritableStream, wait: ()=>number, cancel: ()=>void } +// # Methods — Go mapping // -// box.Attach(port, options?) → ServiceConn // WebSocket/SSE attach -// port: number // container port -// options: { protocol, path, headers } -// returns: { url: string, close: ()=>void } +// box.Exec(cmd, options?) → ExecResult // -// box.VNC() → string // VNC WebSocket URL -// box.Proxy(port, path?) → string // HTTP proxy URL +// Go: Box.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error) // -// box.Workspace() → WorkspaceFS // workspace file system -// returns WorkspaceFS object (see workspace/jsapi) +// JS args: +// cmd: string[] → cmd []string +// options: { → ExecOption functional options +// workdir: string, → WithWorkDir(dir) +// env: object, → WithEnv(map[string]string) +// timeout: number → WithTimeout(ms → time.Duration) +// } +// JS returns: { +// exit_code: number, ← ExecResult.ExitCode +// stdout: string, ← ExecResult.Stdout +// stderr: string ← ExecResult.Stderr +// } // -// box.Info() → BoxInfo // container status -// returns: { id, container_id, pool, owner, status, policy, -// labels, image, created_at, last_active, process_count, vnc } +// box.Stream(cmd, options?) → ExecStream // -// box.Start() → void // start stopped box -// box.Stop() → void // stop running box -// box.Remove() → void // remove box permanently -// box.Release() → void // release JS bridge ref -func NewBoxObject(v8ctx *v8go.Context /* , box *sandbox.Box */) (*v8go.Value, error) { +// Go: Box.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error) +// +// JS returns: { +// stdout: ReadableStream, ← ExecStream.Stdout +// stderr: ReadableStream, ← ExecStream.Stderr +// stdin: WritableStream, ← ExecStream.Stdin +// wait: function() → number, ← ExecStream.Wait() (int, error) +// cancel: function() → void ← ExecStream.Cancel() +// } +// +// box.Attach(port, options?) → ServiceConn +// +// Go: Box.Attach(ctx, port int, opts ...AttachOption) (*ServiceConn, error) +// +// JS args: +// port: number → port int +// options: { → AttachOption functional options +// protocol: "ws"|"sse", → WithProtocol(protocol) +// path: string, → WithPath(path) +// headers: object → WithHeaders(map[string]string) +// } +// JS returns: { +// url: string, ← ServiceConn.URL +// read: function() → Uint8Array, ← ServiceConn.Read() ([]byte, error) +// write: function(data) → void, ← ServiceConn.Write(data) error +// events: AsyncIterable, ← ServiceConn.Events <-chan []byte +// close: function() → void ← ServiceConn.Close() error +// } +// +// 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 +// +// Go: Box.Workspace() workspace.FS +// Box.WorkspaceID() string +// Returns: WorkspaceFS object (see workspace/jsapi/fs.go) +// Uses box.WorkspaceID() to create NewFSObject +// +// box.Info() → BoxInfo +// +// Go: Box.Info(ctx) (*BoxInfo, error) +// JS returns: { +// id: string, ← BoxInfo.ID +// container_id: string, ← BoxInfo.ContainerID +// pool: string, ← BoxInfo.Pool +// owner: string, ← BoxInfo.Owner +// status: string, ← BoxInfo.Status +// image: string, ← BoxInfo.Image +// vnc: boolean, ← BoxInfo.VNC +// policy: string, ← BoxInfo.Policy (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 +// +// 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 ObjectTemplate with InternalFieldCount(1) - // 2. Register box in bridge - // 3. Bind property accessors: ID, Owner, ContainerID, Pool, WorkspaceID - // 4. Bind methods: Exec, Stream, Attach, VNC, Proxy, Workspace, - // Info, Start, Stop, Remove, Release - // 5. Create instance, set internal field + // 1. Create JS object via v8go.NewObjectTemplate + // 2. Set read-only properties: id, owner, pool (from sandbox.M().Get(boxID)) + // 3. Bind each method as FunctionTemplate: + // - Exec → sandbox.M().Get(id).Exec(ctx, cmd, opts...) + // - Stream → sandbox.M().Get(id).Stream(ctx, cmd, opts...) + // - Attach → sandbox.M().Get(id).Attach(ctx, port, opts...) + // - VNC → sandbox.M().Get(id).VNC(ctx) + // - Proxy → sandbox.M().Get(id).Proxy(ctx, port, path) + // - 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/jsapi.go b/sandbox/v2/jsapi/jsapi.go index 12fecca4..84c71337 100644 --- a/sandbox/v2/jsapi/jsapi.go +++ b/sandbox/v2/jsapi/jsapi.go @@ -1,14 +1,24 @@ -// Package jsapi registers the Sandbox() constructor into the Yao V8 runtime. +// Package jsapi registers the sandbox namespace into the Yao V8 runtime. +// +// All methods are static on the sandbox object — no constructor. // // # JavaScript API // -// const sb = new Sandbox({ pool: "default", image: "node:20", owner: "user1" }) -// const box = sb.Create({ workdir: "/app", env: { NODE_ENV: "dev" } }) +// const box = sandbox.Create({ image: "node:20", owner: "user1" }) // const result = box.Exec(["node", "-e", "console.log('hi')"]) -// box.Remove() +// console.log(result.stdout) // -// The constructor returns a SandboxManager object; Create/GetOrCreate returns -// a Box object with Exec/Stream/Attach/VNC/Proxy/Workspace/Info/Stop/Start/Remove. +// const box = sandbox.Get(id) // → Box +// const list = sandbox.List({ owner: "u1" }) // → BoxInfo[] +// sandbox.Delete(id) // → void +// +// # 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 // // Registration happens via init() — import with: // @@ -21,30 +31,125 @@ import ( ) func init() { - v8.RegisterFunction("Sandbox", ExportFunction) + v8.RegisterObject("sandbox", ExportObject) } -// ExportFunction exports the Sandbox constructor to V8. -func ExportFunction(iso *v8go.Isolate) *v8go.FunctionTemplate { - return v8go.NewFunctionTemplate(iso, sandboxConstructor) +// ExportObject exports the sandbox namespace object to V8. +func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate { + obj := v8go.NewObjectTemplate(iso) + obj.Set("Create", v8go.NewFunctionTemplate(iso, sbCreate)) + obj.Set("Get", v8go.NewFunctionTemplate(iso, sbGet)) + obj.Set("List", v8go.NewFunctionTemplate(iso, sbList)) + obj.Set("Delete", v8go.NewFunctionTemplate(iso, sbDelete)) + return obj } -// sandboxConstructor is called when JS executes `new Sandbox(options)`. +// sbCreate: `sandbox.Create(options)` → Box // -// Options: +// Go: Manager.Create(ctx, CreateOptions) (*Box, error) +// +// Manager.GetOrCreate(ctx, CreateOptions) (*Box, error) — when opts.id is set +// +// JS options → Go CreateOptions mapping: // // { -// pool: string // pool name (required) -// image: string // container image (required) -// owner: string // owner ID (required) +// id: string → CreateOptions.ID // optional; triggers GetOrCreate +// owner: string → CreateOptions.Owner // required +// pool: string → CreateOptions.Pool // default: first pool +// image: string → CreateOptions.Image // required +// workdir: string → CreateOptions.WorkDir +// user: string → CreateOptions.User // e.g. "1000:1000" +// env: object → CreateOptions.Env // map[string]string +// memory: number → CreateOptions.Memory // bytes (int64) +// cpus: number → CreateOptions.CPUs // float64 e.g. 1.5 +// vnc: boolean → CreateOptions.VNC +// ports: array → CreateOptions.Ports // [{container, host, host_ip, protocol}] → []PortMapping +// policy: string → CreateOptions.Policy // "oneshot"|"session"|"longrunning"|"persistent" +// idle_timeout: number → CreateOptions.IdleTimeout // ms → time.Duration +// stop_timeout: number → CreateOptions.StopTimeout // ms → time.Duration +// workspace_id: string → CreateOptions.WorkspaceID +// mount_mode: string → CreateOptions.MountMode // "rw"|"ro" +// mount_path: string → CreateOptions.MountPath +// labels: object → CreateOptions.Labels // map[string]string // } // -// Returns a SandboxManager JS object. -func sandboxConstructor(info *v8go.FunctionCallbackInfo) *v8go.Value { - // TODO: Phase 2 implementation - // 1. Parse options from args[0] - // 2. Validate required fields (pool, image, owner) - // 3. Get sandbox.M() singleton - // 4. Return NewManagerObject(v8ctx, manager, options) +// Returns: Box object (see box.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()) + return v8go.Undefined(info.Context().Isolate()) +} + +// sbGet: `sandbox.Get(id)` → Box | null +// +// Go: Manager.Get(ctx, id) (*Box, error) +// +// Args: +// +// id: string — sandbox ID +// +// Returns: Box object 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 + return v8go.Undefined(info.Context().Isolate()) +} + +// sbList: `sandbox.List(filter?)` → BoxInfo[] +// +// Go: Manager.List(ctx, ListOptions) ([]*Box, error) +// +// then Box.Info(ctx) for each → BoxInfo +// +// JS filter → Go ListOptions mapping: +// +// { +// owner: string → ListOptions.Owner // filter by owner; empty = all +// pool: string → ListOptions.Pool // filter by pool; empty = all +// labels: object → ListOptions.Labels // filter by labels +// } +// +// Returns: BoxInfo[] — each element: +// +// { +// id: string ← BoxInfo.ID +// container_id: string ← BoxInfo.ContainerID +// pool: string ← BoxInfo.Pool +// owner: string ← BoxInfo.Owner +// status: string ← BoxInfo.Status +// image: string ← BoxInfo.Image +// vnc: boolean ← BoxInfo.VNC +// policy: string ← BoxInfo.Policy +// labels: object ← BoxInfo.Labels +// created_at: string ← BoxInfo.CreatedAt (ISO 8601) +// last_active: string ← BoxInfo.LastActive (ISO 8601) +// process_count: number ← BoxInfo.ProcessCount +// } +func sbList(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. Parse optional filter from info.Args()[0] + // 2. boxes := sandbox.M().List(ctx, opts) + // 3. For each box: box.Info(ctx) → BoxInfo → JS object + // 4. Return JS array of BoxInfo objects + return v8go.Undefined(info.Context().Isolate()) +} + +// sbDelete: `sandbox.Delete(id)` → void +// +// Go: Manager.Remove(ctx, id) error +// +// Args: +// +// id: string — sandbox ID to remove +func sbDelete(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. id = info.Args()[0].String() + // 2. sandbox.M().Remove(ctx, id) return v8go.Undefined(info.Context().Isolate()) } diff --git a/sandbox/v2/jsapi/manager.go b/sandbox/v2/jsapi/manager.go deleted file mode 100644 index 247bfcbd..00000000 --- a/sandbox/v2/jsapi/manager.go +++ /dev/null @@ -1,45 +0,0 @@ -package jsapi - -import ( - "rogchap.com/v8go" -) - -// NewManagerObject creates a JS SandboxManager object with the following methods: -// -// manager.Create(options?) → Box // create a new sandbox box -// manager.GetOrCreate(opts) → Box // get existing or create -// manager.Get(id) → Box|null // get by sandbox ID -// manager.List(options?) → Box[] // list boxes -// manager.Remove(id) → void // remove a box -// manager.EnsureImage(ref) → void // pull image if missing -// manager.ImageExists(ref) → boolean // check image presence -// manager.Pools() → PoolInfo[] // list pool info -// manager.Release() → void // release JS bridge ref -// -// Create options (merged with constructor defaults): -// -// { -// id: string // explicit sandbox ID (optional) -// workdir: string // container working directory -// user: string // container user (e.g. "1000:1000") -// env: object // environment variables -// memory: number // memory limit in bytes -// cpus: number // CPU limit (e.g. 1.5) -// vnc: boolean // enable VNC -// ports: array // port mappings [{container: 8080, host: 0}] -// policy: string // "oneshot"|"session"|"longrunning"|"persistent" -// idle_timeout: number // idle timeout in ms -// stop_timeout: number // stop timeout in ms -// workspace_id: string // workspace to mount -// mount_mode: string // "rw"|"ro" -// mount_path: string // mount target in container -// } -func NewManagerObject(v8ctx *v8go.Context /* manager *sandbox.Manager, defaults CreateDefaults */) (*v8go.Value, error) { - // TODO: Phase 2 implementation - // 1. Create ObjectTemplate with InternalFieldCount(1) - // 2. Register manager in bridge - // 3. Bind methods: Create, GetOrCreate, Get, List, Remove, - // EnsureImage, ImageExists, Pools, Release - // 4. Create instance, set internal field - return nil, nil -} diff --git a/workspace/jsapi/fs.go b/workspace/jsapi/fs.go index 19583dc3..17c0515c 100644 --- a/workspace/jsapi/fs.go +++ b/workspace/jsapi/fs.go @@ -4,33 +4,127 @@ import ( "rogchap.com/v8go" ) -// NewFSObject creates a JS WorkspaceFS object implementing a file system interface. +// NewFSObject creates a JS WorkspaceFS object backed by a workspace ID string. +// All methods delegate to workspace.M() → FS — no Go object passed to V8. // -// This is the object returned by both: -// - WorkspaceManager.FS(id) (standalone workspace access) -// - Box.Workspace() (sandbox-mounted workspace access) +// # Properties (read-only) // -// Methods: +// ws.id → string // workspace ID ← workspaceID arg +// ws.name → string // workspace name ← Workspace.Name +// ws.node → string // tai node name ← Workspace.Node // -// fs.ReadFile(path) → string // read UTF-8 content -// fs.ReadFileBytes(path) → ArrayBuffer // read binary content -// fs.WriteFile(path, data) → void // write string or ArrayBuffer -// fs.Stat(path) → FileInfo // file metadata -// returns: { name, size, mode, mod_time, is_dir } -// fs.ReadDir(path?) → DirEntry[] // list directory (default ".") -// returns: [{ name, is_dir, size }] -// fs.MkdirAll(path) → void // create directory tree -// fs.Remove(path) → void // remove single file/empty dir -// fs.RemoveAll(path) → void // remove recursively -// fs.Rename(from, to) → void // rename/move -// fs.Close() → void // close FS handle -// fs.Release() → void // release JS bridge ref -func NewFSObject(v8ctx *v8go.Context /* , wfs taiworkspace.FS */) (*v8go.Value, error) { +// # Methods — Go mapping +// +// Each method internally does: fs, _ := workspace.M().FS(ctx, workspaceID) +// then calls the corresponding method on taiworkspace.FS. +// +// ws.ReadFile(path) → string +// +// Go: FS.ReadFile(name string) ([]byte, error) +// — also available via Manager.ReadFile(ctx, id, path) +// JS args: path string +// JS returns: string (UTF-8 content of the file) +// +// ws.WriteFile(path, data, perm?) → void +// +// Go: FS.WriteFile(name string, data []byte, perm os.FileMode) error +// — also available via Manager.WriteFile(ctx, id, path, data, perm) +// JS args: path string, data string|Uint8Array, perm? number (default 0644) +// +// ws.ReadDir(path?) → DirEntry[] +// +// Go: FS.ReadDir(name string) ([]fs.DirEntry, error) +// — also available via Manager.ListDir(ctx, id, path) +// JS args: path string (default ".") +// JS returns: [{ +// name: string, ← DirEntry.Name() +// is_dir: boolean, ← DirEntry.IsDir() +// size: number ← DirEntry.Info().Size() +// }] +// +// ws.Stat(path) → FileInfo +// +// Go: FS.Stat(name string) (fs.FileInfo, error) +// JS args: path string +// JS returns: { +// name: string, ← FileInfo.Name() +// size: number, ← FileInfo.Size() +// is_dir: boolean, ← FileInfo.IsDir() +// mod_time: string ← FileInfo.ModTime() (ISO 8601) +// } +// +// ws.MkdirAll(path, perm?) → void +// +// Go: FS.MkdirAll(name string, perm os.FileMode) error +// JS args: path string, perm? number (default 0755) +// +// ws.Remove(path) → void +// +// Go: FS.Remove(name string) error +// — also available via Manager.Remove(ctx, id, path) +// JS args: path string (single file or empty directory) +// +// ws.RemoveAll(path) → void +// +// Go: FS.RemoveAll(name string) error +// JS args: path string (recursive removal) +// +// ws.Rename(from, to) → void +// +// Go: FS.Rename(oldname, newname string) error +// JS args: from string, to string +// +// # Base64 variants (PLANNED — not yet implemented) +// +// Avoids V8↔Go binary bridge overhead for images, archives, etc. +// +// ws.ReadFileBase64(path) → string +// +// Go: FS.ReadFile(name) → base64.StdEncoding.EncodeToString(data) +// JS args: path string +// JS returns: string (base64-encoded content) +// +// ws.WriteFileBase64(path, b64, perm?) → void +// +// Go: base64.StdEncoding.DecodeString(b64) → FS.WriteFile(name, data, perm) +// JS args: path string, b64 string, perm? number (default 0644) +// +// # Host copy (PLANNED — not yet implemented) +// +// Copy files/dirs from Yao host filesystem into the workspace volume. +// Useful for seeding workspaces with templates, config files, assets, etc. +// +// ws.CopyFromHost(hostPath, destPath?) → void +// +// Copies a single file or directory tree from the Yao host into the workspace. +// Go: read host file(s) → FS.WriteFile / FS.MkdirAll for each entry +// JS args: hostPath string (absolute path on Yao host), +// destPath? string (target path inside workspace, default basename of hostPath) +// +// ws.CopyFromHostArchive(hostPath, destPath?) → void +// +// For large directory trees: zip on host → transfer → unzip on Tai node. +// Requires Tai server-side unarchive support. +// Go: zip hostPath → tai Volume upload → tai unarchive at destPath +// JS args: hostPath string, destPath? string (default ".") +func NewFSObject(v8ctx *v8go.Context, workspaceID string) (*v8go.Value, error) { // TODO: Phase 2 implementation - // 1. Create ObjectTemplate with InternalFieldCount(1) - // 2. Register FS in bridge - // 3. Bind methods: ReadFile, ReadFileBytes, WriteFile, - // Stat, ReadDir, MkdirAll, Remove, RemoveAll, Rename, Close, Release - // 4. Create instance, set internal field + // 1. Create JS object via v8go.NewObjectTemplate + // 2. Set read-only properties: id, name, node (from workspace.M().Get(workspaceID)) + // 3. Bind each method as FunctionTemplate: + // - ReadFile → workspace.M().FS(ctx, id).ReadFile(path) + // - WriteFile → workspace.M().FS(ctx, id).WriteFile(path, data, perm) + // - ReadDir → workspace.M().FS(ctx, id).ReadDir(path) + // - Stat → workspace.M().FS(ctx, id).Stat(path) + // - MkdirAll → workspace.M().FS(ctx, id).MkdirAll(path, perm) + // - Remove → workspace.M().FS(ctx, id).Remove(path) + // - RemoveAll → workspace.M().FS(ctx, id).RemoveAll(path) + // - Rename → workspace.M().FS(ctx, id).Rename(old, new) + // + // PLANNED (not yet implemented): + // - ReadFileBase64 → ReadFile + base64 encode in Go + // - WriteFileBase64 → base64 decode in Go + WriteFile + // - CopyFromHost → host fs.Read → FS.Write (file-by-file) + // - CopyFromHostArchive → zip on host → tai transfer → unzip (needs Tai support) return nil, nil } diff --git a/workspace/jsapi/jsapi.go b/workspace/jsapi/jsapi.go index 51f49fa2..624818da 100644 --- a/workspace/jsapi/jsapi.go +++ b/workspace/jsapi/jsapi.go @@ -1,14 +1,22 @@ -// Package jsapi registers the Workspace() constructor into the Yao V8 runtime. +// Package jsapi registers the workspace namespace into the Yao V8 runtime. +// +// All methods are static on the workspace object — no constructor. // // # JavaScript API // -// const ws = new Workspace({ node: "tai-1" }) -// const info = ws.Create({ name: "my-project", owner: "user1" }) -// const file = ws.ReadFile(info.id, "/README.md") -// ws.WriteFile(info.id, "/app.ts", content) +// const ws = workspace.Create({ name: "proj", owner: "user1", node: "default" }) +// const ws = workspace.Get(id) +// ws.ReadFile("main.go") → string +// ws.WriteFile("out.txt", data) → void +// ws.ReadDir("src/") → [{ name, is_dir, size }] +// workspace.Delete(id) → void // -// The constructor returns a WorkspaceManager object; individual workspace -// files are accessed through ReadFile/WriteFile/ListDir or the FS() handle. +// # Go mapping +// +// workspace.Create(opts) → Manager.Create(ctx, CreateOptions) → *Workspace → WorkspaceFS +// workspace.Get(id) → Manager.Get(ctx, id) → *Workspace → WorkspaceFS +// workspace.List(filter?) → Manager.List(ctx, ListOptions) → []*Workspace → WorkspaceInfo[] +// workspace.Delete(id) → Manager.Delete(ctx, id, false) → void // // Registration happens via init() — import with: // @@ -21,27 +29,101 @@ import ( ) func init() { - v8.RegisterFunction("Workspace", ExportFunction) + v8.RegisterObject("workspace", ExportObject) } -// ExportFunction exports the Workspace constructor to V8. -func ExportFunction(iso *v8go.Isolate) *v8go.FunctionTemplate { - return v8go.NewFunctionTemplate(iso, workspaceConstructor) +// ExportObject exports the workspace namespace object to V8. +func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate { + obj := v8go.NewObjectTemplate(iso) + obj.Set("Create", v8go.NewFunctionTemplate(iso, wsCreate)) + obj.Set("Get", v8go.NewFunctionTemplate(iso, wsGet)) + obj.Set("List", v8go.NewFunctionTemplate(iso, wsList)) + obj.Set("Delete", v8go.NewFunctionTemplate(iso, wsDelete)) + return obj } -// workspaceConstructor is called when JS executes `new Workspace(options?)`. +// wsCreate: `workspace.Create(options)` → WorkspaceFS // -// Options (all optional — uses global workspace.Manager if omitted): +// Go: Manager.Create(ctx, CreateOptions) (*Workspace, error) +// +// JS options → Go CreateOptions mapping: // // { -// node: string // default target node for Create (optional) +// id: string → CreateOptions.ID // optional; auto-generated if empty +// name: string → CreateOptions.Name // required, human-readable name +// owner: string → CreateOptions.Owner // required, user ID +// node: string → CreateOptions.Node // required, target Tai node +// labels: object → CreateOptions.Labels // optional, map[string]string // } // -// Returns a WorkspaceManager JS object. -func workspaceConstructor(info *v8go.FunctionCallbackInfo) *v8go.Value { - // TODO: Phase 2 implementation - // 1. Parse optional options from args[0] - // 2. Get workspace manager instance - // 3. Return NewManagerObject(v8ctx, manager, defaults) +// Returns: WorkspaceFS object (see fs.go) +func wsCreate(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. Parse options from info.Args()[0] + // 2. Validate required fields (name, owner, node) + // 3. ws := workspace.M().Create(ctx, opts) + // 4. Return NewFSObject(v8ctx, ws.ID) + return v8go.Undefined(info.Context().Isolate()) +} + +// wsGet: `workspace.Get(id)` → WorkspaceFS | null +// +// Go: Manager.Get(ctx, id) (*Workspace, error) +// +// Args: +// +// id: string — workspace ID +// +// Returns: WorkspaceFS object if found, null if not found +func wsGet(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. id = info.Args()[0].String() + // 2. ws, err := workspace.M().Get(ctx, id) + // 3. Return NewFSObject(v8ctx, id) or null + return v8go.Undefined(info.Context().Isolate()) +} + +// wsList: `workspace.List(filter?)` → WorkspaceInfo[] +// +// Go: Manager.List(ctx, ListOptions) ([]*Workspace, error) +// +// JS filter → Go ListOptions mapping: +// +// { +// owner: string → ListOptions.Owner // filter by owner; empty = all +// node: string → ListOptions.Node // filter by node; empty = all +// } +// +// Returns: WorkspaceInfo[] — each element: +// +// { +// id: string ← Workspace.ID +// name: string ← Workspace.Name +// owner: string ← Workspace.Owner +// node: string ← Workspace.Node +// labels: object ← Workspace.Labels +// created_at: string ← Workspace.CreatedAt (ISO 8601) +// updated_at: string ← Workspace.UpdatedAt (ISO 8601) +// } +func wsList(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. Parse optional filter from info.Args()[0] + // 2. list := workspace.M().List(ctx, opts) + // 3. Convert each *Workspace → JS object + // 4. Return JS array + return v8go.Undefined(info.Context().Isolate()) +} + +// wsDelete: `workspace.Delete(id)` → void +// +// Go: Manager.Delete(ctx, id string, force bool) error +// +// Args: +// +// id: string — workspace ID to remove (force = false) +func wsDelete(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. id = info.Args()[0].String() + // 2. workspace.M().Delete(ctx, id, false) return v8go.Undefined(info.Context().Isolate()) } diff --git a/workspace/jsapi/manager.go b/workspace/jsapi/manager.go deleted file mode 100644 index 0aac6754..00000000 --- a/workspace/jsapi/manager.go +++ /dev/null @@ -1,53 +0,0 @@ -package jsapi - -import ( - "rogchap.com/v8go" -) - -// NewManagerObject creates a JS WorkspaceManager object with the following methods: -// -// wm.Create(options) → WorkspaceInfo // create workspace -// options: { -// id: string // explicit ID (optional, auto uuid) -// name: string // display name (required) -// owner: string // owner user ID (required) -// node: string // target Tai node (required, or use constructor default) -// labels: object // metadata key-value pairs -// } -// returns: { id, name, owner, node, labels, created_at, updated_at } -// -// wm.Get(id) → WorkspaceInfo|null -// wm.List(options?) → WorkspaceInfo[] -// options: { owner: string, node: string } -// -// wm.Update(id, options) → WorkspaceInfo -// options: { name: string, labels: object } -// -// wm.Delete(id, force?) → void -// force: boolean // delete even if has active mounts -// -// wm.ReadFile(id, path) → string // read file content (UTF-8) -// wm.ReadFileBytes(id, path) → ArrayBuffer // read file content (binary) -// wm.WriteFile(id, path, data) → void // write file (string or ArrayBuffer) -// wm.ListDir(id, path?) → DirEntry[] // list directory -// returns: [{ name, is_dir, size }] -// wm.Remove(id, path) → void // remove file or dir -// wm.MkdirAll(id, path) → void // create directory tree -// wm.Rename(id, from, to) → void // rename/move file -// -// wm.FS(id) → WorkspaceFS // get full FS handle -// wm.MountPath(id) → string // host mount path -// wm.Nodes() → NodeInfo[] // list available nodes -// returns: [{ name, addr, online }] -// -// wm.Release() → void // release JS bridge ref -func NewManagerObject(v8ctx *v8go.Context /* , manager *workspace.Manager, defaults ManagerDefaults */) (*v8go.Value, error) { - // TODO: Phase 2 implementation - // 1. Create ObjectTemplate with InternalFieldCount(1) - // 2. Register manager in bridge - // 3. Bind methods: Create, Get, List, Update, Delete, - // ReadFile, ReadFileBytes, WriteFile, ListDir, Remove, - // MkdirAll, Rename, FS, MountPath, Nodes, Release - // 4. Create instance, set internal field - return nil, nil -}