Update DESIGN.md and IMPL.md for Sandbox V2 enhancements
- Add workspace integration as a new feature in Sandbox V2, allowing for persistent user storage. - Update the Manager API to include new methods for workspace management and image handling. - Refactor the Pool struct to include additional options and improve lifecycle management. - Revise implementation status in IMPL.md to reflect completed phases and optimizations, ensuring clarity on the current state of the project. These changes enhance the functionality and documentation of Sandbox V2, improving user experience and system architecture.
This commit is contained in:
parent
d25bfefee0
commit
dd08881027
2 changed files with 731 additions and 1438 deletions
1272
sandbox/v2/DESIGN.md
1272
sandbox/v2/DESIGN.md
File diff suppressed because it is too large
Load diff
|
|
@ -1,704 +1,283 @@
|
|||
# Sandbox V2 — Implementation Plan
|
||||
|
||||
Phase 1 implementation. Covers tai SDK prerequisites + sandbox/v2 core Go API.
|
||||
No JSAPI, no Process registration — those are Phase 2.
|
||||
# Sandbox V2 — Implementation Status
|
||||
|
||||
Reference: [DESIGN.md](./DESIGN.md)
|
||||
|
||||
## Execution Order
|
||||
---
|
||||
|
||||
```
|
||||
Step 0: tai/sandbox — Labels, User, ContainerInfo.Labels (no deps)
|
||||
Step 1: tai/sandbox — ExecStream (no deps)
|
||||
Step 2: tai/proxy — Connect (no deps)
|
||||
Step 3: yao/grpc — Heartbeat RPC (proto + handler) (no deps)
|
||||
Step 4: tai/grpc — yao-grpc heartbeat goroutine (depends on Step 3 proto)
|
||||
Step 4.5: docker — build v2 test images (depends on Steps 1–4)
|
||||
Step 5: sandbox/v2 — core module (depends on Steps 0–4)
|
||||
Step 6: tests (depends on Steps 5 + 4.5)
|
||||
```
|
||||
## Phase 1: Core Module — DONE
|
||||
|
||||
Steps 0–3 are independent and can be parallelized.
|
||||
Step 4.5 (images) depends on tai SDK + yao-grpc changes being compiled into binaries.
|
||||
### tai SDK Prerequisites — DONE
|
||||
|
||||
| Step | Package | What | Status |
|
||||
|------|---------|------|--------|
|
||||
| 0 | `tai/sandbox` | Labels, User in CreateOptions + ContainerInfo | DONE |
|
||||
| 1 | `tai/sandbox` | ExecStream (Docker + K8s) | DONE |
|
||||
| 2 | `tai/proxy` | Connect (WS/SSE, Local + Remote) | DONE |
|
||||
| 3 | `tai/sandbox` | Image interface (Exists, Pull, Remove, List) | DONE |
|
||||
| 4 | `tai/tai.go` | Client: Sandbox(), Image(), Proxy(), VNC(), Volume(), Workspace() | DONE |
|
||||
| 5 | `yao/grpc` | Heartbeat RPC (proto + handler) | DONE |
|
||||
|
||||
### sandbox/v2 Core — DONE
|
||||
|
||||
| File | What | Status |
|
||||
|------|------|--------|
|
||||
| `sandbox.go` | `Init()`, `M()`, global singleton | DONE |
|
||||
| `manager.go` | Manager: Create/Get/GetOrCreate/List/Remove/Cleanup/Close, Start (container recovery), AddPool/RemovePool/Pools, Heartbeat, SetGRPCPort, SetWorkspaceManager, ImageExists/PullImage/EnsureImage | DONE |
|
||||
| `box.go` | Box: Exec, Stream, Attach, Workspace, VNC, Proxy, Start/Stop/Remove, Info, touch/lastActiveTime/idleTimeout/maxLifetime/stopTimeout | DONE |
|
||||
| `types.go` | LifecyclePolicy (OneShot/Session/LongRunning/Persistent), Pool, PoolInfo, PortMapping, CreateOptions (with WorkspaceID/MountMode/MountPath), ListOptions, ExecOption/ExecResult/ExecStream, AttachOption/ServiceConn, ImagePullOptions/RegistryAuth, BoxInfo, DefaultStopTimeout | DONE |
|
||||
| `config.go` | Config struct | DONE |
|
||||
| `errors.go` | ErrNotAvailable, ErrNotFound, ErrLimitExceeded, ErrPoolNotFound, ErrPoolInUse | DONE |
|
||||
| `grpc.go` | CreateContainerTokens, RevokeContainerTokens, BuildGRPCEnv | DONE |
|
||||
|
||||
### workspace Module — DONE
|
||||
|
||||
| File | What | Status |
|
||||
|------|------|--------|
|
||||
| `workspace.go` | Workspace struct, CreateOptions, ListOptions, UpdateOptions, NodeInfo, MountMode, metadata marshal/unmarshal | DONE |
|
||||
| `manager.go` | Manager: Create/Get/List/Update/Delete, ReadFile/WriteFile/ListDir/Remove/FS, Nodes/AddPool/RemovePool, NodeForWorkspace/MountPath | DONE |
|
||||
| `errors.go` | ErrNotFound, ErrNodeMissing, ErrNodeOffline, ErrHasMounts | DONE |
|
||||
|
||||
### Tests — DONE
|
||||
|
||||
| File | Coverage | Status |
|
||||
|------|----------|--------|
|
||||
| **sandbox/v2** | | |
|
||||
| `sandbox_test.go` | Init, M, singleton | DONE |
|
||||
| `manager_test.go` | Create, Get, GetOrCreate, List, Remove, pool limits (MaxTotal, MaxPerUser), multi-pool | DONE |
|
||||
| `manager_lifecycle_test.go` | Start (recovery), Cleanup, idle tracking, Heartbeat | DONE |
|
||||
| `box_test.go` | Exec, Info, Workspace (ReadFile/WriteFile), status | DONE |
|
||||
| `box_attach_test.go` | Attach WS echo, Attach SSE events, VNC URL, VNC Connect (RFB handshake) | DONE |
|
||||
| `box_workspace_test.go` | Workspace mount, file I/O through Box, invalid ID | DONE |
|
||||
| `box_image_test.go` | ImageExists (Docker+K8s), PullImage (progress+K8s no-op), EnsureImage, bad ref | DONE |
|
||||
| `grpc_test.go` | Token creation/revocation, env var building (local vs remote) | DONE |
|
||||
| `bench_test.go` | ContainerLifecycle, Create, Exec, ExecHeavy, Remove, Info, StopStart, WorkspaceReadWrite | DONE |
|
||||
| `testutils_test.go` | testPools (local/remote/k8s), setupManager, createTestBox, ensureTestImage | DONE |
|
||||
| `export_test.go` | ResetForTest | DONE |
|
||||
| **workspace** | | |
|
||||
| `workspace_test.go` | Create (auto/explicit ID, labels, invalid node), Get, List (owner/node filter), Update (name/labels), Delete, Nodes, NodeForWorkspace, AddPool/RemovePool, MountPath | DONE |
|
||||
| `fileio_test.go` | ReadWriteFile, nested paths, ListDir, Remove, fs.FS (ReadFile, WriteFile, MkdirAll, Rename, WalkDir, Remove, NotFound) | DONE |
|
||||
| `bench_test.go` | WriteFile, ReadFile, ReadWriteCycle, WriteLargeFile, ListDir, FSWalkDir, CreateDelete | DONE |
|
||||
| `testutils_test.go` | testPools, setupManagerForPool, clientForPool, localClient, setupManagerMultiNode, createWorkspace | DONE |
|
||||
|
||||
### CI — DONE
|
||||
|
||||
| Job | Contents | Status |
|
||||
|-----|----------|--------|
|
||||
| `SandboxV2Test` | Consolidated: image pre-pull → tai-test → sandbox/v2 (local+remote+k8s) → workspace (local+remote) | DONE |
|
||||
| `BenchmarkSandboxV2` | Parallel: performance tests for sandbox/v2 + workspace | DONE |
|
||||
| `GRPCTest` | Independent: gRPC tests (unchanged) | DONE |
|
||||
|
||||
### Performance Optimizations — DONE
|
||||
|
||||
| Optimization | Before | After | Impact |
|
||||
|-------------|--------|-------|--------|
|
||||
| Remove redundant Stop in Manager.Remove() | 2.14s | 177ms | 12x faster Docker remove |
|
||||
| Container CMD trap SIGTERM | 2s+ stop | near-instant | Graceful shutdown on Stop |
|
||||
| K8s Start: respect ctx deadline | 30s hardcoded | ctx-aware + 60s default | Proper timeout propagation |
|
||||
| K8s Pod spec: Args vs Command | CMD overridden | ENTRYPOINT preserved | Correct container behavior |
|
||||
|
||||
---
|
||||
|
||||
## Step 0: `tai` — Labels, User, ContainerInfo.Labels + `tai.New("local")`
|
||||
## Phase 2: Process + JSAPI — PENDING
|
||||
|
||||
**Files:** `tai/tai.go`, `tai/sandbox/sandbox.go`, `tai/sandbox/docker_core.go`, `tai/sandbox/k8s.go`
|
||||
| Task | Package | Detail |
|
||||
|------|---------|--------|
|
||||
| `process.go` | `sandbox/v2` | Register `sandbox.*` process namespace (sandbox.Create, sandbox.Exec, sandbox.ReadFile, etc.) |
|
||||
| `process.go` | `workspace` | Register `workspace.*` process namespace |
|
||||
| `jsapi/sandbox.go` | `sandbox/v2/jsapi` | V8 JSAPI `Sandbox()` constructor (registered in gou runtime) |
|
||||
| `cmd/start.go` integration | `yao` | Call `sandbox.Init(config.Conf.Sandbox)` + `sandbox.M().Start(ctx)` in startup sequence |
|
||||
| Heartbeat bridge | `yao/grpc` | Wire gRPC Heartbeat handler → `sandbox.M().Heartbeat()` |
|
||||
| Wire `openapi/oauth` | `sandbox/v2/grpc.go` | `CreateContainerTokens` currently generates random strings; `RevokeContainerTokens` is a no-op. Replace with real `openapi/oauth` issue/revoke calls |
|
||||
|
||||
### 0.0 `tai.New("")` → error, add `"local"` / `"127.0.0.1"` aliases
|
||||
### Process Registration (planned)
|
||||
|
||||
```go
|
||||
// tai/tai.go — parseAddr changes:
|
||||
// - addr == "" → return error ("use local")
|
||||
// - addr == "local" || addr == "127.0.0.1" → return "docker", "", "" (platform default socket)
|
||||
```
|
||||
sandbox.pool.Add sandbox.pool.Remove sandbox.pool.List
|
||||
sandbox.Create sandbox.Get sandbox.GetOrCreate
|
||||
sandbox.Remove sandbox.List
|
||||
sandbox.Start sandbox.Stop
|
||||
sandbox.Exec sandbox.Stream sandbox.Attach
|
||||
sandbox.ReadFile sandbox.WriteFile sandbox.ListDir
|
||||
sandbox.RemoveFile sandbox.MkdirAll
|
||||
sandbox.VNC sandbox.Proxy
|
||||
sandbox.EnsureImage sandbox.ImageExists sandbox.PullImage
|
||||
|
||||
workspace.Create workspace.Get workspace.List
|
||||
workspace.Update workspace.Delete
|
||||
workspace.ReadFile workspace.WriteFile workspace.ListDir
|
||||
workspace.Remove workspace.FS
|
||||
workspace.Nodes
|
||||
```
|
||||
|
||||
All callers must use explicit addresses. `"local"` means platform-default Docker daemon.
|
||||
### JSAPI (planned)
|
||||
|
||||
### 0.1 Add `Labels` and `User` to `CreateOptions`
|
||||
```javascript
|
||||
// Sandbox
|
||||
var sb = Sandbox("my-workspace", {
|
||||
image: "yaoapp/workspace:latest",
|
||||
owner: "user-123"
|
||||
})
|
||||
sb.Exec(["go", "build", "./..."])
|
||||
sb.ReadFile("src/main.go")
|
||||
sb.WriteFile("src/main.go", "package main\n...")
|
||||
sb.Stream(["npm", "run", "dev"], function(chunk) { ... })
|
||||
var conn = sb.Attach(3000, { protocol: "ws", path: "/ws" })
|
||||
sb.Info()
|
||||
sb.Stop()
|
||||
sb.Start()
|
||||
sb.Remove()
|
||||
|
||||
```go
|
||||
// sandbox.go — add two fields to existing struct
|
||||
type CreateOptions struct {
|
||||
// ... existing fields ...
|
||||
Labels map[string]string
|
||||
User string
|
||||
}
|
||||
// Workspace
|
||||
var ws = Workspace("my-workspace")
|
||||
ws.ReadFile("src/main.go")
|
||||
ws.WriteFile("src/main.go", "package main\n...")
|
||||
ws.ListDir("src/")
|
||||
ws.Remove("tmp.txt")
|
||||
```
|
||||
|
||||
### 0.2 Wire into Docker create
|
||||
|
||||
```go
|
||||
// docker_core.go — in create(), after building cfg:
|
||||
cfg.Labels = opts.Labels
|
||||
if opts.User != "" {
|
||||
cfg.User = opts.User
|
||||
}
|
||||
```
|
||||
|
||||
### 0.3 Wire into K8s create
|
||||
|
||||
```go
|
||||
// k8s.go — in Create(), set pod labels:
|
||||
pod.ObjectMeta.Labels = mergeLabels(pod.ObjectMeta.Labels, opts.Labels)
|
||||
|
||||
// For User, parse and set SecurityContext.RunAsUser
|
||||
```
|
||||
|
||||
### 0.4 Add `Labels` to `ContainerInfo`
|
||||
|
||||
```go
|
||||
// sandbox.go
|
||||
type ContainerInfo struct {
|
||||
// ... existing fields ...
|
||||
Labels map[string]string
|
||||
}
|
||||
```
|
||||
|
||||
### 0.5 Populate Labels in Docker list/inspect
|
||||
|
||||
```go
|
||||
// docker_core.go — in list():
|
||||
ci.Labels = c.Labels
|
||||
|
||||
// docker_core.go — in inspect():
|
||||
ci.Labels = info.Config.Labels
|
||||
```
|
||||
|
||||
### 0.6 Populate Labels in K8s list
|
||||
|
||||
```go
|
||||
// k8s.go — in List():
|
||||
ci.Labels = pod.Labels
|
||||
```
|
||||
|
||||
### 0.7 Tests
|
||||
|
||||
- `TestCreateWithLabels` — create container with labels, list with label filter, verify match
|
||||
- `TestCreateWithUser` — create container with user, exec `whoami`, verify
|
||||
- `TestListLabels` — create 2 containers with different labels, list with filter, verify count
|
||||
|
||||
**Estimated: ~20 lines code + ~60 lines tests**
|
||||
|
||||
---
|
||||
|
||||
## Step 1: `tai/sandbox` — ExecStream
|
||||
## Phase 3: Agent Integration — PENDING
|
||||
|
||||
**Files:** `tai/sandbox/sandbox.go`, `tai/sandbox/docker_core.go`, `tai/sandbox/k8s.go`
|
||||
| Task | Detail |
|
||||
|------|--------|
|
||||
| Agent creates Box via `sandbox.M().GetOrCreate()` | Replace `infraSandbox.Manager` |
|
||||
| Agent uses `Box.Workspace()` for file I/O | Replace Docker Copy/bind mount reads |
|
||||
| Agent uses `Box.Exec()` for commands | Replace Docker exec |
|
||||
| Agent uses `Box.VNC()` / `Box.Proxy()` | Replace vncproxy |
|
||||
| Agent injects `Box` as `SandboxExecutor` | `ctx.sandbox` JSAPI unchanged for hooks |
|
||||
|
||||
### 1.1 Add to Sandbox interface
|
||||
---
|
||||
|
||||
```go
|
||||
// sandbox.go
|
||||
type ExecStream struct {
|
||||
Stdout io.ReadCloser
|
||||
Stderr io.ReadCloser
|
||||
Stdin io.WriteCloser
|
||||
Wait func() (int, error)
|
||||
Cancel func()
|
||||
}
|
||||
## Phase 4: Cutover — PENDING
|
||||
|
||||
// Add to Sandbox interface:
|
||||
ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecStream, error)
|
||||
| Task | Detail |
|
||||
|------|--------|
|
||||
| Move `sandbox/v2` → `sandbox` | Rename package |
|
||||
| Delete old sandbox code | manager.go, ipc/, bridge/, vncproxy/, docker/ |
|
||||
| Delete `DESIGN-REMOTE.md` | Superseded by tai.Client |
|
||||
| Update `cmd/start.go` | Use new init path |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Container CMD
|
||||
|
||||
All V2 containers use a SIGTERM-aware sleep as PID 1:
|
||||
|
||||
```bash
|
||||
sh -c "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done"
|
||||
```
|
||||
|
||||
### 1.2 Docker implementation
|
||||
This ensures:
|
||||
- Container stays alive indefinitely (no hardcoded `sleep infinity`)
|
||||
- Exits immediately on SIGTERM (no 2s wait)
|
||||
- Works on both Docker and K8s
|
||||
|
||||
### Container Labels
|
||||
|
||||
Manager injects these labels at creation time:
|
||||
|
||||
```
|
||||
managed-by=yao-sandbox
|
||||
sandbox-id=<id>
|
||||
sandbox-owner=<owner>
|
||||
sandbox-pool=<pool>
|
||||
sandbox-policy=<policy>
|
||||
workspace-id=<workspace-id> (if WorkspaceID set)
|
||||
```
|
||||
|
||||
Used by `Manager.Start()` to discover and recover existing containers after restart.
|
||||
|
||||
### Workspace Bind Mount
|
||||
|
||||
When `CreateOptions.WorkspaceID` is set:
|
||||
|
||||
```
|
||||
1. NodeForWorkspace(wsID) → node name
|
||||
2. Force pool = node name
|
||||
3. MountPath(wsID) → hostDir
|
||||
4. Bind: hostDir:/workspace:rw
|
||||
```
|
||||
|
||||
### Multi-Mode Testing
|
||||
|
||||
`testPools()` returns all available pool configurations:
|
||||
|
||||
```go
|
||||
// docker_core.go — new method
|
||||
func (d *dockerCore) execStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecStream, error) {
|
||||
execCfg := container.ExecOptions{
|
||||
Cmd: cmd,
|
||||
WorkingDir: opts.WorkDir,
|
||||
Env: envSlice(opts.Env),
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
AttachStdin: true,
|
||||
func testPools() []poolConfig {
|
||||
pools := []poolConfig{{Name: "local", Addr: testLocalAddr()}}
|
||||
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
|
||||
pools = append(pools, poolConfig{Name: "remote", Addr: addr})
|
||||
}
|
||||
execResp, err := d.cli.ContainerExecCreate(ctx, id, execCfg)
|
||||
// ...
|
||||
resp, err := d.cli.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{})
|
||||
// ...
|
||||
// Use io.Pipe + stdcopy.StdCopy in a goroutine to demux stdout/stderr
|
||||
// Wait: poll ContainerExecInspect until Running=false
|
||||
// Cancel: context cancel → close resp.Conn
|
||||
}
|
||||
```
|
||||
|
||||
Key: `ContainerExecAttach` returns `HijackedResponse` with multiplexed stream. Use `stdcopy.StdCopy` in a goroutine writing to `io.Pipe` pairs for stdout/stderr separation.
|
||||
|
||||
### 1.3 K8s implementation
|
||||
|
||||
```go
|
||||
// k8s.go — new method
|
||||
func (s *k8sSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecStream, error) {
|
||||
// remotecommand.NewSPDYExecutor
|
||||
// StreamWithContext using io.Pipe for stdin/stdout/stderr
|
||||
// Wait: executor returns when process exits
|
||||
// Cancel: cancel the context
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 Tests
|
||||
|
||||
- `TestExecStream_ShortCommand` — `echo hello`, read stdout, verify Wait returns 0
|
||||
- `TestExecStream_LongRunning` — `sleep 10`, Cancel after 1s, verify cleanup
|
||||
- `TestExecStream_Stdin` — `cat`, write to stdin, read from stdout, verify echo
|
||||
- `TestExecStream_ExitCode` — `exit 42`, verify Wait returns 42
|
||||
|
||||
**Estimated: ~80 lines code + ~100 lines tests**
|
||||
|
||||
---
|
||||
|
||||
## Step 2: `tai/proxy` — Connect
|
||||
|
||||
**Files:** `tai/proxy/proxy.go`, `tai/proxy/connect.go` (new)
|
||||
|
||||
### 2.1 Add to Proxy interface
|
||||
|
||||
```go
|
||||
// proxy.go — extend interface
|
||||
type Proxy interface {
|
||||
URL(ctx context.Context, containerID string, port int, path string) (string, error)
|
||||
Connect(ctx context.Context, containerID string, port int, opts ConnectOptions) (*Connection, error)
|
||||
Healthz(ctx context.Context) error
|
||||
}
|
||||
|
||||
type ConnectOptions struct {
|
||||
Protocol string // "ws", "sse", "tcp"; default "ws"
|
||||
Path string
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
Read func() ([]byte, error)
|
||||
Write func(data []byte) error
|
||||
Events <-chan []byte
|
||||
URL string
|
||||
Close func() error
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Implementation — `connect.go`
|
||||
|
||||
Local: resolve URL via `URL()`, then dial directly.
|
||||
Remote: resolve URL via `URL()` (points to Tai HTTP proxy), then dial.
|
||||
|
||||
Both modes use the same dialing logic after URL resolution:
|
||||
- **WebSocket**: `gorilla/websocket.Dialer.DialContext`
|
||||
- **SSE**: `http.Get` + chunked body reader, parse `data:` lines into Events channel
|
||||
- **TCP**: `net.Dial`
|
||||
|
||||
### 2.3 Tests
|
||||
|
||||
- `TestConnectWebSocket` — start a WS echo server in container, connect, send/receive
|
||||
- `TestConnectSSE` — start an SSE server in container, connect, verify events arrive
|
||||
- Skip TCP for now (less common use case)
|
||||
|
||||
**Estimated: ~120 lines code + ~80 lines tests**
|
||||
|
||||
---
|
||||
|
||||
## Step 3: `yao/grpc` — Heartbeat RPC
|
||||
|
||||
**Files:** `grpc/pb/yao.proto`, `grpc/sandbox/heartbeat.go` (new), `grpc/api/api.go`
|
||||
|
||||
### 3.1 Proto
|
||||
|
||||
```protobuf
|
||||
// Add to service Yao:
|
||||
rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
|
||||
|
||||
message HeartbeatRequest {
|
||||
string sandbox_id = 1;
|
||||
bool active = 2;
|
||||
int32 process_count = 3;
|
||||
}
|
||||
message HeartbeatResponse {}
|
||||
```
|
||||
|
||||
Regenerate: `protoc --go_out=. --go-grpc_out=. grpc/pb/yao.proto`
|
||||
|
||||
### 3.2 Handler
|
||||
|
||||
```go
|
||||
// grpc/sandbox/heartbeat.go
|
||||
func (s *Server) Heartbeat(ctx context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) {
|
||||
err := sandbox.M().Heartbeat(req.SandboxId, req.Active, int(req.ProcessCount))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.NotFound, "sandbox %s: %v", req.SandboxId, err)
|
||||
if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" {
|
||||
// ... K8s pool with kubeconfig, namespace, ports
|
||||
pools = append(pools, poolConfig{Name: "k8s", ...})
|
||||
}
|
||||
return &pb.HeartbeatResponse{}, nil
|
||||
return pools
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Register in server
|
||||
|
||||
Wire into `grpc/api/api.go` server registration (same pattern as Healthz).
|
||||
|
||||
### 3.4 ACL virtual endpoint
|
||||
|
||||
Add to `grpc/auth/endpoints.go`:
|
||||
Every test iterates over all available pools:
|
||||
|
||||
```go
|
||||
// Heartbeat → POST /grpc/heartbeat (reuse existing container token scope)
|
||||
```
|
||||
|
||||
### 3.5 Tests
|
||||
|
||||
- `TestHeartbeat_Success` — create sandbox, send heartbeat, verify no error
|
||||
- `TestHeartbeat_NotFound` — send heartbeat with unknown sandbox_id, verify NotFound
|
||||
- `TestHeartbeat_Auth` — verify token auth works (reuse testutils)
|
||||
|
||||
**Estimated: ~40 lines code + ~50 lines tests**
|
||||
|
||||
---
|
||||
|
||||
## Step 4: `tai/grpc` — yao-grpc heartbeat goroutine
|
||||
|
||||
**Files:** `tai/grpc/cmd/main.go` (or equivalent entry point), `tai/grpc/heartbeat.go` (new)
|
||||
|
||||
### 4.1 Heartbeat loop
|
||||
|
||||
```go
|
||||
// tai/grpc/heartbeat.go
|
||||
func heartbeatLoop(ctx context.Context, client *grpc.Client, sandboxID string) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
count := countUserProcesses()
|
||||
if count > 0 {
|
||||
client.Heartbeat(ctx, sandboxID, true, int32(count))
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
func TestSomething(t *testing.T) {
|
||||
for _, pc := range testPools() {
|
||||
t.Run(pc.Name, func(t *testing.T) {
|
||||
m := setupManagerForPool(t, pc)
|
||||
// test logic
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func countUserProcesses() int {
|
||||
// exec: ps -eo comm --no-headers
|
||||
// filter out: sleep, init, yao-grpc, sh, bash (if parent is sleep)
|
||||
// return count
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Wire into main
|
||||
### Benchmark Helpers
|
||||
|
||||
```go
|
||||
// In main() or NewFromEnv(), after client is connected:
|
||||
sandboxID := os.Getenv("YAO_SANDBOX_ID")
|
||||
if sandboxID != "" {
|
||||
go heartbeatLoop(ctx, client, sandboxID)
|
||||
}
|
||||
func setupManagerForBench(b *testing.B, pc poolConfig) *sandbox.Manager
|
||||
func ensureTestImageBench(b *testing.B, m *sandbox.Manager, pool string)
|
||||
func createBoxForBench(b *testing.B, m *sandbox.Manager) *sandbox.Box
|
||||
```
|
||||
|
||||
Note: `heartbeatLoop` calls `client.Heartbeat()` (new public method on `tai/grpc.Client`), not `client.svc` (private).
|
||||
|
||||
### 4.3 Tests
|
||||
|
||||
- `TestCountUserProcesses` — unit test for process filtering logic
|
||||
- `TestHeartbeatLoop_SendsWhenActive` — mock gRPC client, start background process, verify heartbeat sent
|
||||
- `TestHeartbeatLoop_SilentWhenIdle` — no user processes, verify no RPC calls
|
||||
|
||||
**Estimated: ~40 lines code + ~40 lines tests**
|
||||
K8s-specific behavior:
|
||||
- `BenchmarkStopStart`: skipped (K8s Stop deletes Pod)
|
||||
- Create/Lifecycle benchmarks: 120s timeout for K8s Pod scheduling
|
||||
|
||||
---
|
||||
|
||||
## Step 4.5: Docker — V2 Test Images
|
||||
|
||||
**Depends on:** Steps 1–4 (tai SDK ExecStream, proxy Connect, yao-grpc heartbeat)
|
||||
|
||||
Sandbox V2 tests need containers that have `yao-grpc` (with heartbeat) pre-installed. Also need a test-specific image with Nginx for Attach WS/SSE testing.
|
||||
|
||||
**Files:** `sandbox/docker/v2/` (new directory)
|
||||
|
||||
### Image hierarchy
|
||||
|
||||
```
|
||||
sandbox-v2-base ← base + yao-grpc + claude-proxy
|
||||
sandbox-v2-test ← v2-base + nginx (WS echo + SSE endpoint)
|
||||
```
|
||||
|
||||
### 4.5.1 `sandbox/docker/v2/Dockerfile.base`
|
||||
|
||||
```dockerfile
|
||||
FROM yaoapp/sandbox-base:latest
|
||||
|
||||
# Replace yao-bridge with yao-grpc
|
||||
ARG TARGETARCH
|
||||
COPY yao-grpc-${TARGETARCH} /usr/local/bin/yao-grpc
|
||||
RUN chmod +x /usr/local/bin/yao-grpc
|
||||
|
||||
# Claude API proxy (OpenAPI-compatible)
|
||||
COPY claude-proxy-${TARGETARCH} /usr/local/bin/claude-proxy
|
||||
RUN chmod +x /usr/local/bin/claude-proxy
|
||||
|
||||
# yao-grpc auto-start: if YAO_SANDBOX_ID is set, start heartbeat + serve
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
WORKDIR /workspace
|
||||
USER sandbox
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["sleep", "infinity"]
|
||||
```
|
||||
|
||||
### 4.5.2 `sandbox/docker/v2/entrypoint.sh`
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Start yao-grpc in background if sandbox env vars are present
|
||||
if [ -n "$YAO_SANDBOX_ID" ] && [ -n "$YAO_GRPC_ADDR" ]; then
|
||||
yao-grpc serve &
|
||||
fi
|
||||
|
||||
# Start claude-proxy if config exists
|
||||
if [ -n "$CLAUDE_PROXY_BACKEND" ] || [ -f /workspace/.claude-proxy.json ]; then
|
||||
claude-proxy &
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
```
|
||||
|
||||
### 4.5.3 `sandbox/docker/v2/Dockerfile.test`
|
||||
|
||||
For unit tests — adds Nginx with a simple WS echo server and SSE endpoint.
|
||||
|
||||
```dockerfile
|
||||
FROM yaoapp/sandbox-v2-base:latest
|
||||
|
||||
USER root
|
||||
|
||||
# Nginx + test services
|
||||
RUN apt-get update && apt-get install -y nginx python3 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# WS echo server (Python, ~15 lines)
|
||||
COPY ws-echo.py /opt/test/ws-echo.py
|
||||
|
||||
# SSE endpoint (Python, ~15 lines)
|
||||
COPY sse-server.py /opt/test/sse-server.py
|
||||
|
||||
# Nginx config — proxy WS on :3000, SSE on :3001
|
||||
COPY nginx-test.conf /etc/nginx/sites-available/default
|
||||
|
||||
# Test entrypoint — start nginx + test services + original entrypoint
|
||||
COPY test-entrypoint.sh /usr/local/bin/test-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/test-entrypoint.sh
|
||||
|
||||
USER sandbox
|
||||
WORKDIR /workspace
|
||||
ENTRYPOINT ["/usr/local/bin/test-entrypoint.sh"]
|
||||
CMD ["sleep", "infinity"]
|
||||
```
|
||||
|
||||
### 4.5.4 Test services
|
||||
|
||||
**`ws-echo.py`** — WebSocket echo on port 3000:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import asyncio, websockets
|
||||
async def echo(ws):
|
||||
async for msg in ws:
|
||||
await ws.send(msg)
|
||||
asyncio.run(websockets.serve(echo, "0.0.0.0", 3000))
|
||||
```
|
||||
|
||||
**`sse-server.py`** — SSE endpoint on port 3001:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
import time
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
for i in range(5):
|
||||
self.wfile.write(f"data: event-{i}\n\n".encode())
|
||||
self.wfile.flush()
|
||||
time.sleep(0.1)
|
||||
HTTPServer(("0.0.0.0", 3001), Handler).serve_forever()
|
||||
```
|
||||
|
||||
**`test-entrypoint.sh`**:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
python3 /opt/test/ws-echo.py &
|
||||
python3 /opt/test/sse-server.py &
|
||||
exec /usr/local/bin/entrypoint.sh "$@"
|
||||
```
|
||||
|
||||
### 4.5.5 Build script update
|
||||
|
||||
Add `v2` and `v2-test` targets to `sandbox/docker/build.sh`:
|
||||
|
||||
```bash
|
||||
v2)
|
||||
echo "=== Building V2 images ==="
|
||||
# Build yao-grpc binary (replaces yao-bridge)
|
||||
cd "$SCRIPT_DIR/../../tai/grpc/cmd"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/v2/yao-grpc-amd64" .
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/v2/yao-grpc-arm64" .
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Build claude-proxy binary
|
||||
cd "$SCRIPT_DIR/../proxy/cmd/claude-proxy"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/v2/claude-proxy-amd64" .
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/v2/claude-proxy-arm64" .
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
build_multiarch "sandbox-v2-base" "v2/Dockerfile.base" "$PUSH"
|
||||
build_multiarch "sandbox-v2-test" "v2/Dockerfile.test" "$PUSH"
|
||||
;;
|
||||
```
|
||||
|
||||
### 4.5.6 Image usage
|
||||
|
||||
| Image | Purpose | Used by |
|
||||
|-------|---------|---------|
|
||||
| `sandbox-v2-base` | Production base for V2 sandboxes. Has `yao-grpc` (heartbeat) + `claude-proxy`. | `Manager.Create()` default image candidate |
|
||||
| `sandbox-v2-test` | Unit tests. Has WS echo + SSE server for Attach testing. | `SANDBOX_TEST_IMAGE` in CI and local dev |
|
||||
|
||||
### 4.5.7 Env update
|
||||
|
||||
```bash
|
||||
# env.local.sh — change test image to v2-test
|
||||
export SANDBOX_TEST_IMAGE="yaoapp/sandbox-v2-test:latest"
|
||||
```
|
||||
|
||||
**Estimated: ~5 files (Dockerfiles + scripts + test services), ~100 lines**
|
||||
|
||||
---
|
||||
|
||||
## Step 5: `sandbox/v2` — Core Module
|
||||
|
||||
**Files:** all in `sandbox/v2/`
|
||||
|
||||
### 5.1 `errors.go`
|
||||
|
||||
```go
|
||||
var (
|
||||
ErrNotAvailable = errors.New("sandbox: not available (no pools configured)")
|
||||
ErrNotFound = errors.New("sandbox: not found")
|
||||
ErrLimitExceeded = errors.New("sandbox: limit exceeded")
|
||||
ErrPoolNotFound = errors.New("sandbox: pool not found")
|
||||
ErrPoolInUse = errors.New("sandbox: pool has running boxes")
|
||||
)
|
||||
```
|
||||
|
||||
### 5.2 `types.go`
|
||||
|
||||
All type definitions from DESIGN.md:
|
||||
- `LifecyclePolicy`, `Pool`, `PoolInfo`, `PortMapping`
|
||||
- `CreateOptions`, `ListOptions`
|
||||
- `ExecOption`, `ExecResult`, `ExecStream`
|
||||
- `AttachOption`, `ServiceConn`, `ConnectOptions`
|
||||
- `BoxInfo`
|
||||
|
||||
### 5.3 `config.go`
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Pool []Pool
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 `sandbox.go` — singleton
|
||||
|
||||
```go
|
||||
var mgr *Manager
|
||||
|
||||
func Init(cfg Config) error {
|
||||
m, err := newManager(cfg)
|
||||
if err != nil { return err }
|
||||
mgr = m
|
||||
return nil
|
||||
}
|
||||
|
||||
func M() *Manager {
|
||||
if mgr == nil { panic("sandbox.Init not called") }
|
||||
return mgr
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 `manager.go`
|
||||
|
||||
Core implementation. Key methods:
|
||||
|
||||
| Method | Logic |
|
||||
|--------|-------|
|
||||
| `newManager(cfg)` | Parse pool defs, set default pool |
|
||||
| `Start(ctx)` | For each pool: connect, list containers with `managed-by=yao-sandbox`, rebuild boxes map, start cleanupLoop |
|
||||
| `Create(ctx, opts)` | Validate → check limits → resolve pool → lazy-connect tai.Client → create OAuth tokens → build tai.CreateOptions (merge env, labels, field mapping) → tai.Create → tai.Start → wrap Box → register |
|
||||
| `Get(ctx, id)` | Lookup boxes map |
|
||||
| `GetOrCreate(ctx, opts)` | Get by ID, if not found → Create |
|
||||
| `List(ctx, opts)` | Filter boxes by owner/pool/labels |
|
||||
| `Remove(ctx, id)` | Lookup box → tai.Stop → tai.Remove → revoke OAuth token → delete from map |
|
||||
| `Cleanup(ctx)` | Range boxes, apply policy-based idle/lifetime rules |
|
||||
| `Close()` | Cancel cleanup loop, close all tai.Clients |
|
||||
| `Heartbeat(id, active, count)` | Lookup box → update lastHeartbeat + processCount atomics |
|
||||
| `AddPool(ctx, p)` | Validate name unique → append to poolDefs |
|
||||
| `RemovePool(ctx, name, force)` | Check no boxes (or force-remove them) → remove from poolDefs → close tai.Client if connected |
|
||||
| `Pools()` | Return PoolInfo slice |
|
||||
|
||||
Internal helpers:
|
||||
- `getPool(name)` — lazy-connect tai.Client from poolDefs
|
||||
- `buildTaiCreateOptions(opts, pool)` — field mapping + env injection + label injection
|
||||
- `recoverBoxes(ctx, pool, client)` — list + parse labels + rebuild Box structs
|
||||
|
||||
### 5.6 `box.go`
|
||||
|
||||
```go
|
||||
type Box struct { /* fields from DESIGN.md */ }
|
||||
|
||||
func (b *Box) Exec(ctx, cmd, opts) // b.touch() → tai.Sandbox().Exec(b.containerID, ...)
|
||||
func (b *Box) Stream(ctx, cmd, opts) // b.touch() → tai.Sandbox().ExecStream(b.containerID, ...)
|
||||
func (b *Box) Attach(ctx, port, opts) // b.touch() → tai.Proxy().Connect(b.containerID, port, ...)
|
||||
func (b *Box) Workspace() // lazy-init: tai.Client.Workspace(b.id)
|
||||
func (b *Box) VNC(ctx) // b.touch() → tai.VNC().URL(b.containerID)
|
||||
func (b *Box) Proxy(ctx, port, path) // b.touch() → tai.Proxy().URL(b.containerID, port, path)
|
||||
func (b *Box) Start(ctx) // tai.Sandbox().Start(b.containerID)
|
||||
func (b *Box) Stop(ctx) // tai.Sandbox().Stop(b.containerID, 10s)
|
||||
func (b *Box) Remove(ctx) // b.manager.Remove(ctx, b.id)
|
||||
func (b *Box) Info(ctx) // tai.Sandbox().Inspect + merge with box metadata
|
||||
|
||||
func (b *Box) touch() // b.lastCall.Store(time.Now().UnixMilli())
|
||||
func (b *Box) lastActiveTime() Time // max(lastCall, lastHeartbeat)
|
||||
func (b *Box) idleTimeout() Duration // box-level override or pool default
|
||||
func (b *Box) maxLifetime() Duration // pool default
|
||||
```
|
||||
|
||||
### 5.7 `grpc.go` — OAuth token injection
|
||||
|
||||
```go
|
||||
func createContainerTokens(sandboxID, owner string) (access, refresh string, err error)
|
||||
func revokeContainerTokens(refresh string) error
|
||||
func buildGRPCEnv(pool *Pool, sandboxID, access, refresh string) map[string]string
|
||||
```
|
||||
|
||||
Uses `openapi/oauth` to create token pairs. Local mode: `YAO_GRPC_ADDR=127.0.0.1:<port>`. Remote mode: adds `YAO_GRPC_TAI=enable` + `YAO_GRPC_UPSTREAM`.
|
||||
|
||||
**Estimated: ~600 lines code total**
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Tests
|
||||
|
||||
### 6.1 Test environment
|
||||
|
||||
Two pools configured via env vars (reuse existing CI infrastructure):
|
||||
|
||||
```
|
||||
# Local pool — direct Docker
|
||||
SANDBOX_TEST_LOCAL_ADDR=local (default Docker daemon)
|
||||
|
||||
# Remote pool — via Tai container (same as tai-test job)
|
||||
SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1 (uses TAI_TEST_* ports)
|
||||
```
|
||||
|
||||
Skip tests when Docker/Tai unavailable: `t.Skipf`.
|
||||
|
||||
### 6.2 Test files
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `sandbox_test.go` | `Init()`, `M()`, singleton behavior |
|
||||
| `manager_test.go` | Create, Get, GetOrCreate, List, Remove, pool management |
|
||||
| `manager_lifecycle_test.go` | Start (container discovery), Cleanup, idle tracking |
|
||||
| `box_test.go` | Exec, Stream, Workspace (ReadFile/WriteFile/MkdirAll), Proxy, VNC, lifecycle |
|
||||
| `box_attach_test.go` | Attach with WS/SSE (requires service in container) |
|
||||
| `grpc_test.go` | Token creation/revocation, env var building |
|
||||
|
||||
### 6.3 Key test scenarios
|
||||
|
||||
| Test | What it verifies |
|
||||
|------|-----------------|
|
||||
| `TestCreateAndExec` | Create box → exec `echo hello` → verify stdout → remove |
|
||||
| `TestCreateWithLabels` | Create → inspect labels → list with label filter |
|
||||
| `TestWorkspace` | Create → WriteFile → ReadFile → verify content match |
|
||||
| `TestIdleCleanup` | Create with Session + 1s idle timeout → wait → verify removed |
|
||||
| `TestStartRecovery` | Create → restart Manager → Start → verify box recovered from labels |
|
||||
| `TestPoolLimits` | Set MaxTotal=1 → create 1 → create 2nd → verify ErrLimitExceeded |
|
||||
| `TestHeartbeatUpdates` | Create → call Heartbeat → verify lastActive updated |
|
||||
| `TestStream` | Create → stream `sh -c "echo a; sleep 0.1; echo b"` → verify chunks arrive |
|
||||
| `TestMultiPool` | Create on local → create on remote → verify both work |
|
||||
|
||||
### 6.4 CI integration
|
||||
|
||||
Add `sandbox-v2-test` job to `unit-test.yml` (same pattern as existing `sandbox-test` + `tai-test`):
|
||||
|
||||
```yaml
|
||||
sandbox-v2-test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
# MongoDB (for Yao runtime)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- name: Start Tai container
|
||||
run: |
|
||||
docker run -d --name tai \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 2375:2375 -p 9100:9100 -p 8080:8080 \
|
||||
yaoapp/tai:latest
|
||||
- name: Build V2 test image
|
||||
run: |
|
||||
cd sandbox/docker
|
||||
bash build.sh v2
|
||||
- name: Run tests
|
||||
env:
|
||||
SANDBOX_TEST_LOCAL_ADDR: "local"
|
||||
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1"
|
||||
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
|
||||
TAI_TEST_HOST: "127.0.0.1"
|
||||
run: go test -v -count=1 ./sandbox/v2/...
|
||||
```
|
||||
|
||||
**Estimated: ~400 lines tests**
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Step | Package | Lines (code) | Lines (test) | Depends on |
|
||||
|------|---------|-------------|-------------|------------|
|
||||
| 0 | `tai/sandbox` | ~20 | ~60 | — |
|
||||
| 1 | `tai/sandbox` | ~80 | ~100 | — |
|
||||
| 2 | `tai/proxy` | ~120 | ~80 | — |
|
||||
| 3 | `yao/grpc` | ~40 | ~50 | — |
|
||||
| 4 | `tai/grpc` | ~40 | ~40 | Step 3 |
|
||||
| 4.5 | `sandbox/docker/v2` | ~100 | — | Steps 1–4 |
|
||||
| 5 | `sandbox/v2` | ~600 | — | Steps 0–4 |
|
||||
| 6 | `sandbox/v2` | — | ~400 | Steps 5 + 4.5 |
|
||||
| **Total** | | **~1000** | **~730** | |
|
||||
|
||||
Steps 0–3 can start in parallel. Step 4 needs Step 3's proto. Step 5 needs all prerequisites done. Step 6 runs after Step 5.
|
||||
## File Inventory
|
||||
|
||||
### sandbox/v2 (7 source + 10 test = 17 files)
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `sandbox.go` | ~25 | Global singleton |
|
||||
| `manager.go` | ~620 | Manager implementation |
|
||||
| `box.go` | ~230 | Box implementation |
|
||||
| `types.go` | ~170 | Type definitions |
|
||||
| `config.go` | ~5 | Config struct |
|
||||
| `errors.go` | ~10 | Error definitions |
|
||||
| `grpc.go` | ~55 | Token/env injection |
|
||||
| `testutils_test.go` | ~130 | Test helpers |
|
||||
| `sandbox_test.go` | ~30 | Singleton tests |
|
||||
| `manager_test.go` | ~250 | CRUD tests |
|
||||
| `manager_lifecycle_test.go` | ~120 | Lifecycle tests |
|
||||
| `box_test.go` | ~200 | Box tests |
|
||||
| `box_attach_test.go` | ~260 | Attach/VNC tests |
|
||||
| `box_workspace_test.go` | ~285 | Workspace tests |
|
||||
| `box_image_test.go` | ~120 | Image tests |
|
||||
| `grpc_test.go` | ~80 | Token tests |
|
||||
| `bench_test.go` | ~230 | Benchmarks |
|
||||
|
||||
### workspace (3 source + 4 test = 7 files)
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `workspace.go` | ~80 | Types + metadata |
|
||||
| `manager.go` | ~320 | Manager implementation |
|
||||
| `errors.go` | ~10 | Error definitions |
|
||||
| `testutils_test.go` | ~90 | Test helpers |
|
||||
| `workspace_test.go` | ~325 | CRUD tests |
|
||||
| `fileio_test.go` | ~235 | File I/O tests |
|
||||
| `bench_test.go` | ~150 | Benchmarks |
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue