- tai/volume: add WithRemotePath sync option, 8 archive/compression methods (Zip/Unzip/Gzip/Gunzip/Tar/Untar/Tgz/Untgz) with local+remote impls - workspace/jsapi: implement 24-method WorkspaceFS (file I/O, Copy with local:///tmp:// URI dispatch, archive ops) and 4 static methods - workspace/manager: add Rename, MkdirAll, Volume() accessors via singleton - Tests: comprehensive Go tests for tai/volume, workspace, workspace/jsapi covering both local and remote modes via SANDBOX_TEST_REMOTE_ADDR - CI: update tai Docker image to latest in pr-test.yml - sandbox/v2: add JSAPI docs and host/node module stubs Made-with: Cursor
572 lines
15 KiB
Markdown
572 lines
15 KiB
Markdown
# Workspace JavaScript API
|
|
|
|
All methods are available on the global `workspace` object. No constructor needed.
|
|
|
|
## Quick Start
|
|
|
|
```javascript
|
|
// Create a workspace
|
|
const ws = workspace.Create({ name: "my-project", owner: "user-123", node: "default" })
|
|
|
|
// File I/O
|
|
ws.WriteFile("src/main.go", 'package main\n\nfunc main() {}\n')
|
|
const content = ws.ReadFile("src/main.go")
|
|
|
|
// Binary file (Base64)
|
|
const b64 = ws.ReadFileBase64("image.png")
|
|
ws.WriteFileBase64("copy.png", b64)
|
|
|
|
// Clean up
|
|
workspace.Delete(ws.id)
|
|
```
|
|
|
|
---
|
|
|
|
## Static Methods
|
|
|
|
### workspace.Create(options) → WorkspaceFS
|
|
|
|
Create a new workspace on a Tai node.
|
|
|
|
```javascript
|
|
const ws = workspace.Create({
|
|
name: "my-project", // required — human-readable name
|
|
owner: "user-123", // required — user ID
|
|
node: "default", // required — target Tai node
|
|
id: "ws-custom-id", // optional — auto-generated if empty
|
|
labels: { team: "backend" } // optional — custom labels
|
|
})
|
|
```
|
|
|
|
### workspace.Get(id) → WorkspaceFS | null
|
|
|
|
Get an existing workspace by ID. Returns `null` if not found.
|
|
|
|
```javascript
|
|
const ws = workspace.Get("ws-abc123")
|
|
if (ws) {
|
|
console.log(ws.id, ws.name, ws.node)
|
|
}
|
|
```
|
|
|
|
### workspace.List(filter?) → WorkspaceInfo[]
|
|
|
|
List all workspaces, optionally filtered.
|
|
|
|
```javascript
|
|
const all = workspace.List()
|
|
const mine = workspace.List({ owner: "user-123" })
|
|
const onNode = workspace.List({ node: "gpu-01" })
|
|
```
|
|
|
|
Each element:
|
|
|
|
```javascript
|
|
{
|
|
id: "ws-abc123",
|
|
name: "my-project",
|
|
owner: "user-123",
|
|
node: "default",
|
|
labels: { team: "backend" },
|
|
created_at: "2026-03-07T10:00:00Z",
|
|
updated_at: "2026-03-07T10:05:00Z"
|
|
}
|
|
```
|
|
|
|
### workspace.Delete(id) → void
|
|
|
|
Delete a workspace and its storage.
|
|
|
|
```javascript
|
|
workspace.Delete("ws-abc123")
|
|
```
|
|
|
|
---
|
|
|
|
## WorkspaceFS Object
|
|
|
|
Returned by `workspace.Create()`, `workspace.Get()`, `box.Workspace()`, and `host.Workspace()`.
|
|
|
|
### Properties (read-only)
|
|
|
|
| Property | Type | Description |
|
|
|----------|------|-------------|
|
|
| `ws.id` | string | Workspace ID |
|
|
| `ws.name` | string | Workspace name |
|
|
| `ws.node` | string | Tai node name |
|
|
|
|
---
|
|
|
|
### File Reading
|
|
|
|
#### ws.ReadFile(path) → string
|
|
|
|
Read file content as UTF-8 string.
|
|
|
|
```javascript
|
|
const content = ws.ReadFile("src/main.go")
|
|
```
|
|
|
|
Go: `workspace.M().ReadFile(id, name)` → `Volume.ReadFile` → `string(data)`
|
|
|
|
#### ws.ReadFileBase64(path) → string
|
|
|
|
Read file content as Base64-encoded string. Use for binary files (images, archives, etc.).
|
|
|
|
```javascript
|
|
const b64 = ws.ReadFileBase64("assets/logo.png")
|
|
```
|
|
|
|
Go: `M().ReadFile` → `base64.StdEncoding.EncodeToString(data)`
|
|
|
|
#### ws.ReadFileBuffer(path) → string
|
|
|
|
Read file content as Base64 string. Alias for `ReadFileBase64` (temporary — Uint8Array support pending v8go upgrade).
|
|
|
|
```javascript
|
|
const b64 = ws.ReadFileBuffer("data.bin")
|
|
```
|
|
|
|
Go: `M().ReadFile` → `base64.StdEncoding.EncodeToString(data)`
|
|
|
|
---
|
|
|
|
### File Writing
|
|
|
|
#### ws.WriteFile(path, data, perm?) → void
|
|
|
|
Write string data to a file. Creates parent directories if needed.
|
|
|
|
```javascript
|
|
ws.WriteFile("src/main.go", "package main\n...")
|
|
ws.WriteFile("config.yml", yamlContent, 0644)
|
|
```
|
|
|
|
Go: `workspace.M().WriteFile(id, name, []byte(data), perm)` → `Volume.WriteFile` — perm defaults to `0644`
|
|
|
|
#### ws.WriteFileBase64(path, b64, perm?) → void
|
|
|
|
Write Base64-encoded data to a file. Use for binary files.
|
|
|
|
```javascript
|
|
ws.WriteFileBase64("assets/logo.png", b64Data)
|
|
```
|
|
|
|
Go: `base64.Decode` → `M().WriteFile` → `Volume.WriteFile`
|
|
|
|
#### ws.WriteFileBuffer(path, b64, perm?) → void
|
|
|
|
Write Base64-encoded data to a file. Alias for `WriteFileBase64` (temporary — Uint8Array support pending v8go upgrade).
|
|
|
|
```javascript
|
|
ws.WriteFileBuffer("data.bin", b64Data)
|
|
```
|
|
|
|
Go: `base64.Decode` → `M().WriteFile` → `Volume.WriteFile`
|
|
|
|
---
|
|
|
|
### Directory Operations
|
|
|
|
#### ws.ReadDir(path?, recursive?) → DirEntry[]
|
|
|
|
List directory contents. Defaults to root (`"."`), non-recursive.
|
|
|
|
```javascript
|
|
// One level (default)
|
|
const entries = ws.ReadDir("src/")
|
|
// → [{ name: "main.go", ... }, { name: "utils", is_dir: true, ... }]
|
|
|
|
// Recursive — name becomes relative path
|
|
const all = ws.ReadDir("src/", true)
|
|
// → [{ name: "main.go", ... }, { name: "utils/helper.go", ... }]
|
|
|
|
entries.forEach(function(e) {
|
|
console.log(e.name, e.is_dir ? "(dir)" : e.size + " bytes")
|
|
})
|
|
```
|
|
|
|
Return type:
|
|
|
|
```javascript
|
|
{ name: "main.go", is_dir: false, size: 1234 }
|
|
// recursive mode: name is relative path, e.g. "utils/helper.go"
|
|
```
|
|
|
|
Go: non-recursive → `workspace.M().ListDir` → `Volume.ListDir`; recursive → `M().FS()` → `fs.WalkDir`
|
|
|
|
#### ws.MkdirAll(path) → void
|
|
|
|
Create a directory tree recursively. Permission is always `0755`.
|
|
|
|
```javascript
|
|
ws.MkdirAll("src/utils/helpers")
|
|
```
|
|
|
|
Go: `workspace.M().MkdirAll(id, name)` → `Volume.MkdirAll` (0755)
|
|
|
|
---
|
|
|
|
### File Operations
|
|
|
|
#### ws.Remove(path) → void
|
|
|
|
Remove a single file or empty directory.
|
|
|
|
```javascript
|
|
ws.Remove("tmp.txt")
|
|
```
|
|
|
|
Go: `FS.Remove(name)`
|
|
|
|
#### ws.RemoveAll(path) → void
|
|
|
|
Remove a file or directory recursively.
|
|
|
|
```javascript
|
|
ws.RemoveAll("build/")
|
|
```
|
|
|
|
Go: `FS.RemoveAll(name)`
|
|
|
|
#### ws.Rename(from, to) → void
|
|
|
|
Rename or move a file/directory within the workspace.
|
|
|
|
```javascript
|
|
ws.Rename("old.txt", "new.txt")
|
|
ws.Rename("src/foo.go", "src/bar.go")
|
|
```
|
|
|
|
Go: `workspace.M().Rename(id, oldname, newname)` → `Volume.Rename`
|
|
|
|
#### ws.Copy(src, dst, options?) → SyncResult | void
|
|
|
|
Unified copy method. Supports workspace-internal copy and host ↔ workspace copy via `local://` URI prefix.
|
|
|
|
**Path resolution:**
|
|
|
|
- No prefix → workspace-internal path (relative to workspace root)
|
|
- `local://` prefix → relative to App Root (`config.Conf.AppSource`), e.g. `local:///data/templates` → `{AppRoot}/data/templates`
|
|
- `tmp://` prefix → relative to `os.TempDir()`, e.g. `tmp:///workspace-staging` → `/tmp/workspace-staging`
|
|
|
|
**Security:** `..` traversal is rejected for both `local://` and `tmp://`. `local://` paths escaping App Root are rejected.
|
|
|
|
**Examples:**
|
|
|
|
```javascript
|
|
// workspace → workspace
|
|
ws.Copy("src/main.go", "src/main_backup.go")
|
|
ws.Copy("templates/", "projects/new/")
|
|
|
|
// host → workspace
|
|
ws.Copy("local:///app/templates/nextjs", "prompts/")
|
|
ws.Copy("local:///data/assistants/bot-a/config", "config/")
|
|
|
|
// workspace → host
|
|
ws.Copy("build/dist/", "local:///app/output/dist")
|
|
|
|
// host → host (no extra fs needed)
|
|
ws.Copy("local:///templates/nextjs", "local:///backup/nextjs-backup")
|
|
|
|
// tmp dir → workspace
|
|
ws.Copy("tmp:///workspace-staging/data", "imported/")
|
|
|
|
// workspace → tmp dir
|
|
ws.Copy("build/dist/", "tmp:///export-staging")
|
|
|
|
// with options (excludes, force)
|
|
ws.Copy("local:///app/templates/nextjs", "project/", {
|
|
excludes: ["node_modules", ".git", "*.log"],
|
|
force: true
|
|
})
|
|
```
|
|
|
|
Options:
|
|
|
|
```javascript
|
|
{
|
|
excludes: ["node_modules"], // optional — glob patterns to exclude
|
|
force: false // optional — skip incremental diff, sync everything
|
|
}
|
|
```
|
|
|
|
Return value:
|
|
|
|
- **Workspace internal**: void
|
|
- **Host ↔ workspace** (`local://`/`tmp://` on one side): `SyncResult`
|
|
- **Host → host** (both `local://`/`tmp://`): void
|
|
|
|
```javascript
|
|
{
|
|
files_synced: 42, // number of files transferred
|
|
bytes_transferred: 1048576, // total bytes
|
|
duration_ms: 1234 // time taken in ms
|
|
}
|
|
```
|
|
|
|
**Dispatch rules (Go layer):**
|
|
|
|
| src | dst | Implementation |
|
|
|-----|-----|----------------|
|
|
| workspace | workspace | `FS.ReadFile` + `FS.WriteFile` (recursive for dirs) |
|
|
| host URI | workspace | `Volume.SyncPush(hostPath, wsPath, opts)` |
|
|
| workspace | host URI | `Volume.SyncPull(wsPath, hostPath, opts)` |
|
|
| host URI | host URI | `os` package recursive copy |
|
|
|
|
Where "host URI" = `local://` (relative to App Root) or `tmp://` (relative to `os.TempDir()`).
|
|
|
|
---
|
|
|
|
### Archive & Compression
|
|
|
|
All archive methods operate on paths within the workspace. Pack operations support an `excludes` option.
|
|
|
|
#### ws.Zip(src, dst, options?) → ArchiveResult
|
|
|
|
Create a ZIP archive from `src` directory to `dst` file.
|
|
|
|
```javascript
|
|
const result = ws.Zip("src/", "dist.zip")
|
|
const filtered = ws.Zip("src/", "dist.zip", { excludes: ["*.log", "node_modules"] })
|
|
```
|
|
|
|
#### ws.Unzip(src, dst) → ArchiveResult
|
|
|
|
Extract a ZIP archive from `src` file to `dst` directory.
|
|
|
|
```javascript
|
|
const result = ws.Unzip("dist.zip", "extracted/")
|
|
```
|
|
|
|
#### ws.Gzip(src, dst) → ArchiveResult
|
|
|
|
Compress a single file with gzip.
|
|
|
|
```javascript
|
|
ws.Gzip("data.json", "data.json.gz")
|
|
```
|
|
|
|
#### ws.Gunzip(src, dst) → ArchiveResult
|
|
|
|
Decompress a gzip file.
|
|
|
|
```javascript
|
|
ws.Gunzip("data.json.gz", "data.json")
|
|
```
|
|
|
|
#### ws.Tar(src, dst, options?) → ArchiveResult
|
|
|
|
Create a tar archive from `src` directory.
|
|
|
|
```javascript
|
|
ws.Tar("src/", "archive.tar")
|
|
ws.Tar("src/", "archive.tar", { excludes: [".git"] })
|
|
```
|
|
|
|
#### ws.Untar(src, dst) → ArchiveResult
|
|
|
|
Extract a tar archive.
|
|
|
|
```javascript
|
|
ws.Untar("archive.tar", "extracted/")
|
|
```
|
|
|
|
#### ws.Tgz(src, dst, options?) → ArchiveResult
|
|
|
|
Create a gzip-compressed tar archive (.tar.gz / .tgz).
|
|
|
|
```javascript
|
|
ws.Tgz("src/", "archive.tgz")
|
|
```
|
|
|
|
#### ws.Untgz(src, dst) → ArchiveResult
|
|
|
|
Extract a gzip-compressed tar archive.
|
|
|
|
```javascript
|
|
ws.Untgz("archive.tgz", "extracted/")
|
|
```
|
|
|
|
**ArchiveResult:**
|
|
|
|
```javascript
|
|
{
|
|
size_bytes: 102400, // output file size (pack) or total extracted size (unpack)
|
|
files_count: 15 // number of files processed
|
|
}
|
|
```
|
|
|
|
Go: delegates to `Volume.Zip`, `Volume.Unzip`, `Volume.Gzip`, `Volume.Gunzip`, `Volume.Tar`, `Volume.Untar`, `Volume.Tgz`, `Volume.Untgz`.
|
|
|
|
---
|
|
|
|
### File Information
|
|
|
|
#### ws.Stat(path) → FileInfo
|
|
|
|
Get file or directory metadata.
|
|
|
|
```javascript
|
|
const info = ws.Stat("src/main.go")
|
|
console.log(info.name, info.size, info.is_dir, info.mod_time, info.mode)
|
|
```
|
|
|
|
Return type:
|
|
|
|
```javascript
|
|
{
|
|
name: "main.go",
|
|
size: 1234,
|
|
is_dir: false,
|
|
mod_time: "2026-03-07T10:00:00Z",
|
|
mode: 0644
|
|
}
|
|
```
|
|
|
|
Go: `FS.Stat(name) → fs.FileInfo`
|
|
|
|
#### ws.Exists(path) → boolean
|
|
|
|
Check if a file or directory exists.
|
|
|
|
```javascript
|
|
if (ws.Exists("config.yml")) {
|
|
// ...
|
|
}
|
|
```
|
|
|
|
Go: `FS.Stat(name)` — returns `true` if err == nil
|
|
|
|
#### ws.IsDir(path) → boolean
|
|
|
|
Check if a path is a directory. Returns `false` if not found.
|
|
|
|
```javascript
|
|
if (ws.IsDir("src/")) {
|
|
// ...
|
|
}
|
|
```
|
|
|
|
Go: `FS.Stat(name) → info.IsDir()`
|
|
|
|
#### ws.IsFile(path) → boolean
|
|
|
|
Check if a path is a regular file. Returns `false` if not found.
|
|
|
|
```javascript
|
|
if (ws.IsFile("main.go")) {
|
|
// ...
|
|
}
|
|
```
|
|
|
|
Go: `FS.Stat(name) → !info.IsDir()`
|
|
|
|
---
|
|
|
|
## Go Interface Reference
|
|
|
|
WorkspaceFS methods map to `taiworkspace.FS` and `volume.Volume`. Some methods (Stat, Remove, RemoveAll) use `workspace.M().FS()` directly; others (ReadFile, WriteFile, Rename, MkdirAll) go through `workspace.M()` → `Volume`:
|
|
|
|
FS interface (defined in `tai/workspace/workspace.go`):
|
|
|
|
```go
|
|
type FS interface {
|
|
fs.FS // Open(name) (fs.File, error)
|
|
fs.StatFS // Stat(name) (fs.FileInfo, error)
|
|
fs.ReadFileFS // ReadFile(name) ([]byte, error)
|
|
fs.ReadDirFS // ReadDir(name) ([]fs.DirEntry, error)
|
|
io.Closer
|
|
|
|
WriteFile(name string, data []byte, perm os.FileMode) error
|
|
Remove(name string) error
|
|
RemoveAll(name string) error
|
|
Rename(oldname, newname string) error
|
|
MkdirAll(name string, perm os.FileMode) error
|
|
}
|
|
```
|
|
|
|
Archive methods delegate to `volume.Volume` (defined in `tai/volume/volume.go`):
|
|
|
|
```go
|
|
type Volume interface {
|
|
// ... FS methods ...
|
|
Zip(ctx, sessionID, src, dst string, excludes []string) (*ArchiveResult, error)
|
|
Unzip(ctx, sessionID, src, dst string) (*ArchiveResult, error)
|
|
Gzip(ctx, sessionID, src, dst string) (*ArchiveResult, error)
|
|
Gunzip(ctx, sessionID, src, dst string) (*ArchiveResult, error)
|
|
Tar(ctx, sessionID, src, dst string, excludes []string) (*ArchiveResult, error)
|
|
Untar(ctx, sessionID, src, dst string) (*ArchiveResult, error)
|
|
Tgz(ctx, sessionID, src, dst string, excludes []string) (*ArchiveResult, error)
|
|
Untgz(ctx, sessionID, src, dst string) (*ArchiveResult, error)
|
|
}
|
|
```
|
|
|
|
`Exists`, `IsDir`, `IsFile` are thin JSAPI wrappers over `FS.Stat`.
|
|
|
|
`ReadDir` adds a `recursive` parameter in Go — non-recursive calls `Volume.ListDir`, recursive uses `M().FS()` + `fs.WalkDir`.
|
|
|
|
`Copy` is implemented in Go with host URI dispatch: workspace paths use `FS.ReadFile`/`FS.WriteFile`; `local://` (App Root) and `tmp://` (`os.TempDir()`) paths use `Volume.SyncPush`/`SyncPull` with `WithRemotePath`; host-to-host uses `os` package.
|
|
|
|
Base64/Buffer variants (`ReadFileBase64`, `ReadFileBuffer`, `WriteFileBase64`, `WriteFileBuffer`) are Go-side encoding wrappers around `M().ReadFile` / `M().WriteFile` (→ `Volume`). Buffer variants currently use Base64 encoding (Uint8Array support pending v8go upgrade).
|
|
|
|
---
|
|
|
|
## Method Summary
|
|
|
|
### Standard FS — core file operations
|
|
|
|
| Method | Returns | Go mapping |
|
|
|--------|---------|------------|
|
|
| `ws.ReadFile(path)` | string | `M().ReadFile` → `Volume.ReadFile` |
|
|
| `ws.WriteFile(path, data, perm?)` | void | `M().WriteFile` → `Volume.WriteFile` |
|
|
| `ws.Stat(path)` | FileInfo | `M().FS()` → `FS.Stat` |
|
|
| `ws.MkdirAll(path)` | void | `M().MkdirAll` → `Volume.MkdirAll` (0755) |
|
|
| `ws.Remove(path)` | void | `M().FS()` → `FS.Remove` |
|
|
| `ws.RemoveAll(path)` | void | `M().FS()` → `FS.RemoveAll` |
|
|
| `ws.Rename(from, to)` | void | `M().Rename` → `Volume.Rename` |
|
|
|
|
### Go wrapper — implemented in Go, called directly from JSAPI
|
|
|
|
| Method | Returns | Go implementation |
|
|
|--------|---------|-------------------|
|
|
| `ws.ReadDir(path?, recursive?)` | DirEntry[] | non-recursive: `Volume.ListDir`; recursive: `FS` + `fs.WalkDir` |
|
|
| `ws.ReadFileBase64(path)` | string | `M().ReadFile` → `base64.Encode` |
|
|
| `ws.ReadFileBuffer(path)` | string | `M().ReadFile` → `base64.Encode` (temp, Uint8Array pending) |
|
|
| `ws.WriteFileBase64(path, b64, perm?)` | void | `base64.Decode` → `M().WriteFile` |
|
|
| `ws.WriteFileBuffer(path, b64, perm?)` | void | `base64.Decode` → `M().WriteFile` (temp, Uint8Array pending) |
|
|
| `ws.Copy(src, dst, opts?)` | void / SyncResult | dispatch by host URI prefix (see below) |
|
|
|
|
`ws.Copy` dispatch (all handled in Go):
|
|
|
|
| src | dst | Returns | Go implementation |
|
|
|-----|-----|---------|-------------------|
|
|
| workspace | workspace | void | `FS.ReadFile` + `FS.WriteFile` (recursive for dirs) |
|
|
| host URI | workspace | SyncResult | `Volume.SyncPush` with `WithRemotePath` |
|
|
| workspace | host URI | SyncResult | `Volume.SyncPull` with `WithRemotePath` |
|
|
| host URI | host URI | void | `os` package recursive copy |
|
|
|
|
Where "host URI" = `local://` (App Root) or `tmp://` (`os.TempDir()`).
|
|
|
|
### Archive — delegates to `Volume` interface
|
|
|
|
| Method | Returns | Go implementation |
|
|
|--------|---------|-------------------|
|
|
| `ws.Zip(src, dst, opts?)` | ArchiveResult | `Volume.Zip` |
|
|
| `ws.Unzip(src, dst)` | ArchiveResult | `Volume.Unzip` |
|
|
| `ws.Gzip(src, dst)` | ArchiveResult | `Volume.Gzip` |
|
|
| `ws.Gunzip(src, dst)` | ArchiveResult | `Volume.Gunzip` |
|
|
| `ws.Tar(src, dst, opts?)` | ArchiveResult | `Volume.Tar` |
|
|
| `ws.Untar(src, dst)` | ArchiveResult | `Volume.Untar` |
|
|
| `ws.Tgz(src, dst, opts?)` | ArchiveResult | `Volume.Tgz` |
|
|
| `ws.Untgz(src, dst)` | ArchiveResult | `Volume.Untgz` |
|
|
|
|
### JSAPI composition — thin JS wrappers over standard FS
|
|
|
|
| Method | Returns | Composed from |
|
|
|--------|---------|---------------|
|
|
| `ws.Exists(path)` | boolean | `Stat` → err == nil |
|
|
| `ws.IsDir(path)` | boolean | `Stat` → `info.IsDir()` |
|
|
| `ws.IsFile(path)` | boolean | `Stat` → `!info.IsDir()` |
|
|
|
|
**Total: 24 methods + 3 read-only properties** (7 standard FS + 6 Go wrapper + 8 archive + 3 JSAPI composition)
|