Enhance Sandbox V2 testing and integration with Workspace

- Introduce new unit test targets for Workspace and Sandbox V2 integration in the Makefile, ensuring comprehensive testing of the new features.
- Update CI workflows to support MongoDB service for Sandbox V2 tests and improve Docker image handling.
- Implement Workspace as a first-class entity, allowing for persistent storage decoupled from container lifecycles.
- Enhance the Box struct to manage workspace IDs and update related methods for improved functionality.
- Refactor tests to accommodate new Workspace features, ensuring robust testing of the integration.

These changes significantly improve the testing framework and functionality of the Sandbox V2, enhancing the overall architecture and user experience.
This commit is contained in:
Max 2026-03-05 21:00:50 +08:00
parent 2bd2a37c4b
commit d25bfefee0
34 changed files with 3471 additions and 560 deletions

View file

@ -924,10 +924,20 @@ jobs:
});
# =============================================================================
# Sandbox V2 Tests (requires Docker + Tai for dual-mode)
# Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d)
# =============================================================================
SandboxV2Test:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:6.0
ports:
- 27017:27017
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: 123456
MONGO_INITDB_DATABASE: test
strategy:
matrix:
go: ["1.25"]
@ -975,7 +985,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: '🤖 Sandbox V2 Tests running (dual-mode: local + remote)...'
body: '🤖 Sandbox V2 Tests running (tai + sandbox-v2 + workspace)...'
});
- name: Checkout Kun
@ -1038,48 +1048,107 @@ jobs:
with:
ref: ${{ env.HEAD }}
- name: Setup Apple Private Key
run: |
mkdir -p ../app/openapi/certs/apple
echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
- name: Setup Go ${{ matrix.go }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Start Redis
run: docker run --name redis --publish 6379:6379 --detach redis:6
- name: Setup Go Tools
run: make tools
- name: Setup ENV (SQLite)
run: |
mkdir -p ${{ github.WORKSPACE }}/../app/db
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
- name: Pull Test Images
run: |
docker pull yaoapp/sandbox-v2-test:latest || true
docker pull yaoapp/tai:latest
docker pull alpine:latest
- name: Start Tai Server (Docker proxy for remote mode)
- name: Install k3d
run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
- name: Create k3d cluster
run: |
k3d cluster create tai-test --no-lb --wait --api-port 16443
kubectl wait --for=condition=Ready node --all --timeout=60s
k3d image import alpine:latest -c tai-test
- name: Start Tai (Docker + K8s proxy)
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}"
docker run -d --name tai \
--network k3d-tai-test \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
yaoapp/tai:latest
TAI_HTTP_READY=false
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
echo "Tai HTTP is ready"
TAI_HTTP_READY=true
break
fi
echo "Waiting for Tai HTTP... ($i)"
sleep 1
done
if [ "$TAI_HTTP_READY" != "true" ]; then
echo "::error::Tai HTTP failed to become ready within 30s"
docker logs tai 2>&1 || true
docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true
exit 1
fi
TAI_GRPC_READY=false
for i in $(seq 1 15); do
if nc -z 127.0.0.1 9100 2>/dev/null; then
echo "Tai gRPC is ready"
TAI_GRPC_READY=true
break
fi
echo "Waiting for Tai gRPC... ($i)"
sleep 1
done
if [ "$TAI_GRPC_READY" != "true" ]; then
echo "::error::Tai gRPC failed to become ready within 15s"
docker logs tai 2>&1 || true
exit 1
fi
- name: "Run Sandbox V2 Tests (dual-mode: local + remote)"
- 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:
SANDBOX_TEST_IMAGE: yaoapp/sandbox-v2-test:latest
TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
TAI_TEST_K8S_HOST: "127.0.0.1"
TAI_TEST_K8S_PORT: "6443"
TAI_TEST_GRPC_PORT: "9100"
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
TAI_TEST_HOST_IP: "172.17.0.1"
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
run: make unit-test-sandbox-v2
- name: Codecov Report
@ -1087,8 +1156,6 @@ jobs:
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: sandbox/v2/coverage.out
flags: sandbox-v2
fail_ci_if_error: false
- name: "Comment on PR - Sandbox V2 Tests Done"
@ -1102,7 +1169,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: '✅ Sandbox V2 Tests passed!'
body: '✅ Sandbox V2 Tests passed (tai + sandbox-v2 + workspace)!'
});
# =============================================================================
@ -1715,20 +1782,10 @@ jobs:
});
# =============================================================================
# Tai SDK Tests (requires Tai container with Docker socket mount)
# Benchmark: Sandbox V2 + Workspace (parallel with SandboxV2Test, non-blocking)
# =============================================================================
TaiTest:
BenchmarkSandboxV2:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:6.0
ports:
- 27017:27017
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: 123456
MONGO_INITDB_DATABASE: test
strategy:
matrix:
go: ["1.25"]
@ -1765,20 +1822,6 @@ jobs:
echo HEAD=$(cat SHA) >> $GITHUB_ENV
echo NR=$(cat NR) >> $GITHUB_ENV
- name: "Comment on PR"
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { NR } = process.env
var issue_number = NR;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: '🤖 Tai SDK Tests running...'
});
- name: Checkout Kun
uses: actions/checkout@v4
with:
@ -1861,105 +1904,46 @@ jobs:
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
- name: Pull Tai & Test Images
- name: Pull Test Images
run: |
docker pull yaoapp/sandbox-v2-test:latest || true
docker pull yaoapp/tai:latest
docker pull alpine:latest
- name: Install k3d
run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
- name: Create k3d cluster
- name: Start Tai (Docker proxy for benchmarks)
run: |
k3d cluster create tai-test --no-lb --wait --api-port 16443
kubectl wait --for=condition=Ready node --all --timeout=60s
k3d image import alpine:latest -c tai-test
- name: Start Tai (with Docker socket + K8s proxy)
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}"
docker run -d --name tai \
--network k3d-tai-test \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \
yaoapp/tai:latest
TAI_HTTP_READY=false
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
echo "Tai HTTP is ready"
TAI_HTTP_READY=true
break
fi
echo "Waiting for Tai HTTP... ($i)"
sleep 1
done
if [ "$TAI_HTTP_READY" != "true" ]; then
echo "::error::Tai HTTP failed to become ready within 30s"
echo "--- Tai container logs ---"
docker logs tai 2>&1 || true
echo "--- Tai container status ---"
docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true
exit 1
fi
TAI_GRPC_READY=false
for i in $(seq 1 15); do
if nc -z 127.0.0.1 9100 2>/dev/null; then
echo "Tai gRPC is ready"
TAI_GRPC_READY=true
break
fi
echo "Waiting for Tai gRPC... ($i)"
sleep 1
done
if [ "$TAI_GRPC_READY" != "true" ]; then
echo "::error::Tai gRPC failed to become ready within 15s"
echo "--- Tai container logs ---"
docker logs tai 2>&1 || true
exit 1
fi
- 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 Tai SDK Tests
- name: Run Benchmarks
env:
TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_GRPC: "127.0.0.1:9100"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
TAI_TEST_K8S_HOST: "127.0.0.1"
TAI_TEST_K8S_PORT: "6443"
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
TAI_TEST_GRPC_PORT: "9100"
TAI_TEST_HOST_IP: "172.17.0.1"
run: make unit-test-tai
- name: Codecov Report
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: "Comment on PR - Tai Tests Done"
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { NR } = process.env
var issue_number = NR;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: '✅ Tai SDK Tests passed!'
});
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
run: make benchmark-sandbox-v2
# =============================================================================
# gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed)

View file

@ -680,10 +680,20 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
# =============================================================================
# Sandbox V2 Tests (requires Docker + Tai for dual-mode)
# Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d)
# =============================================================================
sandbox-v2-test:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:6.0
ports:
- 27017:27017
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: 123456
MONGO_INITDB_DATABASE: test
strategy:
matrix:
go: ["1.25"]
@ -746,48 +756,107 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Apple Private Key
run: |
mkdir -p ../app/openapi/certs/apple
echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
- name: Setup Go ${{ matrix.go }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Start Redis
run: docker run --name redis --publish 6379:6379 --detach redis:6
- name: Setup Go Tools
run: make tools
- name: Setup ENV (SQLite)
run: |
mkdir -p ${{ github.WORKSPACE }}/../app/db
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
- name: Pull Test Images
run: |
docker pull yaoapp/sandbox-v2-test:latest || true
docker pull yaoapp/tai:latest
docker pull alpine:latest
- name: Start Tai Server (Docker proxy for remote mode)
- name: Install k3d
run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
- name: Create k3d cluster
run: |
k3d cluster create tai-test --no-lb --wait --api-port 16443
kubectl wait --for=condition=Ready node --all --timeout=60s
k3d image import alpine:latest -c tai-test
- name: Start Tai (Docker + K8s proxy)
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}"
docker run -d --name tai \
--network k3d-tai-test \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
yaoapp/tai:latest
TAI_HTTP_READY=false
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
echo "Tai HTTP is ready"
TAI_HTTP_READY=true
break
fi
echo "Waiting for Tai HTTP... ($i)"
sleep 1
done
if [ "$TAI_HTTP_READY" != "true" ]; then
echo "::error::Tai HTTP failed to become ready within 30s"
docker logs tai 2>&1 || true
docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true
exit 1
fi
TAI_GRPC_READY=false
for i in $(seq 1 15); do
if nc -z 127.0.0.1 9100 2>/dev/null; then
echo "Tai gRPC is ready"
TAI_GRPC_READY=true
break
fi
echo "Waiting for Tai gRPC... ($i)"
sleep 1
done
if [ "$TAI_GRPC_READY" != "true" ]; then
echo "::error::Tai gRPC failed to become ready within 15s"
docker logs tai 2>&1 || true
exit 1
fi
- name: "Run Sandbox V2 Tests (dual-mode: local + remote)"
- 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:
SANDBOX_TEST_IMAGE: yaoapp/sandbox-v2-test:latest
TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
TAI_TEST_K8S_HOST: "127.0.0.1"
TAI_TEST_K8S_PORT: "6443"
TAI_TEST_GRPC_PORT: "9100"
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
TAI_TEST_HOST_IP: "172.17.0.1"
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
run: make unit-test-sandbox-v2
- name: Codecov Report
@ -795,8 +864,6 @@ jobs:
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: sandbox/v2/coverage.out
flags: sandbox-v2
fail_ci_if_error: false
# =============================================================================
@ -1255,20 +1322,10 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
# =============================================================================
# Tai SDK Tests (requires Tai container with Docker socket mount)
# Benchmark: Sandbox V2 + Workspace (parallel with sandbox-v2-test)
# =============================================================================
tai-test:
benchmark-sandbox-v2:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:6.0
ports:
- 27017:27017
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: 123456
MONGO_INITDB_DATABASE: test
strategy:
matrix:
go: ["1.25"]
@ -1353,91 +1410,46 @@ jobs:
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
- name: Pull Tai & Test Images
- name: Pull Test Images
run: |
docker pull yaoapp/sandbox-v2-test:latest || true
docker pull yaoapp/tai:latest
docker pull alpine:latest
- name: Install k3d
run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
- name: Create k3d cluster
- name: Start Tai (Docker proxy for benchmarks)
run: |
k3d cluster create tai-test --no-lb --wait --api-port 16443
kubectl wait --for=condition=Ready node --all --timeout=60s
k3d image import alpine:latest -c tai-test
- name: Start Tai (with Docker socket + K8s proxy)
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}"
docker run -d --name tai \
--network k3d-tai-test \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \
yaoapp/tai:latest
TAI_HTTP_READY=false
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
echo "Tai HTTP is ready"
TAI_HTTP_READY=true
break
fi
echo "Waiting for Tai HTTP... ($i)"
sleep 1
done
if [ "$TAI_HTTP_READY" != "true" ]; then
echo "::error::Tai HTTP failed to become ready within 30s"
echo "--- Tai container logs ---"
docker logs tai 2>&1 || true
echo "--- Tai container status ---"
docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true
exit 1
fi
TAI_GRPC_READY=false
for i in $(seq 1 15); do
if nc -z 127.0.0.1 9100 2>/dev/null; then
echo "Tai gRPC is ready"
TAI_GRPC_READY=true
break
fi
echo "Waiting for Tai gRPC... ($i)"
sleep 1
done
if [ "$TAI_GRPC_READY" != "true" ]; then
echo "::error::Tai gRPC failed to become ready within 15s"
echo "--- Tai container logs ---"
docker logs tai 2>&1 || true
exit 1
fi
- 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 Tai SDK Tests
- name: Run Benchmarks
env:
TAI_TEST_HOST: "127.0.0.1"
TAI_TEST_GRPC: "127.0.0.1:9100"
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
TAI_TEST_K8S_HOST: "127.0.0.1"
TAI_TEST_K8S_PORT: "6443"
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
TAI_TEST_GRPC_PORT: "9100"
TAI_TEST_HOST_IP: "172.17.0.1"
run: make unit-test-tai
- name: Codecov Report
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
run: make benchmark-sandbox-v2
# =============================================================================
# gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed)

View file

@ -23,6 +23,8 @@ TESTFOLDER_ROBOT := $(shell $(GO) list ./agent/robot/... | grep -vE 'agent/robot
TESTFOLDER_SANDBOX := $(shell $(GO) list ./sandbox/... | grep -v 'sandbox/v2')
# Tai SDK tests (requires Tai container with Docker socket)
TESTFOLDER_TAI := $(shell $(GO) list ./tai/...)
# Workspace tests (requires Tai for remote mode)
TESTFOLDER_WORKSPACE := $(shell $(GO) list ./workspace/...)
# gRPC tests
TESTFOLDER_GRPC := $(shell $(GO) list ./grpc/...)
TESTTAGS ?= ""
@ -199,16 +201,95 @@ unit-test-registry:
rm profile.out; \
fi
# Sandbox V2 Unit Test (requires Docker; optionally Tai for remote mode)
# ---------------------------------------------------------------------------
# Sandbox V2 Integration Test (tai + sandbox/v2 + workspace)
# Requires: Docker, Tai container, optionally k3d for K8s mode
# ---------------------------------------------------------------------------
SANDBOX_V2_IMAGE ?= yaoapp/sandbox-v2-test:latest
.PHONY: unit-test-sandbox-v2
unit-test-sandbox-v2:
unit-test-sandbox-v2: unit-test-sandbox-v2-pull unit-test-tai unit-test-sandbox-v2-core unit-test-workspace
@echo ""
@echo "============================================="
@echo "All Sandbox V2 integration tests passed"
@echo "============================================="
.PHONY: unit-test-sandbox-v2-pull
unit-test-sandbox-v2-pull:
@echo ""
@echo "============================================="
@echo "Pulling test images..."
@echo "============================================="
docker pull $(SANDBOX_V2_IMAGE) || true
docker pull alpine:latest || true
.PHONY: unit-test-sandbox-v2-core
unit-test-sandbox-v2-core:
@echo ""
@echo "============================================="
@echo "Running Sandbox V2 Tests..."
@echo "============================================="
docker pull $(SANDBOX_V2_IMAGE) || true
$(MAKE) -C sandbox/v2 test-ci TEST_IMAGE=$(SANDBOX_V2_IMAGE)
SANDBOX_V2_IMAGE ?= yaoapp/sandbox-v2-test:latest
# Workspace Unit Test (requires Tai for remote mode)
.PHONY: unit-test-workspace
unit-test-workspace:
@echo ""
@echo "============================================="
@echo "Running Workspace Tests..."
@echo "============================================="
echo "mode: count" > coverage.out
for d in $(TESTFOLDER_WORKSPACE); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=10m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "setup failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "runtime error" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \
rm profile.out; \
fi; \
done
@echo ""
@echo "============================================="
@echo "All workspace tests passed"
@echo "============================================="
# Benchmark: Sandbox V2 + Workspace
.PHONY: benchmark-sandbox-v2
benchmark-sandbox-v2:
@echo ""
@echo "============================================="
@echo "Running Sandbox V2 + Workspace Benchmarks..."
@echo "============================================="
@for d in $$($(GO) list ./sandbox/v2/... ./workspace/...); do \
if $(GO) test -list=Benchmark $$d 2>/dev/null | grep -q "^Benchmark"; then \
echo ""; \
echo "Benchmarking: $$d"; \
echo "---------------------------------------------"; \
$(GO) test -bench=. -benchmem -benchtime=1x -run='^$$' -timeout=600s $$d || true; \
fi; \
done
@echo ""
@echo "============================================="
@echo "All benchmarks completed"
@echo "============================================="
# Sandbox Unit Test (requires Docker)
.PHONY: unit-test-sandbox
@ -262,9 +343,6 @@ unit-test-tai:
@echo "============================================="
@echo "Running Tai SDK Tests (requires Tai container)..."
@echo "============================================="
@echo "Pulling test images..."
docker pull alpine:latest || true
@echo ""
echo "mode: count" > coverage.out
for d in $(TESTFOLDER_TAI); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=5m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \

View file

@ -1126,330 +1126,31 @@ Everything in the current `sandbox/` that is replaced by tai:
| **Multi-node** | Local only | Local + Remote via Tai |
| **K8s** | Not supported | Supported via tai.Client |
## Workspace — First-Class Entity
## Workspace
### Problem
Workspace is now a **top-level module** (`workspace/`), parallel to `sandbox/v2`.
Current design: `Box.Workspace()` returns `workspace.FS` keyed by `box.id` — workspace and container are 1:1, same lifecycle. This couples file storage to container lifetime.
See [`workspace/DESIGN.md`](../workspace/DESIGN.md) for the full design document covering:
- Workspace as a first-class, persistent entity decoupled from containers
- Node binding and container scheduling
- Workspace CRUD and file I/O APIs
- Integration with Sandbox `CreateOptions`
- Metadata storage strategy
- Process and JSAPI registration
- Implementation plan
Real usage pattern:
### Integration point
```
User creates a project → uploads files → works on it across multiple chat sessions
→ attaches a long-running dev server → destroys/rebuilds containers freely
→ project files must survive all of this
```
Workspace must outlive containers. It is the persistent artifact; containers are disposable compute.
### Design
Workspace becomes an independent entity with its own CRUD, decoupled from both Chat sessions and containers.
```
Workspace (persistent, user-managed)
├── CRUD / file management UI
├── Mountable to 0~N containers simultaneously
└── Referenced by 0~N Chat sessions
Chat Session
└── Selects a Workspace (not a container)
Container (ephemeral compute)
├── Bind-mounts a Workspace to /workspace
├── rw or ro per mount
└── Created/destroyed independently of Workspace
```
### Workspace struct
```go
type Workspace struct {
ID string // unique identifier, e.g. "ws-abc123"
Name string // human-readable, e.g. "my-react-app"
Owner string // user ID
Labels map[string]string // arbitrary metadata
CreatedAt time.Time
UpdatedAt time.Time
}
```
No container references stored here. Workspace is pure storage — it doesn't know or care about containers.
### MountMode
```go
type MountMode string
const (
MountRW MountMode = "rw" // read-write (default)
MountRO MountMode = "ro" // read-only
)
```
Rules:
- A Workspace can be mounted by multiple containers simultaneously
- Each mount independently specifies `rw` or `ro`
- No write-lock enforcement — caller manages concurrency
- Default is `rw`
Rationale: In practice, Chat containers write source code and Runtime containers write build artifacts/logs — different files, no real conflict. Enforcing locks adds complexity without solving a real problem in this use case.
### CreateOptions changes
`sandbox/v2` integrates with Workspace via `CreateOptions.WorkspaceID`:
```go
type CreateOptions struct {
// ... existing fields ...
// Workspace mount (new)
WorkspaceID string // workspace to mount; empty = no workspace
MountMode MountMode // "rw" (default) or "ro"
MountPath string // container path; default "/workspace"
}
```
When `WorkspaceID` is set, Manager resolves the storage path via `VolumeProvider.MountSpec()` and injects the bind mount into the container create options.
### Manager API additions
```go
// --- Workspace CRUD ---
// CreateWorkspace creates a persistent workspace.
// Storage is allocated via VolumeProvider.ResolvePath().
func (m *Manager) CreateWorkspace(ctx context.Context, opts WorkspaceOptions) (*Workspace, error)
// GetWorkspace returns a workspace by ID.
func (m *Manager) GetWorkspace(ctx context.Context, id string) (*Workspace, error)
// ListWorkspaces returns workspaces, optionally filtered by owner.
func (m *Manager) ListWorkspaces(ctx context.Context, opts WorkspaceListOptions) ([]*Workspace, error)
// DeleteWorkspace removes workspace storage.
// Fails if any containers currently mount it (unless force=true).
func (m *Manager) DeleteWorkspace(ctx context.Context, id string, force bool) error
type WorkspaceOptions struct {
ID string // explicit ID; empty = auto-generate
Name string // human-readable name
Owner string
Labels map[string]string
}
type WorkspaceListOptions struct {
Owner string
}
```
### Container creation flow (updated)
```
Manager.Create(ctx, CreateOptions{
Image: "yaoapp/workspace:latest",
WorkspaceID: "ws-abc123", // ← new
MountMode: MountRW, // ← new
})
1. Validate CreateOptions (image required, etc.)
2. If WorkspaceID set:
a. Verify workspace exists
b. spec := provider.MountSpec(workspaceID)
c. Inject into tai CreateOptions:
- Docker: opts.Binds = ["/data/ws/ws-abc123:/workspace:rw"]
- K8s: opts.Volumes + opts.VolumeMounts (PVC)
3. Create container via tai.Client.Sandbox().Create()
4. Start container
5. Return Box
```
### Box.Workspace() behavior change
```go
func (b *Box) Workspace() workspace.FS {
// If container has a workspace mounted, use the workspace ID as session.
// Otherwise fall back to box ID (backward compatible).
sessionID := b.workspaceID
if sessionID == "" {
sessionID = b.id
}
client, _ := b.manager.getPool(b.pool)
return client.Workspace(sessionID)
}
```
Multiple boxes mounting the same workspace → same `sessionID` → same files via Volume API.
### Typical flows
**Flow 1: Workspace management UI**
```
1. User creates workspace "my-project"
→ Manager.CreateWorkspace(opts) → VolumeProvider.ResolvePath("ws-123")
→ Directory /data/ws/ws-123/ created
2. User uploads files via Workspace management UI
→ Volume.WriteFile(ctx, "ws-123", "src/main.go", data, 0644)
→ Files written to /data/ws/ws-123/src/main.go
3. User browses files
→ Volume.ListDir(ctx, "ws-123", "src/")
→ Returns file listing from /data/ws/ws-123/src/
```
**Flow 2: Chat with Workspace**
```
1. User opens Chat, selects workspace "my-project" (ws-123)
2. Agent needs a container:
→ Manager.Create(ctx, CreateOptions{
Image: "yaoapp/workspace:latest",
WorkspaceID: "ws-123",
MountMode: MountRW,
})
→ Container starts with -v /data/ws/ws-123:/workspace:rw
→ Agent can exec "ls /workspace/src/" inside container
3. Chat ends, container destroyed
→ Workspace files persist in /data/ws/ws-123/
```
**Flow 3: Long-running Runtime**
```
1. User starts Runtime container for workspace "my-project":
→ Manager.Create(ctx, CreateOptions{
Image: "node:20",
WorkspaceID: "ws-123",
MountMode: MountRW,
Policy: Persistent,
Ports: [{ContainerPort: 3000}],
})
→ Container starts with -v /data/ws/ws-123:/workspace:rw
→ Inside container: cd /workspace && npm install && npm run dev
2. User accesses dev server:
→ box.Proxy(ctx, 3000, "/") → "http://localhost:32768/"
→ Or box.VNC(ctx) for desktop preview
3. User opens Chat, selects same workspace:
→ Manager.Create(ctx, CreateOptions{
Image: "yaoapp/agent:latest",
WorkspaceID: "ws-123",
MountMode: MountRW,
})
→ Second container, same workspace mounted
→ Agent modifies source → Runtime hot-reloads
4. Chat ends, Chat container destroyed
→ Runtime container keeps running
→ Workspace files persist
```
### Storage backend (already implemented in Tai)
The `storage.VolumeProvider` interface in Tai Server already has three complete implementations:
```go
// tai/storage/provider.go
type VolumeProvider interface {
ResolvePath(sessionID string) (string, error)
MountSpec(sessionID string) MountConfig
Cleanup(sessionID string) error
}
type MountConfig struct {
Type string // "bind" | "volume" | "pvc"
Source string
Target string // always /workspace
}
```
| Provider | Backend | MountSpec | Status |
|----------|---------|-----------|--------|
| `BindMountProvider` | Host directory (`/data/ws/{id}/`) | `type:"bind"` | Implemented, default |
| `DockerVolumeProvider` | Docker named volume (`tai-{id}`) | `type:"volume"` | Implemented |
| `K8sPVCProvider` | K8s PVC (`tai-{id}-pvc`, 10Gi RWO) | `type:"pvc"` | Implemented |
These are implemented but **not yet wired** into the container creation flow. The only work needed is calling `MountSpec()` during `Manager.Create()` and passing the result into `tai.sandbox.CreateOptions.Binds`.
For file operations (CRUD UI), Tai's `Volume` gRPC service already operates on the same `dataDir/{sessionID}/` paths. No additional work needed — `Volume.ReadFile("ws-123", "src/main.go")` reads from the same directory that gets bind-mounted into containers.
### Workspace metadata storage
Workspace metadata (ID, Name, Owner, Labels, timestamps) needs persistent storage.
Recommendation: **JSON file** (`/data/ws/{id}/.workspace.json`) for Phase 1. Each workspace directory contains its own metadata. Listing = scan directories + read metadata files. Zero dependencies, works everywhere.
```json
{
"id": "ws-abc123",
"name": "my-react-app",
"owner": "user-001",
"labels": {"project": "frontend"},
"created_at": "2026-03-05T10:00:00Z",
"updated_at": "2026-03-05T12:30:00Z"
}
```
Can migrate to SQLite or Yao DB later if query/filter requirements grow.
### Process registration additions
| Process | Args | Returns |
|---------|------|---------|
| `sandbox.workspace.Create` | `options` (WorkspaceOptions JSON) | Workspace |
| `sandbox.workspace.Get` | `id` | Workspace |
| `sandbox.workspace.List` | `options` (WorkspaceListOptions JSON) | []Workspace |
| `sandbox.workspace.Delete` | `id`, `force?` | — |
### JSAPI additions
```javascript
// Workspace CRUD
var ws = Sandbox.CreateWorkspace({ name: "my-project", owner: "user-001" })
var ws = Sandbox.GetWorkspace("ws-abc123")
var list = Sandbox.ListWorkspaces({ owner: "user-001" })
Sandbox.DeleteWorkspace("ws-abc123")
// File operations on workspace (without a container)
ws.ReadFile("src/main.go")
ws.WriteFile("src/main.go", "package main\n...")
ws.ListDir("src/")
ws.Remove("tmp.txt")
// Create container with workspace
var sb = Sandbox("my-box", {
image: "node:20",
workspace_id: ws.id,
mount_mode: "rw",
})
```
### What changes from current design
| Aspect | Before | After |
|--------|--------|-------|
| Workspace lifecycle | Tied to Box (same ID, same lifetime) | Independent entity, outlives containers |
| Workspace identity | `sessionID = box.id` | `sessionID = workspace.id` (explicit) |
| Container ↔ Workspace | 1:1, implicit | N:1, explicit via `CreateOptions.WorkspaceID` |
| File persistence | Lost when container removed | Persists until workspace deleted |
| Multi-container access | Not possible | Multiple containers mount same workspace |
| Storage backend | Volume gRPC only (no mount) | Volume gRPC + bind mount into container |
| CRUD without container | Not possible | Via Volume API directly |
### Implementation plan
**Phase 1.5** (between current Phase 1 and Phase 2):
| Task | Detail |
|------|--------|
| `workspace.go` | Workspace struct, WorkspaceOptions, metadata JSON read/write |
| Manager: workspace CRUD | `CreateWorkspace` / `GetWorkspace` / `ListWorkspaces` / `DeleteWorkspace` via VolumeProvider + JSON metadata |
| Manager: `Create()` updated | Wire `WorkspaceID``VolumeProvider.MountSpec()``Binds` |
| `Box.Workspace()` updated | Use `workspaceID` as sessionID when set |
| Tai Server: wire `VolumeProvider` | Call `MountSpec()` in container creation path |
| Tests | Workspace CRUD + mount verification |
No breaking changes. Containers created without `WorkspaceID` work exactly as before (`sessionID = box.id`, no bind mount).
When `WorkspaceID` is set, the Sandbox Manager resolves the Workspace's bound node and forces the container to be created on that node. See `workspace/DESIGN.md` for full details.

254
sandbox/v2/bench_test.go Normal file
View file

@ -0,0 +1,254 @@
package sandbox_test
import (
"context"
"fmt"
"testing"
"time"
sandbox "github.com/yaoapp/yao/sandbox/v2"
)
// BenchmarkContainerLifecycle measures the full Create → Exec → Remove cycle.
func BenchmarkContainerLifecycle(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
ensureTestImageBench(b, m, pc.Name)
b.ResetTimer()
for i := 0; i < b.N; i++ {
ctx := context.Background()
box, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "bench",
})
if err != nil {
b.Fatalf("Create: %v", err)
}
_, err = box.Exec(ctx, []string{"echo", "ok"})
if err != nil {
b.Fatalf("Exec: %v", err)
}
m.Remove(ctx, box.ID())
}
})
}
}
// BenchmarkCreate measures container creation time only.
func BenchmarkCreate(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
ensureTestImageBench(b, m, pc.Name)
ids := make([]string, 0, b.N)
b.ResetTimer()
for i := 0; i < b.N; i++ {
box, err := m.Create(context.Background(), sandbox.CreateOptions{
Image: testImage(),
Owner: "bench",
})
if err != nil {
b.Fatalf("Create: %v", err)
}
ids = append(ids, box.ID())
}
b.StopTimer()
for _, id := range ids {
m.Remove(context.Background(), id)
}
})
}
}
// BenchmarkExec measures command execution latency on a pre-created container.
func BenchmarkExec(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
box := createBoxForBench(b, m)
b.ResetTimer()
for i := 0; i < b.N; i++ {
result, err := box.Exec(context.Background(), []string{"echo", "bench"})
if err != nil {
b.Fatalf("Exec: %v", err)
}
if result.ExitCode != 0 {
b.Fatalf("exit code = %d", result.ExitCode)
}
}
})
}
}
// BenchmarkExecHeavy measures execution of a heavier command (write + read file).
func BenchmarkExecHeavy(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
box := createBoxForBench(b, m)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cmd := []string{"sh", "-c", fmt.Sprintf("echo bench-%d > /tmp/b.txt && cat /tmp/b.txt", i)}
result, err := box.Exec(context.Background(), cmd)
if err != nil {
b.Fatalf("Exec: %v", err)
}
if result.ExitCode != 0 {
b.Fatalf("exit code = %d", result.ExitCode)
}
}
})
}
}
// BenchmarkRemove measures container removal time.
func BenchmarkRemove(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
ensureTestImageBench(b, m, pc.Name)
boxes := make([]*sandbox.Box, b.N)
for i := 0; i < b.N; i++ {
box, err := m.Create(context.Background(), sandbox.CreateOptions{
Image: testImage(),
Owner: "bench",
})
if err != nil {
b.Fatalf("Create: %v", err)
}
boxes[i] = box
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := m.Remove(context.Background(), boxes[i].ID()); err != nil {
b.Fatalf("Remove: %v", err)
}
}
})
}
}
// BenchmarkInfo measures Info() latency on a running container.
func BenchmarkInfo(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
box := createBoxForBench(b, m)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := box.Info(context.Background())
if err != nil {
b.Fatalf("Info: %v", err)
}
}
})
}
}
// BenchmarkStopStart measures Stop → Start cycle time.
func BenchmarkStopStart(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
if pc.Name == "k8s" {
b.Skip("K8s Stop deletes Pod; Stop→Start cycle not applicable")
}
m := setupManagerForBench(b, pc)
box := createBoxForBench(b, m)
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := box.Stop(context.Background()); err != nil {
b.Fatalf("Stop: %v", err)
}
if err := box.Start(context.Background()); err != nil {
b.Fatalf("Start: %v", err)
}
}
})
}
}
// BenchmarkWorkspaceReadWrite measures workspace file read/write via container Box.
func BenchmarkWorkspaceReadWrite(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc)
box := createBoxForBench(b, m)
ws := box.Workspace()
if ws == nil {
b.Skip("workspace not available")
}
payload := []byte("package main\nfunc main() { println(\"hello\") }\n")
b.ResetTimer()
for i := 0; i < b.N; i++ {
name := fmt.Sprintf("f%d.go", i)
if err := ws.WriteFile(name, payload, 0644); err != nil {
b.Fatalf("WriteFile: %v", err)
}
data, err := ws.ReadFile(name)
if err != nil {
b.Fatalf("ReadFile: %v", err)
}
if len(data) != len(payload) {
b.Fatalf("size mismatch: %d vs %d", len(data), len(payload))
}
}
})
}
}
// --- helpers ---
func setupManagerForBench(b *testing.B, pc poolConfig) *sandbox.Manager {
b.Helper()
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options}
cfg := sandbox.Config{Pool: []sandbox.Pool{pool}}
if err := sandbox.Init(cfg); err != nil {
b.Fatalf("Init: %v", err)
}
m := sandbox.M()
b.Cleanup(func() { m.Close() })
return m
}
func ensureTestImageBench(b *testing.B, m *sandbox.Manager, pool string) {
b.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
if err := m.EnsureImage(ctx, pool, testImage(), sandbox.ImagePullOptions{}); err != nil {
b.Fatalf("EnsureImage: %v", err)
}
}
func createBoxForBench(b *testing.B, m *sandbox.Manager) *sandbox.Box {
b.Helper()
pools := m.Pools()
if len(pools) > 0 {
ensureTestImageBench(b, m, pools[0].Name)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
box, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "bench",
})
if err != nil {
b.Fatalf("Create: %v", err)
}
b.Cleanup(func() { m.Remove(context.Background(), box.ID()) })
return box
}

View file

@ -23,10 +23,12 @@ type Box struct {
lastHeartbeat atomic.Int64
processCount atomic.Int32
idleTimeoutD time.Duration
stopTimeoutD time.Duration
createdAt time.Time
refreshToken string
vnc bool
image string
workspaceID string
ws workspace.FS
manager *Manager
}
@ -143,19 +145,28 @@ func (b *Box) Attach(ctx context.Context, port int, opts ...AttachOption) (*Serv
}
// Workspace returns an fs.FS-compatible filesystem for this sandbox.
// If a workspace is mounted (WorkspaceID set), uses the workspace ID as session;
// otherwise falls back to the sandbox ID (backward compatible).
func (b *Box) Workspace() workspace.FS {
b.touch()
if b.ws != nil {
return b.ws
}
sessionID := b.workspaceID
if sessionID == "" {
sessionID = b.id
}
client, err := b.manager.getPool(b.pool)
if err != nil {
return nil
}
b.ws = client.Workspace(b.id)
b.ws = client.Workspace(sessionID)
return b.ws
}
// WorkspaceID returns the workspace ID mounted to this sandbox, or empty string.
func (b *Box) WorkspaceID() string { return b.workspaceID }
// VNC returns the VNC WebSocket URL.
func (b *Box) VNC(ctx context.Context) (string, error) {
b.touch()
@ -191,7 +202,7 @@ func (b *Box) Stop(ctx context.Context) error {
if err != nil {
return err
}
return client.Sandbox().Stop(ctx, b.containerID, 10*time.Second)
return client.Sandbox().Stop(ctx, b.containerID, b.stopTimeout())
}
// Remove stops and removes the sandbox.
@ -259,3 +270,14 @@ func (b *Box) maxLifetime() time.Duration {
}
return 0
}
func (b *Box) stopTimeout() time.Duration {
if b.stopTimeoutD > 0 {
return b.stopTimeoutD
}
pd := b.manager.findPoolDef(b.pool)
if pd != nil && pd.StopTimeout > 0 {
return pd.StopTimeout
}
return DefaultStopTimeout
}

View file

@ -2,7 +2,6 @@ package sandbox_test
import (
"context"
"fmt"
"net"
"net/http"
"strings"
@ -19,12 +18,12 @@ func waitForPort(t *testing.T, box *sandbox.Box, port int, timeout time.Duration
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
url, err := box.Proxy(ctx, port, "/")
proxyURL, err := box.Proxy(ctx, port, "/")
if err != nil {
t.Fatalf("Proxy URL: %v", err)
}
host := url[len("http://"):]
host := proxyURL[len("http://"):]
if i := len(host) - 1; host[i] == '/' {
host = host[:i]
}
@ -36,7 +35,7 @@ func waitForPort(t *testing.T, box *sandbox.Box, port int, timeout time.Duration
}
deadline := time.After(timeout)
ticker := time.NewTicker(200 * time.Millisecond)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
@ -44,13 +43,16 @@ func waitForPort(t *testing.T, box *sandbox.Box, port int, timeout time.Duration
case <-deadline:
t.Fatalf("port %d not ready within %v", port, timeout)
case <-ticker.C:
conn, err := net.DialTimeout("tcp", host, time.Second)
if err == nil {
conn.Close()
time.Sleep(200 * time.Millisecond)
return
conn, err := net.DialTimeout("tcp", host, 2*time.Second)
if err != nil {
continue
}
fmt.Printf("waiting for %s: %v\n", host, err)
conn.Close()
// TCP reachable — give the service process time to accept
// application-layer connections (Python ws/sse servers in CI
// may take 1-3s after the port opens before they're ready).
time.Sleep(2 * time.Second)
return
}
}
}
@ -74,9 +76,17 @@ func TestAttachWS(t *testing.T) {
waitForPort(t, box, 9800, 30*time.Second)
conn, err := box.Attach(t.Context(), 9800, sandbox.WithProtocol("ws"), sandbox.WithPath("/"))
var conn *sandbox.ServiceConn
var err error
for attempt := 0; attempt < 5; attempt++ {
conn, err = box.Attach(t.Context(), 9800, sandbox.WithProtocol("ws"), sandbox.WithPath("/"))
if err == nil {
break
}
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
}
if err != nil {
t.Fatalf("Attach WS: %v", err)
t.Fatalf("Attach WS after retries: %v", err)
}
defer conn.Close()
@ -114,9 +124,17 @@ func TestAttachSSE(t *testing.T) {
waitForPort(t, box, 9801, 30*time.Second)
conn, err := box.Attach(t.Context(), 9801, sandbox.WithProtocol("sse"), sandbox.WithPath("/events"))
var conn *sandbox.ServiceConn
var err error
for attempt := 0; attempt < 5; attempt++ {
conn, err = box.Attach(t.Context(), 9801, sandbox.WithProtocol("sse"), sandbox.WithPath("/events"))
if err == nil {
break
}
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
}
if err != nil {
t.Fatalf("Attach SSE: %v", err)
t.Fatalf("Attach SSE after retries: %v", err)
}
defer conn.Close()
@ -181,7 +199,7 @@ func TestVNCConnect(t *testing.T) {
co.VNC = true
})
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
vncURL, err := box.VNC(ctx)
@ -196,13 +214,20 @@ func TestVNCConnect(t *testing.T) {
Subprotocols: []string{"binary"},
HandshakeTimeout: 10 * time.Second,
}
ws, resp, err := dialer.DialContext(ctx, vncURL, http.Header{})
if err != nil {
extra := ""
if resp != nil {
extra = fmt.Sprintf(" (status %d)", resp.StatusCode)
var ws *websocket.Conn
for attempt := 0; attempt < 5; attempt++ {
var resp *http.Response
ws, resp, err = dialer.DialContext(ctx, vncURL, http.Header{})
if err == nil {
break
}
t.Fatalf("VNC dial: %v%s", err, extra)
if resp != nil {
resp.Body.Close()
}
time.Sleep(time.Duration(attempt+1) * time.Second)
}
if err != nil {
t.Fatalf("VNC dial after retries: %v", err)
}
defer ws.Close()
@ -240,12 +265,16 @@ func waitForWSEndpoint(t *testing.T, wsURL string, timeout time.Duration) {
case <-deadline:
t.Fatalf("VNC endpoint %s not ready within %v", host, timeout)
case <-ticker.C:
conn, err := net.DialTimeout("tcp", host, time.Second)
if err == nil {
conn.Close()
time.Sleep(500 * time.Millisecond)
return
conn, err := net.DialTimeout("tcp", host, 2*time.Second)
if err != nil {
continue
}
conn.Close()
// VNC services (Xvfb → fluxbox → x11vnc → websockify) need time
// after the TCP port is reachable. Give the process chain time to
// stabilize before attempting the WebSocket handshake.
time.Sleep(2 * time.Second)
return
}
}
}

View file

@ -0,0 +1,121 @@
package sandbox_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sandbox "github.com/yaoapp/yao/sandbox/v2"
)
func TestImageExists(t *testing.T) {
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
if pc.Name == "k8s" {
t.Run("always_true", func(t *testing.T) {
m := setupManagerForPool(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
exists, err := m.ImageExists(ctx, pc.Name, "anything:nonexistent")
require.NoError(t, err)
assert.True(t, exists, "k8s mode should always return true")
})
return
}
m := setupManagerForPool(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
t.Run("existing", func(t *testing.T) {
exists, err := m.ImageExists(ctx, pc.Name, "alpine:latest")
require.NoError(t, err)
assert.True(t, exists)
})
t.Run("missing", func(t *testing.T) {
exists, err := m.ImageExists(ctx, pc.Name, "nonexistent/image:no-such-tag-ever-12345")
require.NoError(t, err)
assert.False(t, exists)
})
})
}
}
func TestImagePull(t *testing.T) {
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
if pc.Name == "k8s" {
t.Run("noop", func(t *testing.T) {
m := setupManagerForPool(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
ch, err := m.PullImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{})
require.NoError(t, err)
assert.Nil(t, ch, "k8s mode should return nil channel")
})
return
}
m := setupManagerForPool(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
t.Run("pull_with_progress", func(t *testing.T) {
ch, err := m.PullImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{})
require.NoError(t, err)
require.NotNil(t, ch)
var count int
for p := range ch {
if p.Error != "" {
t.Fatalf("pull error: %s", p.Error)
}
count++
}
assert.Greater(t, count, 0, "should receive at least one progress event")
})
})
}
}
func TestEnsureImage(t *testing.T) {
for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
err := m.EnsureImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{})
require.NoError(t, err)
if pc.Name != "k8s" {
exists, err := m.ImageExists(ctx, pc.Name, "alpine:latest")
require.NoError(t, err)
assert.True(t, exists)
}
})
}
}
func TestEnsureImage_BadRef(t *testing.T) {
for _, pc := range testPools() {
pc := pc
if pc.Name == "k8s" {
continue
}
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := m.EnsureImage(ctx, pc.Name, "nonexistent/image:no-such-tag-ever-12345", sandbox.ImagePullOptions{})
assert.Error(t, err)
})
}
}

View file

@ -3,6 +3,7 @@ package sandbox_test
import (
"context"
"io"
"strings"
"testing"
"time"
@ -143,7 +144,7 @@ func TestBoxInfo(t *testing.T) {
if info.ID != box.ID() {
t.Errorf("ID = %q, want %q", info.ID, box.ID())
}
if info.Status != "running" {
if s := strings.ToLower(info.Status); s != "running" {
t.Errorf("status = %q, want running", info.Status)
}
if info.Owner != "test-user" {

View file

@ -0,0 +1,284 @@
package sandbox_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/workspace"
)
func TestWorkspaceID_Set(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "test-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
assert.Equal(t, ws.ID, box.WorkspaceID())
})
}
}
func TestWorkspaceID_Empty(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
box := createTestBox(t, m)
assert.Empty(t, box.WorkspaceID())
})
}
}
func TestWorkspace_NodeRouting(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "routed-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
assert.Equal(t, pc.Name, box.Pool())
})
}
}
func TestWorkspace_InvalidID(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
sbm, _ := setupManagerWithWorkspace(t, pc)
ensureTestImage(t, sbm, pc.Name)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := sbm.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
Owner: "user",
WorkspaceID: "nonexistent-workspace",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "resolve workspace")
})
}
}
func TestWorkspace_BindMountLocal(t *testing.T) {
skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()}
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "mount-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
require.NoError(t, wsm.WriteFile(ctx, ws.ID, "seed.txt", []byte("hello from workspace"), 0644))
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
result, err := box.Exec(ctx, []string{"cat", "/workspace/seed.txt"})
require.NoError(t, err)
assert.Equal(t, "hello from workspace", result.Stdout)
}
func TestWorkspace_ContainerWriteBack(t *testing.T) {
skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()}
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "writeback-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
_, err = box.Exec(ctx, []string{"sh", "-c", "echo 'from container' > /workspace/output.txt"})
require.NoError(t, err)
data, err := wsm.ReadFile(ctx, ws.ID, "output.txt")
require.NoError(t, err)
assert.Equal(t, "from container\n", string(data))
}
func TestWorkspace_ReadOnlyMount(t *testing.T) {
skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()}
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "ro-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
require.NoError(t, wsm.WriteFile(ctx, ws.ID, "readonly.txt", []byte("immutable"), 0644))
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
co.MountMode = "ro"
})
result, err := box.Exec(ctx, []string{"cat", "/workspace/readonly.txt"})
require.NoError(t, err)
assert.Equal(t, "immutable", result.Stdout)
result, err = box.Exec(ctx, []string{"sh", "-c", "echo fail > /workspace/nope.txt 2>&1; echo $?"})
require.NoError(t, err)
// Write to read-only mount should fail (non-zero exit or error message)
assert.True(t, result.ExitCode != 0 || result.Stdout != "0\n" || len(result.Stderr) > 0,
"expected write to read-only mount to fail")
}
func TestWorkspace_CustomMountPath(t *testing.T) {
skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()}
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "custom-path-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
require.NoError(t, wsm.WriteFile(ctx, ws.ID, "data.json", []byte(`{"ok":true}`), 0644))
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
co.MountPath = "/data"
})
result, err := box.Exec(ctx, []string{"cat", "/data/data.json"})
require.NoError(t, err)
assert.Equal(t, `{"ok":true}`, result.Stdout)
}
func TestWorkspace_BoxWorkspaceFS(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
if pc.Name == "local" {
// Local mode: sandbox and workspace use separate tai.Clients with
// different dataDirs, so Box.Workspace() writes to the sandbox volume
// while wsm reads from the workspace volume. Bind mount tests cover
// local workspace I/O end-to-end instead.
continue
}
t.Run(pc.Name, func(t *testing.T) {
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "fs-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
wfs := box.Workspace()
if wfs == nil {
t.Skip("Workspace FS not available")
}
require.NoError(t, wfs.WriteFile("via-box.txt", []byte("box wrote this"), 0644))
data, err := wsm.ReadFile(ctx, ws.ID, "via-box.txt")
require.NoError(t, err)
assert.Equal(t, "box wrote this", string(data))
})
}
}
func TestWorkspace_LabelPersistence(t *testing.T) {
skipIfNoDocker(t)
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
sbm, wsm := setupManagerWithWorkspace(t, pc)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{
Name: "label-ws", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true)
box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) {
co.WorkspaceID = ws.ID
})
// WorkspaceID getter should reflect what was set
assert.Equal(t, ws.ID, box.WorkspaceID())
// Container should also carry the label (verify via exec reading env or
// just trust that buildTaiCreateOptions sets it — the label is tested
// indirectly by TestWorkspace_NodeRouting which relies on correct routing)
info, err := box.Info(ctx)
require.NoError(t, err)
assert.Contains(t, []string{"running", "Running"}, info.Status)
})
}
}

View file

@ -8,6 +8,7 @@ RUN sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu
sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list 2>/dev/null || true
RUN apt-get update && apt-get install -y --no-install-recommends \
tini \
curl wget git ca-certificates gnupg lsb-release jq \
vim less tree \
iputils-ping net-tools dnsutils telnet netcat-openbsd \
@ -34,5 +35,5 @@ COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
USER sandbox
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
CMD ["sleep", "infinity"]

View file

@ -24,5 +24,5 @@ ENV DISPLAY=:99
USER sandbox
EXPOSE 5900 6080
ENTRYPOINT ["/test-entrypoint.sh"]
ENTRYPOINT ["/usr/bin/tini", "--", "/test-entrypoint.sh"]
CMD ["sleep", "infinity"]

View file

@ -8,6 +8,7 @@ import (
"github.com/yaoapp/yao/tai"
taisandbox "github.com/yaoapp/yao/tai/sandbox"
"github.com/yaoapp/yao/workspace"
)
// Manager manages a pool of tai.Client connections and sandbox lifecycle.
@ -20,6 +21,7 @@ type Manager struct {
mu sync.Mutex
cancel context.CancelFunc
grpcPort int
wsManager *workspace.Manager
}
func newManager(cfg Config) (*Manager, error) {
@ -176,6 +178,16 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
poolName = m.defaultPool
}
// Workspace node binding: when WorkspaceID is set, resolve the workspace's
// bound node and force the container onto that pool.
if opts.WorkspaceID != "" && m.wsManager != nil {
node, err := m.wsManager.NodeForWorkspace(ctx, opts.WorkspaceID)
if err != nil {
return nil, fmt.Errorf("sandbox: resolve workspace %q: %w", opts.WorkspaceID, err)
}
poolName = node
}
pd := m.findPoolDef(poolName)
if pd == nil {
return nil, ErrPoolNotFound
@ -225,11 +237,13 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
policy: policy,
labels: opts.Labels,
idleTimeoutD: opts.IdleTimeout,
stopTimeoutD: opts.StopTimeout,
createdAt: time.Now(),
refreshToken: refresh,
manager: m,
vnc: opts.VNC,
image: opts.Image,
workspaceID: opts.WorkspaceID,
}
box.lastCall.Store(time.Now().UnixMilli())
@ -280,7 +294,7 @@ func (m *Manager) List(_ context.Context, opts ListOptions) ([]*Box, error) {
return result, nil
}
// Remove stops and removes a sandbox.
// Remove force-removes a sandbox (SIGKILL + delete).
func (m *Manager) Remove(ctx context.Context, id string) error {
v, ok := m.boxes.Load(id)
if !ok {
@ -290,7 +304,6 @@ func (m *Manager) Remove(ctx context.Context, id string) error {
client, err := m.getPool(b.pool)
if err == nil {
client.Sandbox().Stop(ctx, b.containerID, 10*time.Second)
client.Sandbox().Remove(ctx, b.containerID, true)
}
@ -319,7 +332,7 @@ func (m *Manager) Cleanup(ctx context.Context) error {
case LongRunning:
if timeout := b.idleTimeout(); timeout > 0 && idle > timeout {
if client, err := m.getPool(b.pool); err == nil {
client.Sandbox().Stop(ctx, b.containerID, 10*time.Second)
client.Sandbox().Stop(ctx, b.containerID, b.stopTimeout())
}
}
if lifetime := b.maxLifetime(); lifetime > 0 && now.Sub(b.createdAt) > lifetime {
@ -352,6 +365,13 @@ func (m *Manager) SetGRPCPort(port int) {
m.grpcPort = port
}
// SetWorkspaceManager links the workspace manager for workspace-aware container creation.
// When CreateOptions.WorkspaceID is set, the sandbox Manager uses the workspace Manager
// to resolve the workspace's bound node and force container routing.
func (m *Manager) SetWorkspaceManager(wm *workspace.Manager) {
m.wsManager = wm
}
func (m *Manager) cleanupLoop(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
@ -448,6 +468,9 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID,
"sandbox-pool": pd.Name,
"sandbox-policy": string(opts.Policy),
}
if opts.WorkspaceID != "" {
labels["workspace-id"] = opts.WorkspaceID
}
for k, v := range opts.Labels {
labels[k] = v
}
@ -457,7 +480,7 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID,
workDir = "/workspace"
}
cmd := []string{"sleep", "infinity"}
cmd := []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done"}
var ports []taisandbox.PortMapping
for _, p := range opts.Ports {
@ -469,11 +492,29 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID,
})
}
// Workspace bind mount
var binds []string
if opts.WorkspaceID != "" && m.wsManager != nil {
mountPath := opts.MountPath
if mountPath == "" {
mountPath = "/workspace"
}
mode := opts.MountMode
if mode == "" {
mode = "rw"
}
hostPath, _ := m.wsManager.MountPath(context.Background(), opts.WorkspaceID)
if hostPath != "" {
binds = append(binds, fmt.Sprintf("%s:%s:%s", hostPath, mountPath, mode))
}
}
return taisandbox.CreateOptions{
Name: sandboxID,
Image: opts.Image,
Cmd: cmd,
Env: env,
Binds: binds,
WorkingDir: workDir,
User: opts.User,
Memory: opts.Memory,
@ -511,9 +552,65 @@ func (m *Manager) recoverBoxes(ctx context.Context, pd *Pool, client *tai.Client
labels: c.Labels,
createdAt: time.Now(),
image: c.Image,
workspaceID: c.Labels["workspace-id"],
manager: m,
}
box.lastCall.Store(time.Now().UnixMilli())
m.boxes.Store(sandboxID, box)
}
}
// ImageExists reports whether the given image ref exists on the target pool node.
func (m *Manager) ImageExists(ctx context.Context, pool, ref string) (bool, error) {
client, err := m.getPool(pool)
if err != nil {
return false, err
}
return client.Image().Exists(ctx, ref)
}
// PullImage pulls an image to the target pool node, returning a channel of
// real-time progress events. The channel is nil when no pull is needed (e.g. K8s mode).
func (m *Manager) PullImage(ctx context.Context, pool, ref string, opts ImagePullOptions) (<-chan taisandbox.PullProgress, error) {
client, err := m.getPool(pool)
if err != nil {
return nil, err
}
pullOpts := taisandbox.PullOptions{}
if opts.Auth != nil {
pullOpts.Auth = &taisandbox.RegistryAuth{
Username: opts.Auth.Username,
Password: opts.Auth.Password,
Server: opts.Auth.Server,
}
}
return client.Image().Pull(ctx, ref, pullOpts)
}
// EnsureImage checks whether the image exists on the pool node; if not, it
// pulls the image and blocks until the pull completes. Returns the first
// error encountered during pull. For K8s pools this is a no-op.
func (m *Manager) EnsureImage(ctx context.Context, pool, ref string, opts ImagePullOptions) error {
exists, err := m.ImageExists(ctx, pool, ref)
if err != nil {
return fmt.Errorf("image exists check: %w", err)
}
if exists {
return nil
}
ch, err := m.PullImage(ctx, pool, ref, opts)
if err != nil {
return fmt.Errorf("image pull: %w", err)
}
if ch == nil {
return nil
}
for p := range ch {
if p.Error != "" {
return fmt.Errorf("image pull %q: %s", ref, p.Error)
}
}
return nil
}

View file

@ -52,6 +52,7 @@ func TestIdleCleanup(t *testing.T) {
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) {
p.IdleTimeout = 1 * time.Second
})
ensureTestImage(t, m, pc.Name)
ctx := context.Background()
box, err := m.Create(ctx, sandbox.CreateOptions{

View file

@ -127,6 +127,7 @@ func TestRemove(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ensureTestImage(t, m, pc.Name)
ctx := context.Background()
box, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(),
@ -156,6 +157,7 @@ func TestPoolLimits_MaxTotal(t *testing.T) {
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) {
p.MaxTotal = 1
})
ensureTestImage(t, m, pc.Name)
box1 := createTestBox(t, m)
_ = box1
@ -236,10 +238,14 @@ func TestMultiPool(t *testing.T) {
var sps []sandbox.Pool
for _, pc := range pools {
sps = append(sps, sandbox.Pool{Name: pc.Name, Addr: pc.Addr})
sps = append(sps, sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options})
}
m := setupManager(t, sps...)
for _, pc := range pools {
ensureTestImage(t, m, pc.Name)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

View file

@ -2,20 +2,28 @@ package sandbox_test
import (
"context"
"fmt"
"os"
"strconv"
"testing"
"time"
sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/workspace"
)
type poolConfig struct {
Name string
Addr string
Name string
Addr string
Options []tai.Option
}
// testPools returns all available pool configurations for dual-mode testing.
// Always includes "local"; includes "remote" when SANDBOX_TEST_REMOTE_ADDR is set.
// testPools returns all available pool configurations for multi-mode testing.
// - local: always present (direct Docker daemon)
// - remote: when SANDBOX_TEST_REMOTE_ADDR is set (Tai proxy → Docker)
// - k8s: when TAI_TEST_K8S_HOST + TAI_TEST_KUBECONFIG are set (Tai proxy → K8s)
func testPools() []poolConfig {
pools := []poolConfig{
{Name: "local", Addr: testLocalAddr()},
@ -23,6 +31,25 @@ func testPools() []poolConfig {
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
pools = append(pools, poolConfig{Name: "remote", Addr: addr})
}
if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" {
kubeconfig := os.Getenv("TAI_TEST_KUBECONFIG")
if kubeconfig == "" {
return pools
}
addr := fmt.Sprintf("tai://%s", host)
opts := []tai.Option{
tai.K8s,
tai.WithKubeConfig(kubeconfig),
tai.WithPorts(tai.Ports{
K8s: envPort("TAI_TEST_K8S_PORT", 6443),
GRPC: envPort("TAI_TEST_GRPC_PORT", 9100),
}),
}
if ns := os.Getenv("TAI_TEST_K8S_NAMESPACE"); ns != "" {
opts = append(opts, tai.WithNamespace(ns))
}
pools = append(pools, poolConfig{Name: "k8s", Addr: addr, Options: opts})
}
return pools
}
@ -55,6 +82,15 @@ func testImage() string {
return "alpine:latest"
}
func envPort(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if p, err := strconv.Atoi(v); err == nil {
return p
}
}
return fallback
}
func setupManager(t *testing.T, pools ...sandbox.Pool) *sandbox.Manager {
t.Helper()
cfg := sandbox.Config{Pool: pools}
@ -70,13 +106,49 @@ func setupManager(t *testing.T, pools ...sandbox.Pool) *sandbox.Manager {
func setupManagerForPool(t *testing.T, pc poolConfig, mutators ...func(*sandbox.Pool)) *sandbox.Manager {
t.Helper()
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr}
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options}
for _, fn := range mutators {
fn(&pool)
}
return setupManager(t, pool)
}
// setupManagerWithWorkspace creates a sandbox Manager with a linked workspace Manager.
// Returns both managers and a helper to create workspaces on the given pool's node.
func setupManagerWithWorkspace(t *testing.T, pc poolConfig) (*sandbox.Manager, *workspace.Manager) {
t.Helper()
sbm := setupManagerForPool(t, pc)
var wsClient *tai.Client
var err error
if pc.Addr == "local" || pc.Addr == "" {
dataDir := t.TempDir()
vol := volume.NewLocal(dataDir)
wsClient, err = tai.New("local", tai.WithVolume(vol), tai.WithDataDir(dataDir))
} else {
wsClient, err = tai.New(pc.Addr, pc.Options...)
}
if err != nil {
t.Fatalf("tai.New for workspace: %v", err)
}
t.Cleanup(func() { wsClient.Close() })
wsm := workspace.NewManager(map[string]*tai.Client{pc.Name: wsClient})
sbm.SetWorkspaceManager(wsm)
return sbm, wsm
}
// ensureTestImage guarantees testImage() is available on the given pool before
// container creation. Safe for all modes (Docker pull; K8s no-op).
func ensureTestImage(t *testing.T, m *sandbox.Manager, pool string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
if err := m.EnsureImage(ctx, pool, testImage(), sandbox.ImagePullOptions{}); err != nil {
t.Fatalf("EnsureImage(%s, %s): %v", pool, testImage(), err)
}
}
func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.CreateOptions)) *sandbox.Box {
t.Helper()
co := sandbox.CreateOptions{
@ -86,8 +158,24 @@ func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.Creat
for _, fn := range opts {
fn(&co)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
pool := co.Pool
if pool == "" {
pools := m.Pools()
if len(pools) > 0 {
pool = pools[0].Name
}
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
if pool != "" {
if err := m.EnsureImage(ctx, pool, co.Image, sandbox.ImagePullOptions{}); err != nil {
t.Fatalf("EnsureImage(%s, %s): %v", pool, co.Image, err)
}
}
box, err := m.Create(ctx, co)
if err != nil {
t.Fatalf("Create: %v", err)

View file

@ -16,6 +16,8 @@ const (
Persistent LifecyclePolicy = "persistent"
)
const DefaultStopTimeout = 2 * time.Second
type Pool struct {
Name string
Addr string
@ -24,6 +26,7 @@ type Pool struct {
MaxTotal int
IdleTimeout time.Duration
MaxLifetime time.Duration
StopTimeout time.Duration // SIGTERM grace period before SIGKILL; 0 = DefaultStopTimeout
}
type PoolInfo struct {
@ -59,6 +62,12 @@ type CreateOptions struct {
Ports []PortMapping
Policy LifecyclePolicy
IdleTimeout time.Duration
StopTimeout time.Duration // SIGTERM grace period; 0 = pool default or DefaultStopTimeout
WorkspaceID string // workspace to mount; empty = no workspace
MountMode string // "rw" (default) or "ro"
MountPath string // container path; default "/workspace"
}
type ListOptions struct {
@ -133,6 +142,18 @@ func WithHeaders(headers map[string]string) AttachOption {
}
}
// ImagePullOptions configures an image pull operation.
type ImagePullOptions struct {
Auth *RegistryAuth // nil = anonymous / public
}
// RegistryAuth holds credentials for a private container registry.
type RegistryAuth struct {
Username string
Password string
Server string
}
type ServiceConn struct {
Read func() ([]byte, error)
Write func(data []byte) error

View file

@ -0,0 +1,20 @@
package sandbox
import "github.com/docker/docker/client"
// dockerCliAccessor is implemented by sandbox types that hold a Docker client.
type dockerCliAccessor interface {
dockerClient() *client.Client
}
func (l *local) dockerClient() *client.Client { return l.core.cli }
func (d *dockerSandbox) dockerClient() *client.Client { return d.core.cli }
// DockerCli extracts the underlying Docker SDK client from a Sandbox.
// Returns nil if the Sandbox is not Docker-based (e.g. K8s).
func DockerCli(sb Sandbox) *client.Client {
if a, ok := sb.(dockerCliAccessor); ok {
return a.dockerClient()
}
return nil
}

43
tai/sandbox/image.go Normal file
View file

@ -0,0 +1,43 @@
package sandbox
import (
"context"
"time"
)
// Image manages container images on a runtime node.
type Image interface {
Exists(ctx context.Context, ref string) (bool, error)
Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error)
Remove(ctx context.Context, ref string, force bool) error
List(ctx context.Context) ([]ImageInfo, error)
}
// PullOptions configures an image pull operation.
type PullOptions struct {
Auth *RegistryAuth // nil = anonymous / public
}
// RegistryAuth holds credentials for a private container registry.
type RegistryAuth struct {
Username string
Password string
Server string // e.g. "ghcr.io", "registry.example.com"
}
// PullProgress reports real-time progress of an image pull.
type PullProgress struct {
Status string // "Pulling fs layer", "Downloading", "Extracting", "Pull complete", etc.
Layer string // layer digest / short ID
Current int64 // bytes completed
Total int64 // bytes total (0 if unknown)
Error string // non-empty on failure
}
// ImageInfo describes a local image.
type ImageInfo struct {
ID string
Tags []string
Size int64
Created time.Time
}

132
tai/sandbox/image_docker.go Normal file
View file

@ -0,0 +1,132 @@
package sandbox
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"time"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/client"
)
// dockerImage implements Image using the Docker SDK.
// Shared by both local and dockerSandbox (via Tai proxy) modes.
type dockerImage struct {
cli *client.Client
}
// NewDockerImage creates an Image backed by a Docker client.
func NewDockerImage(cli *client.Client) Image {
return &dockerImage{cli: cli}
}
func (d *dockerImage) Exists(ctx context.Context, ref string) (bool, error) {
_, _, err := d.cli.ImageInspectWithRaw(ctx, ref)
if err != nil {
if client.IsErrNotFound(err) {
return false, nil
}
return false, fmt.Errorf("image inspect %q: %w", ref, err)
}
return true, nil
}
func (d *dockerImage) Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error) {
pullOpts := image.PullOptions{}
if opts.Auth != nil {
encoded, err := encodeAuth(opts.Auth)
if err != nil {
return nil, err
}
pullOpts.RegistryAuth = encoded
}
reader, err := d.cli.ImagePull(ctx, ref, pullOpts)
if err != nil {
return nil, fmt.Errorf("image pull %q: %w", ref, err)
}
ch := make(chan PullProgress, 32)
go func() {
defer close(ch)
defer reader.Close()
decodePullStream(reader, ch)
}()
return ch, nil
}
func (d *dockerImage) Remove(ctx context.Context, ref string, force bool) error {
_, err := d.cli.ImageRemove(ctx, ref, image.RemoveOptions{Force: force, PruneChildren: true})
if err != nil {
return fmt.Errorf("image remove %q: %w", ref, err)
}
return nil
}
func (d *dockerImage) List(ctx context.Context) ([]ImageInfo, error) {
imgs, err := d.cli.ImageList(ctx, image.ListOptions{})
if err != nil {
return nil, fmt.Errorf("image list: %w", err)
}
result := make([]ImageInfo, len(imgs))
for i, img := range imgs {
result[i] = ImageInfo{
ID: img.ID,
Tags: img.RepoTags,
Size: img.Size,
Created: time.Unix(img.Created, 0),
}
}
return result, nil
}
// dockerPullEvent mirrors the JSON lines emitted by Docker's ImagePull stream.
type dockerPullEvent struct {
Status string `json:"status"`
ID string `json:"id"`
ProgressDetail struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
} `json:"progressDetail"`
Error string `json:"error"`
}
func decodePullStream(r io.Reader, ch chan<- PullProgress) {
dec := json.NewDecoder(r)
for {
var ev dockerPullEvent
if err := dec.Decode(&ev); err != nil {
if err != io.EOF {
ch <- PullProgress{Error: err.Error()}
}
return
}
p := PullProgress{
Status: ev.Status,
Layer: ev.ID,
Current: ev.ProgressDetail.Current,
Total: ev.ProgressDetail.Total,
}
if ev.Error != "" {
p.Error = ev.Error
}
ch <- p
}
}
func encodeAuth(auth *RegistryAuth) (string, error) {
cfg := registry.AuthConfig{
Username: auth.Username,
Password: auth.Password,
ServerAddress: auth.Server,
}
data, err := json.Marshal(cfg)
if err != nil {
return "", fmt.Errorf("encode registry auth: %w", err)
}
return base64.URLEncoding.EncodeToString(data), nil
}

25
tai/sandbox/image_k8s.go Normal file
View file

@ -0,0 +1,25 @@
package sandbox
import "context"
// k8sImage is a no-op Image for K8s mode.
// Image pulling is handled by kubelet based on imagePullPolicy and imagePullSecrets.
type k8sImage struct{}
func NewK8sImage() Image { return &k8sImage{} }
func (k *k8sImage) Exists(_ context.Context, _ string) (bool, error) {
return true, nil
}
func (k *k8sImage) Pull(_ context.Context, _ string, _ PullOptions) (<-chan PullProgress, error) {
return nil, nil
}
func (k *k8sImage) Remove(_ context.Context, _ string, _ bool) error {
return nil
}
func (k *k8sImage) List(_ context.Context) ([]ImageInfo, error) {
return nil, nil
}

View file

@ -113,7 +113,7 @@ func (s *k8sSandbox) Create(ctx context.Context, opts CreateOptions) (string, er
container := corev1.Container{
Name: "main",
Image: opts.Image,
Command: opts.Cmd,
Args: opts.Cmd,
Env: envVars,
WorkingDir: opts.WorkingDir,
}
@ -160,9 +160,16 @@ func (s *k8sSandbox) Create(ctx context.Context, opts CreateOptions) (string, er
}
func (s *k8sSandbox) Start(ctx context.Context, id string) error {
// K8s pods start automatically after creation.
// Wait briefly for the pod to leave Pending.
for i := 0; i < 30; i++ {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 60*time.Second)
defer cancel()
}
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
pod, err := s.cli.CoreV1().Pods(s.ns).Get(ctx, id, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("get pod: %w", err)
@ -170,9 +177,13 @@ func (s *k8sSandbox) Start(ctx context.Context, id string) error {
if pod.Status.Phase == corev1.PodRunning || pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed {
return nil
}
time.Sleep(1 * time.Second)
select {
case <-ctx.Done():
return fmt.Errorf("pod %s did not reach Running: %w", id, ctx.Err())
case <-ticker.C:
}
}
return fmt.Errorf("pod %s did not reach Running within 30s", id)
}
func (s *k8sSandbox) Stop(ctx context.Context, id string, timeout time.Duration) error {

View file

@ -3,7 +3,6 @@ package sandbox
import (
"context"
"fmt"
"runtime"
"time"
"github.com/docker/docker/client"
@ -36,7 +35,7 @@ func NewLocal(addr string) (Sandbox, error) {
}
func (l *local) Create(ctx context.Context, opts CreateOptions) (string, error) {
return l.core.create(ctx, opts, opts.VNC && needsPortMapping())
return l.core.create(ctx, opts, opts.VNC)
}
func (l *local) Start(ctx context.Context, id string) error {
@ -71,12 +70,6 @@ func (l *local) Close() error {
return l.core.cli.Close()
}
// needsPortMapping returns true on platforms where container IPs are not
// directly reachable (macOS Docker Desktop, Windows).
func needsPortMapping() bool {
return runtime.GOOS == "darwin" || runtime.GOOS == "windows"
}
func portStr(p int) string {
if p == 0 {
return ""

View file

@ -77,6 +77,12 @@ func WithNamespace(ns string) Option {
return optionFunc(func(c *config) { c.namespace = ns })
}
// WithVolume injects a custom Volume implementation.
// Useful for testing workspace operations without Docker.
func WithVolume(vol volume.Volume) Option {
return optionFunc(func(c *config) { c.volume = vol })
}
type config struct {
runtime Runtime
ports Ports
@ -85,6 +91,7 @@ type config struct {
dataDir string
kubeConfig string
namespace string
volume volume.Volume // override volume (for testing without Docker)
}
func defaultPorts() Ports {
@ -121,8 +128,10 @@ type Client struct {
host string
addr string
ports Ports
dataDir string // host-side data directory for local volume
vol volume.Volume
sb sandbox.Sandbox
img sandbox.Image
prx proxy.Proxy
vc vnc.VNC
grpcConn *grpc.ClientConn
@ -170,18 +179,27 @@ func New(addr string, opts ...Option) (*Client, error) {
func (c *Client) initLocal(cfg *config) (*Client, error) {
sb, err := sandbox.NewLocal(c.addr)
if err != nil {
if err != nil && cfg.volume == nil {
return nil, err
}
c.sb = sb
c.prx = proxy.NewLocal(sb)
c.vc = vnc.NewLocal(sb)
dataDir := cfg.dataDir
if dataDir == "" {
dataDir = "/tmp/tai-volumes"
if sb != nil {
c.sb = sb
c.img = sandbox.NewDockerImage(sandbox.DockerCli(sb))
c.prx = proxy.NewLocal(sb)
c.vc = vnc.NewLocal(sb)
}
if cfg.volume != nil {
c.vol = cfg.volume
c.dataDir = cfg.dataDir
} else {
dataDir := cfg.dataDir
if dataDir == "" {
dataDir = "/tmp/tai-volumes"
}
c.dataDir = dataDir
c.vol = volume.NewLocal(dataDir)
}
c.vol = volume.NewLocal(dataDir)
return c, nil
}
@ -219,6 +237,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) {
return nil, err
}
c.sb = sb
c.img = sandbox.NewK8sImage()
default:
dockerPort := c.ports.Docker
if dockerPort == 0 {
@ -231,6 +250,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) {
return nil, err
}
c.sb = sb
c.img = sandbox.NewDockerImage(sandbox.DockerCli(sb))
}
hc := cfg.httpClient
@ -266,6 +286,10 @@ func (c *Client) Close() error {
// Volume returns the Volume IO layer. Never nil.
func (c *Client) Volume() volume.Volume { return c.vol }
// DataDir returns the host-side data directory used by the local volume.
// Empty for remote (Tai gRPC) connections — the Tai server manages paths.
func (c *Client) DataDir() string { return c.dataDir }
// Workspace returns an fs.FS-compatible filesystem for the given session.
func (c *Client) Workspace(sessionID string) workspace.FS {
return workspace.New(c.vol, sessionID)
@ -274,6 +298,9 @@ func (c *Client) Workspace(sessionID string) workspace.FS {
// Sandbox returns the container lifecycle manager. Never nil.
func (c *Client) Sandbox() sandbox.Sandbox { return c.sb }
// Image returns the container image manager. Never nil.
func (c *Client) Image() sandbox.Image { return c.img }
// Proxy returns the HTTP reverse proxy helper. Never nil.
func (c *Client) Proxy() proxy.Proxy { return c.prx }

600
workspace/DESIGN.md Normal file
View file

@ -0,0 +1,600 @@
# Workspace Design Document
> **Status**: Draft
> **Module**: `workspace` (top-level, parallel to `sandbox/v2`)
> **Depends on**: `tai` SDK (Volume, VolumeProvider, Sandbox), `sandbox/v2` Manager
---
## Overview
Workspace is a **first-class, persistent storage entity** independent of containers, chat sessions, and user sessions. It represents a user's project files — source code, configs, build artifacts — that can be mounted into any number of ephemeral containers.
Workspace is the **anchor point** for container scheduling: when a Workspace is created on a specific Tai node (host machine), all subsequent containers that reference it are automatically routed to the same node, because bind mounts require co-location on the same physical host.
---
## Problem
Current design: `Box.Workspace()` returns `workspace.FS` keyed by `box.id` — workspace and container are 1:1, same lifecycle. This couples file storage to container lifetime.
Real usage pattern:
```
User creates a project → uploads files → works on it across multiple chat sessions
→ attaches a long-running dev server → destroys/rebuilds containers freely
→ project files must survive all of this
```
Workspace must outlive containers. It is the persistent artifact; containers are disposable compute.
---
## Architecture
```
┌──────────────────────────────────────────────────┐
│ Application Layer │
│ │
│ Workspace Management UI Chat Interface │
│ ┌─────────────────────┐ ┌─────────────────┐ │
│ │ Create / Delete / UI │ │ Select Workspace│ │
│ │ Browse / Upload │ │ Start Chat │ │
│ └─────────┬───────────┘ └────────┬────────┘ │
│ │ │ │
└────────────┼─────────────────────────┼────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ Yao Engine │
│ │
│ workspace.Manager sandbox.Manager │
│ ┌────────────────┐ ┌─────────────────┐ │
│ │ CRUD │◄────────│ Mount workspace │ │
│ │ File I/O │ │ Route to node │ │
│ │ Node binding │ │ Create container│ │
│ └────────┬───────┘ └────────┬────────┘ │
│ │ │ │
└───────────┼───────────────────────────┼───────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ Tai Node (Host) │
│ │
│ Volume gRPC Container Runtime │
│ ┌──────────────┐ ┌─────────────────────┐ │
│ │ ReadFile │ │ Container A (rw) │ │
│ │ WriteFile │ │ └─ /workspace ─┐ │ │
│ │ ListDir │ │ │ │ │
│ │ SyncPush/Pull │ │ Container B (ro) │ │ │
│ └──────┬───────┘ │ └─ /workspace ─┐│ │ │
│ │ └────────────────┼┼───┘ │
│ │ ││ │
│ ▼ ▼▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ /data/ws/{workspace-id}/ │ │
│ │ ├── .workspace.json (metadata) │ │
│ │ ├── src/ │ │
│ │ ├── package.json │ │
│ │ └── ... │ │
│ └──────────────────────────────────────────┘ │
│ │
│ VolumeProvider │
│ ┌─────────────┬──────────────┬──────────────┐ │
│ │ BindMount │ DockerVolume │ K8s PVC │ │
│ │ (default) │ │ │ │
│ └─────────────┴──────────────┴──────────────┘ │
└───────────────────────────────────────────────────┘
```
---
## Core Design
### Node Binding
Workspace is physically stored on a Tai node's disk. **Bind mount requires Workspace and container to be on the same host.** Therefore:
- **Workspace binds to a specific Tai node at creation time.** This binding is immutable.
- When a container references a Workspace (`CreateOptions.WorkspaceID`), the container is **automatically routed to the same Tai node** — the caller does not (and should not) specify a Pool.
- One Tai node = one Pool = one host machine. These are equivalent in the current architecture.
```
创建 Workspace:
用户选择节点 "gpu-server" → workspace.Create(opts)
→ Tai "gpu-server" 上创建 /data/ws/ws-123/
创建容器(选了 Workspace:
→ sandbox.Create(opts, WorkspaceID: "ws-123")
→ Manager 查到 ws-123 绑在 "gpu-server"
→ 自动路由到 "gpu-server" Pool
→ bind mount /data/ws/ws-123:/workspace:rw ✓ 同机
创建容器(没选 Workspace:
→ 按原逻辑选 Pool用户指定或默认
```
This makes Workspace the **scheduling anchor**: once a Workspace is chosen, the node is determined.
### Workspace struct
```go
type Workspace struct {
ID string // unique identifier, e.g. "ws-abc123"
Name string // human-readable, e.g. "my-react-app"
Owner string // user ID
Node string // Tai node name (= Pool name); set at creation, immutable
Labels map[string]string // arbitrary metadata
CreatedAt time.Time
UpdatedAt time.Time
}
```
`Node` is the critical field: it pins this Workspace to a specific machine. All container operations referencing this Workspace are routed to this node.
No container references stored here. Workspace is pure storage — it doesn't know or care about containers.
### MountMode
```go
type MountMode string
const (
MountRW MountMode = "rw" // read-write (default)
MountRO MountMode = "ro" // read-only
)
```
Rules:
- A Workspace can be mounted by multiple containers simultaneously
- Each mount independently specifies `rw` or `ro`
- No write-lock enforcement — caller manages concurrency
- Default is `rw`
Rationale: In practice, Chat containers write source code and Runtime containers write build artifacts/logs — different files, no real conflict. Enforcing locks adds complexity without solving a real problem in this use case.
---
## API Design
### workspace.Manager
Workspace has its own manager, separate from `sandbox.Manager`. It owns Workspace CRUD and file I/O.
```go
package workspace
type Manager struct {
pools map[string]*tai.Client // node name → tai client (shared with sandbox.Manager)
}
// NewManager creates a workspace manager with the given pools.
// Pools are shared with sandbox.Manager — both reference the same tai.Client instances.
func NewManager(pools map[string]*tai.Client) *Manager
```
### Workspace CRUD
```go
type CreateOptions struct {
ID string // explicit ID; empty = auto-generate (uuid)
Name string // human-readable name
Owner string // user ID
Node string // target Tai node (required)
Labels map[string]string
}
type ListOptions struct {
Owner string // filter by owner; empty = all
Node string // filter by node; empty = all
}
// Create allocates storage on the target node and persists metadata.
func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, error)
// Get returns a workspace by ID.
// Checks the metadata file on the bound node.
func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error)
// List returns workspaces, optionally filtered.
func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, error)
// Delete removes workspace storage from the node.
// Fails if containers currently mount it (unless force=true).
func (m *Manager) Delete(ctx context.Context, id string, force bool) error
// Update modifies workspace metadata (Name, Labels).
// Node and Owner are immutable after creation.
func (m *Manager) Update(ctx context.Context, id string, opts UpdateOptions) (*Workspace, error)
type UpdateOptions struct {
Name *string // nil = no change
Labels map[string]string // nil = no change; non-nil replaces all
}
```
### File I/O (no container needed)
File operations go through the Tai `Volume` gRPC service, using the Workspace ID as the session identifier. No container is needed.
```go
// FS returns an fs.FS view of the workspace, backed by Tai Volume gRPC.
func (m *Manager) FS(ctx context.Context, id string) (workspace.FS, error)
// ReadFile reads a file from the workspace.
func (m *Manager) ReadFile(ctx context.Context, id string, path string) ([]byte, error)
// WriteFile writes a file to the workspace.
func (m *Manager) WriteFile(ctx context.Context, id string, path string, data []byte, perm os.FileMode) error
// ListDir lists entries in a workspace directory.
func (m *Manager) ListDir(ctx context.Context, id string, path string) ([]DirEntry, error)
// Remove deletes a file or directory from the workspace.
func (m *Manager) Remove(ctx context.Context, id string, path string) error
// SyncPush uploads a local directory tree to the workspace.
func (m *Manager) SyncPush(ctx context.Context, id string, localPath string) error
// SyncPull downloads the workspace to a local directory.
func (m *Manager) SyncPull(ctx context.Context, id string, localPath string) error
```
These are thin wrappers around `tai.Client.Volume().{ReadFile,WriteFile,ListDir,...}` — the Tai SDK already implements all of these.
---
## Integration with Sandbox
### sandbox.CreateOptions changes
```go
type CreateOptions struct {
// ... existing fields ...
WorkspaceID string // workspace to mount; empty = no workspace
MountMode MountMode // "rw" (default) or "ro"
MountPath string // container path; default "/workspace"
}
```
### Container creation flow
When `WorkspaceID` is set in `CreateOptions`, the sandbox Manager:
```
Manager.Create(ctx, CreateOptions{
Image: "yaoapp/workspace:latest",
WorkspaceID: "ws-abc123",
MountMode: MountRW,
})
1. Validate CreateOptions (image required, etc.)
2. If WorkspaceID is set:
a. ws := workspaceManager.Get(ctx, workspaceID)
b. Force Pool = ws.Node (override any user-specified Pool)
c. spec := taiClient.VolumeProvider().MountSpec(workspaceID)
d. Inject mount into container create:
- Docker: opts.Binds = ["/data/ws/ws-abc123:/workspace:rw"]
- K8s: opts.Volumes + opts.VolumeMounts (PVC)
3. Create container via tai.Client.Sandbox().Create()
4. Start container
5. Return Box
```
### Box.Workspace() behavior change
```go
func (b *Box) Workspace() workspace.FS {
sessionID := b.workspaceID
if sessionID == "" {
sessionID = b.id // backward compatible
}
client, _ := b.manager.getPool(b.pool)
return client.Workspace(sessionID)
}
```
Multiple boxes mounting the same workspace -> same `sessionID` -> same files via Volume API.
---
## Metadata Storage
Workspace metadata (ID, Name, Owner, Node, Labels, timestamps) is stored as a JSON file inside the workspace directory.
### Storage path
```
/data/ws/{id}/.workspace.json
```
### Schema
```json
{
"id": "ws-abc123",
"name": "my-react-app",
"owner": "user-001",
"node": "gpu-server",
"labels": {"project": "frontend"},
"created_at": "2026-03-05T10:00:00Z",
"updated_at": "2026-03-05T12:30:00Z"
}
```
### Operations
| Operation | Implementation |
|-----------|---------------|
| Create | `Volume.WriteFile(id, ".workspace.json", json)` + `Volume.ResolvePath(id)` |
| Get | `Volume.ReadFile(id, ".workspace.json")` → unmarshal |
| List | `Volume.ListDir("")` → iterate dirs → read `.workspace.json` each |
| Update | Read → merge → `Volume.WriteFile(id, ".workspace.json", json)` |
| Delete | `Volume.Cleanup(id)` (removes entire dir) |
Phase 1 strategy: simple JSON files, zero external dependencies. Can migrate to SQLite or Yao's built-in DB if query/filter performance becomes a bottleneck.
---
## Node Management
### Listing available nodes
Application layer needs to present available nodes when user creates a Workspace. This comes from the sandbox Manager's pool configuration:
```go
// In workspace.Manager or sandbox.Manager
func (m *Manager) Nodes() []NodeInfo
type NodeInfo struct {
Name string // pool name = node name, e.g. "gpu-server"
Addr string // tai:// address
Online bool // is tai client connected
// Can be extended with capacity info later
}
```
### Dynamic node configuration
Nodes are configured at the application level (Yao settings/config). When a node is added or removed, both `workspace.Manager` and `sandbox.Manager` share the updated pool map. The Pool configuration API (from `sandbox/v2`) handles this — Workspace inherits it.
```
Application Config:
nodes:
- name: "local"
addr: "tai://localhost"
- name: "gpu-server"
addr: "tai://192.168.1.100:9527"
→ Both managers share:
pools["local"] = tai.Client("tai://localhost")
pools["gpu-server"] = tai.Client("tai://192.168.1.100:9527")
```
### Node failure handling
If a Tai node goes offline:
- Workspace CRUD for that node: returns error (node unreachable)
- Container creation referencing a Workspace on that node: returns error
- Workspaces on that node are not lost — data is still on the node's disk, will be available when node comes back online
- No automatic migration (Phase 1). Can add migration (rsync between nodes) later if needed.
---
## User Flows
### Flow 1: Workspace management UI
```
1. User opens Workspace management UI
→ API: workspace.List(owner: "user-001")
→ Returns list of workspaces with metadata
2. User creates workspace
→ UI shows available nodes (from Nodes() API)
→ User selects "gpu-server"
→ API: workspace.Create({ name: "my-project", node: "gpu-server" })
→ Directory /data/ws/ws-123/ created on gpu-server
→ .workspace.json written
3. User uploads files
→ API: workspace.WriteFile("ws-123", "src/main.go", data)
→ File written to /data/ws/ws-123/src/main.go via Volume gRPC
4. User browses files
→ API: workspace.ListDir("ws-123", "src/")
→ Returns file listing
5. User deletes workspace
→ API: workspace.Delete("ws-123")
→ Checks no active mounts → removes /data/ws/ws-123/
```
### Flow 2: Chat with Workspace
```
1. User opens Chat
→ Chat UI shows workspace selector
→ User picks "my-project" (ws-123, on node "gpu-server")
2. Agent needs a container:
→ sandbox.Create({
image: "yaoapp/workspace:latest",
workspace_id: "ws-123",
mount_mode: "rw",
})
→ Manager resolves ws-123.node = "gpu-server"
→ Container created on "gpu-server" Pool
→ -v /data/ws/ws-123:/workspace:rw
→ Agent can exec "ls /workspace/src/" inside container
3. Chat ends, container destroyed
→ Workspace files persist in /data/ws/ws-123/
4. User opens new Chat, selects same workspace
→ New container, same workspace, all files still there
```
### Flow 3: Long-running Runtime + Chat
```
1. User starts Runtime container for workspace:
→ sandbox.Create({
image: "node:20",
workspace_id: "ws-123",
mount_mode: "rw",
policy: "persistent",
ports: [{ container: 3000 }],
})
→ Container starts on "gpu-server"
→ -v /data/ws/ws-123:/workspace:rw
→ Inside: cd /workspace && npm install && npm run dev
2. User accesses dev server via proxy
→ box.Proxy(ctx, 3000, "/")
3. User opens Chat with same workspace:
→ Second container created on "gpu-server"
→ Same workspace mounted
→ Agent modifies source → Runtime hot-reloads
4. Chat ends, chat container destroyed
→ Runtime container keeps running
→ Workspace files persist
```
---
## Process & JSAPI
### Process registration
| Process | Args | Returns |
|---------|------|---------|
| `workspace.Create` | `options` (CreateOptions JSON) | Workspace |
| `workspace.Get` | `id` | Workspace |
| `workspace.List` | `options` (ListOptions JSON) | []Workspace |
| `workspace.Update` | `id`, `options` (UpdateOptions JSON) | Workspace |
| `workspace.Delete` | `id`, `force?` | — |
| `workspace.ReadFile` | `id`, `path` | file content |
| `workspace.WriteFile` | `id`, `path`, `data` | — |
| `workspace.ListDir` | `id`, `path` | []DirEntry |
| `workspace.Remove` | `id`, `path` | — |
| `workspace.Nodes` | — | []NodeInfo |
### JSAPI
```javascript
// Workspace CRUD
var ws = Workspace.Create({ name: "my-project", node: "gpu-server" })
var ws = Workspace.Get("ws-abc123")
var list = Workspace.List({ owner: "user-001" })
Workspace.Update("ws-abc123", { name: "new-name" })
Workspace.Delete("ws-abc123")
// File operations (no container needed)
var data = Workspace.ReadFile("ws-abc123", "src/main.go")
Workspace.WriteFile("ws-abc123", "src/main.go", "package main\n...")
var entries = Workspace.ListDir("ws-abc123", "src/")
Workspace.Remove("ws-abc123", "tmp.txt")
// List available nodes
var nodes = Workspace.Nodes()
// → [{ name: "local", addr: "tai://localhost", online: true },
// { name: "gpu-server", addr: "tai://192.168.1.100:9527", online: true }]
// Create container with workspace (via Sandbox API)
var sb = Sandbox("my-box", {
image: "node:20",
workspace_id: ws.id, // → auto-routes to ws.node
mount_mode: "rw",
})
```
---
## Storage Backend (Tai)
The `storage.VolumeProvider` interface in Tai Server already has three implementations:
```go
// tai/storage/provider.go
type VolumeProvider interface {
ResolvePath(sessionID string) (string, error)
MountSpec(sessionID string) MountConfig
Cleanup(sessionID string) error
}
type MountConfig struct {
Type string // "bind" | "volume" | "pvc"
Source string
Target string // always /workspace
}
```
| Provider | Backend | MountSpec | Status |
|----------|---------|-----------|--------|
| `BindMountProvider` | Host directory (`/data/ws/{id}/`) | `type:"bind"` | Implemented, default |
| `DockerVolumeProvider` | Docker named volume (`tai-{id}`) | `type:"volume"` | Implemented |
| `K8sPVCProvider` | K8s PVC (`tai-{id}-pvc`, 10Gi RWO) | `type:"pvc"` | Implemented |
Default is `BindMountProvider` for Docker environments (direct host path access for file CRUD). K8s environments use `K8sPVCProvider`.
The Tai `Volume` gRPC service (`ReadFile`, `WriteFile`, `ListDir`, etc.) already operates on the same `dataDir/{sessionID}/` paths. No additional work needed — Workspace file operations reuse existing Volume gRPC endpoints.
---
## Comparison: Before vs After
| Aspect | Before | After |
|--------|--------|-------|
| Workspace lifecycle | Tied to Box (same ID, same lifetime) | Independent entity, outlives containers |
| Workspace identity | `sessionID = box.id` | `sessionID = workspace.id` (explicit) |
| Container ↔ Workspace | 1:1, implicit | N:1, explicit via `CreateOptions.WorkspaceID` |
| Container scheduling | User picks Pool | Workspace determines Pool (node binding) |
| File persistence | Lost when container removed | Persists until workspace deleted |
| Multi-container access | Not possible | Multiple containers mount same workspace |
| Storage backend | Volume gRPC only (no mount) | Volume gRPC + bind mount into container |
| CRUD without container | Not possible | Via Volume API directly |
| Module status | Part of sandbox/v2 | Top-level module, parallel to sandbox/v2 |
---
## Implementation Plan
### Phase 1: Core (target: week 1-2)
| Task | Detail |
|------|--------|
| `workspace/workspace.go` | Workspace struct, MountMode, CreateOptions, metadata JSON read/write |
| `workspace/manager.go` | Manager with CRUD + file I/O (thin wrapper over tai Volume) |
| `workspace/manager_test.go` | Unit tests for CRUD and file operations |
| Node binding | `Workspace.Node` field, `Nodes()` API |
| `sandbox/v2` integration | `CreateOptions.WorkspaceID` → resolve node → force Pool → inject mount |
| `Box.Workspace()` update | Use `workspaceID` as sessionID when set |
### Phase 2: Wire into Tai (target: week 2-3)
| Task | Detail |
|------|--------|
| Tai Server: `VolumeProvider.MountSpec()` | Wire into container creation path |
| Tai gRPC: workspace metadata endpoints | Optional — can use Volume gRPC directly for Phase 1 |
| Process + JSAPI registration | `workspace.*` processes, JS bindings |
### Phase 3: Advanced (target: week 3+)
| Task | Detail |
|------|--------|
| Active mount tracking | Track which containers mount which workspaces |
| Delete safety | Refuse delete if active mounts exist |
| Workspace migration | rsync between nodes (stretch goal) |
| Quota / size limits | Per-workspace storage limits |
| Snapshot / backup | Workspace snapshots for rollback |
### Backward Compatibility
No breaking changes. Containers created without `WorkspaceID` work exactly as before:
- `sessionID = box.id`
- No bind mount
- Workspace FS backed by Volume gRPC as today

36
workspace/Makefile Normal file
View file

@ -0,0 +1,36 @@
GO ?= go
TEST_TIMEOUT ?= 120s
.PHONY: test test-v test-cover test-race
test:
$(GO) test -timeout=$(TEST_TIMEOUT) -count=1 ./...
test-v:
$(GO) test -v -timeout=$(TEST_TIMEOUT) -count=1 ./...
test-cover:
$(GO) test -v -timeout=$(TEST_TIMEOUT) -count=1 \
-coverprofile=coverage.out -covermode=count ./...
$(GO) tool cover -func=coverage.out | tail -1
test-race:
$(GO) test -race -v -timeout=$(TEST_TIMEOUT) -count=1 ./...
test-ci:
@echo "mode: count" > coverage.out
@for d in $$($(GO) list ./...); do \
$(GO) test -v -timeout=$(TEST_TIMEOUT) -count=1 \
-covermode=count -coverprofile=profile.out \
-coverpkg=$$d $$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm -f tmp.out profile.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
grep -v "mode:" profile.out >> coverage.out; \
rm profile.out; \
fi; \
rm -f tmp.out; \
done

74
workspace/TEST.md Normal file
View file

@ -0,0 +1,74 @@
# Workspace — Test Specification
Design: [DESIGN.md](./DESIGN.md)
## Principles
- **Black-box testing**: all `*_test.go` files use `package workspace_test` — tests only access exported API
- **No Docker required**: workspace unit tests use `volume.NewLocal(t.TempDir())` via `tai.WithVolume` — no Docker daemon needed
- **Skip when unavailable**: `skipIfNoTai(t)` for remote-mode tests
- **Tests follow implementation**: `*_test.go` lives next to the code it tests
- **Coverage > 80%**: per file and overall
## Prerequisites
No external services required for unit tests. Tests create a temp directory for storage.
### Remote mode (optional)
For remote-mode tests via Tai gRPC:
```bash
SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1 go test -v ./workspace/
```
## Directory Structure
```
workspace/
├── workspace.go # Types (Workspace, CreateOptions, MountMode, etc.)
├── errors.go # Error definitions
├── manager.go # Manager (CRUD, file I/O, Nodes)
├── workspace_test.go # CRUD tests
├── fileio_test.go # File I/O + FS tests
├── testutils_test.go # Shared test helpers
├── DESIGN.md # Design document
├── TEST.md # This file
└── Makefile # Test runner
```
## testutils (internal to workspace_test)
```go
// testutils_test.go
package workspace_test
func setupManager(t *testing.T) *workspace.Manager
func setupManagerMultiNode(t *testing.T) *workspace.Manager
func localClient(t *testing.T, dataDir string) *tai.Client
func createTestWorkspace(t *testing.T, m *workspace.Manager, opts ...func(*workspace.CreateOptions)) *workspace.Workspace
func skipIfNoTai(t *testing.T)
```
## Required Test Cases
| File | Required Cases |
|------|---------------|
| `workspace_test.go` | Create / Create auto ID / Create explicit ID / Create with labels / Create invalid node / Create node not found / Get / Get not found / List / List filter owner / List filter node / Update name / Update labels / Update not found / Delete / Delete not found / Nodes / NodeForWorkspace / NodeForWorkspace not found |
| `fileio_test.go` | ReadWriteFile / WriteFile nested path / ListDir / Remove file / FS ReadFile / FS WriteFile / FS MkdirAll / FS Rename / FS WalkDir / FS Remove / FS not found |
## Running Tests
```bash
# All workspace tests (no Docker needed)
make -C workspace test
# Single test
go test -v ./workspace/ -run TestCreate
# With race detector
go test -race -v ./workspace/
# With coverage
go test -v -coverprofile=coverage.out ./workspace/
```

199
workspace/bench_test.go Normal file
View file

@ -0,0 +1,199 @@
package workspace_test
import (
"context"
"fmt"
"io/fs"
"testing"
"github.com/yaoapp/yao/workspace"
)
// BenchmarkWriteFile measures workspace file write latency.
func BenchmarkWriteFile(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ws := createWorkspace(b, m, pc.Name)
ctx := context.Background()
payload := []byte("package main\nfunc main() { println(\"bench\") }\n")
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := m.WriteFile(ctx, ws.ID, fmt.Sprintf("f%d.go", i), payload, 0644); err != nil {
b.Fatalf("WriteFile: %v", err)
}
}
})
}
}
// BenchmarkReadFile measures workspace file read latency.
func BenchmarkReadFile(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ws := createWorkspace(b, m, pc.Name)
ctx := context.Background()
if err := m.WriteFile(ctx, ws.ID, "bench.txt", []byte("benchmark data here"), 0644); err != nil {
b.Fatalf("setup WriteFile: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
data, err := m.ReadFile(ctx, ws.ID, "bench.txt")
if err != nil {
b.Fatalf("ReadFile: %v", err)
}
if len(data) == 0 {
b.Fatal("empty data")
}
}
})
}
}
// BenchmarkReadWriteCycle measures a full write-then-read cycle.
func BenchmarkReadWriteCycle(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ws := createWorkspace(b, m, pc.Name)
ctx := context.Background()
payload := []byte("package main\nfunc main() { println(\"cycle\") }\n")
b.ResetTimer()
for i := 0; i < b.N; i++ {
name := fmt.Sprintf("c%d.go", i)
if err := m.WriteFile(ctx, ws.ID, name, payload, 0644); err != nil {
b.Fatalf("WriteFile: %v", err)
}
data, err := m.ReadFile(ctx, ws.ID, name)
if err != nil {
b.Fatalf("ReadFile: %v", err)
}
if len(data) != len(payload) {
b.Fatalf("size mismatch: %d vs %d", len(data), len(payload))
}
}
})
}
}
// BenchmarkWriteLargeFile measures write throughput with a 1MB payload.
func BenchmarkWriteLargeFile(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ws := createWorkspace(b, m, pc.Name)
ctx := context.Background()
payload := make([]byte, 1<<20) // 1 MB
for i := range payload {
payload[i] = byte('A' + i%26)
}
b.SetBytes(int64(len(payload)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := m.WriteFile(ctx, ws.ID, fmt.Sprintf("large%d.bin", i), payload, 0644); err != nil {
b.Fatalf("WriteFile: %v", err)
}
}
})
}
}
// BenchmarkListDir measures directory listing latency (50 files).
func BenchmarkListDir(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ws := createWorkspace(b, m, pc.Name)
ctx := context.Background()
for i := 0; i < 50; i++ {
m.WriteFile(ctx, ws.ID, fmt.Sprintf("file%d.txt", i), []byte("x"), 0644)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
entries, err := m.ListDir(ctx, ws.ID, ".")
if err != nil {
b.Fatalf("ListDir: %v", err)
}
if len(entries) < 50 {
b.Fatalf("expected >= 50 entries, got %d", len(entries))
}
}
})
}
}
// BenchmarkFSWalkDir measures fs.WalkDir performance over a directory tree (45+ entries).
func BenchmarkFSWalkDir(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ws := createWorkspace(b, m, pc.Name)
ctx := context.Background()
wfs, err := m.FS(ctx, ws.ID)
if err != nil {
b.Fatalf("FS: %v", err)
}
for _, dir := range []string{"src", "src/pkg", "src/cmd", "lib"} {
wfs.MkdirAll(dir, 0755)
}
for i := 0; i < 20; i++ {
wfs.WriteFile(fmt.Sprintf("src/f%d.go", i), []byte("package src"), 0644)
}
for i := 0; i < 10; i++ {
wfs.WriteFile(fmt.Sprintf("src/pkg/p%d.go", i), []byte("package pkg"), 0644)
}
for i := 0; i < 10; i++ {
wfs.WriteFile(fmt.Sprintf("lib/l%d.go", i), []byte("package lib"), 0644)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
count := 0
fs.WalkDir(wfs, ".", func(_ string, _ fs.DirEntry, err error) error {
if err != nil {
return err
}
count++
return nil
})
if count < 40 {
b.Fatalf("walk returned only %d entries", count)
}
}
})
}
}
// BenchmarkCreateDelete measures workspace CRUD cycle.
func BenchmarkCreateDelete(b *testing.B) {
for _, pc := range testPools() {
b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForPool(b, pc)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
ws, err := m.Create(ctx, workspace.CreateOptions{
Name: "bench-workspace",
Owner: "bench-user",
Node: pc.Name,
})
if err != nil {
b.Fatalf("Create: %v", err)
}
if err := m.Delete(ctx, ws.ID, true); err != nil {
b.Fatalf("Delete: %v", err)
}
}
})
}
}

10
workspace/errors.go Normal file
View file

@ -0,0 +1,10 @@
package workspace
import "errors"
var (
ErrNotFound = errors.New("workspace: not found")
ErrNodeMissing = errors.New("workspace: node is required")
ErrNodeOffline = errors.New("workspace: node is offline or not configured")
ErrHasMounts = errors.New("workspace: workspace has active container mounts")
)

232
workspace/fileio_test.go Normal file
View file

@ -0,0 +1,232 @@
package workspace_test
import (
"context"
"io/fs"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/workspace"
)
func TestReadWriteFile(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
err := m.WriteFile(ctx, ws.ID, "hello.txt", []byte("hello world"), 0644)
require.NoError(t, err)
data, err := m.ReadFile(ctx, ws.ID, "hello.txt")
require.NoError(t, err)
assert.Equal(t, "hello world", string(data))
})
}
}
func TestWriteFile_NestedPath(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
err := m.WriteFile(ctx, ws.ID, "src/main.go", []byte("package main"), 0644)
require.NoError(t, err)
data, err := m.ReadFile(ctx, ws.ID, "src/main.go")
require.NoError(t, err)
assert.Equal(t, "package main", string(data))
})
}
}
func TestListDir(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
require.NoError(t, m.WriteFile(ctx, ws.ID, "a.txt", []byte("a"), 0644))
require.NoError(t, m.WriteFile(ctx, ws.ID, "b.txt", []byte("b"), 0644))
entries, err := m.ListDir(ctx, ws.ID, ".")
require.NoError(t, err)
names := make(map[string]bool)
for _, e := range entries {
names[e.Name] = true
}
assert.True(t, names["a.txt"])
assert.True(t, names["b.txt"])
})
}
}
func TestRemoveFile(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
require.NoError(t, m.WriteFile(ctx, ws.ID, "tmp.txt", []byte("temp"), 0644))
err := m.Remove(ctx, ws.ID, "tmp.txt")
require.NoError(t, err)
_, err = m.ReadFile(ctx, ws.ID, "tmp.txt")
assert.Error(t, err)
})
}
}
func TestFS_ReadFile(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
require.NoError(t, m.WriteFile(ctx, ws.ID, "test.txt", []byte("via fs"), 0644))
wfs, err := m.FS(ctx, ws.ID)
require.NoError(t, err)
data, err := fs.ReadFile(wfs, "test.txt")
require.NoError(t, err)
assert.Equal(t, "via fs", string(data))
})
}
}
func TestFS_WriteFile(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
wfs, err := m.FS(ctx, ws.ID)
require.NoError(t, err)
err = wfs.WriteFile("from-fs.txt", []byte("written via fs"), 0644)
require.NoError(t, err)
data, err := m.ReadFile(ctx, ws.ID, "from-fs.txt")
require.NoError(t, err)
assert.Equal(t, "written via fs", string(data))
})
}
}
func TestFS_MkdirAll(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
wfs, err := m.FS(ctx, ws.ID)
require.NoError(t, err)
err = wfs.MkdirAll("a/b/c", 0755)
require.NoError(t, err)
info, err := fs.Stat(wfs, "a/b/c")
require.NoError(t, err)
assert.True(t, info.IsDir())
})
}
}
func TestFS_Rename(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
wfs, err := m.FS(ctx, ws.ID)
require.NoError(t, err)
err = wfs.WriteFile("old.txt", []byte("content"), 0644)
require.NoError(t, err)
err = wfs.Rename("old.txt", "new.txt")
require.NoError(t, err)
data, err := fs.ReadFile(wfs, "new.txt")
require.NoError(t, err)
assert.Equal(t, "content", string(data))
_, err = fs.ReadFile(wfs, "old.txt")
assert.Error(t, err)
})
}
}
func TestFS_WalkDir(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
wfs, err := m.FS(ctx, ws.ID)
require.NoError(t, err)
require.NoError(t, wfs.MkdirAll("src", 0755))
require.NoError(t, wfs.WriteFile("src/main.go", []byte("package main"), 0644))
require.NoError(t, wfs.WriteFile("src/util.go", []byte("package main"), 0644))
var files []string
err = fs.WalkDir(wfs, "src", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
files = append(files, path)
}
return nil
})
require.NoError(t, err)
assert.Len(t, files, 2)
})
}
}
func TestFS_Remove(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
ctx := context.Background()
wfs, err := m.FS(ctx, ws.ID)
require.NoError(t, err)
require.NoError(t, wfs.WriteFile("removeme.txt", []byte("bye"), 0644))
err = wfs.Remove("removeme.txt")
require.NoError(t, err)
_, err = fs.ReadFile(wfs, "removeme.txt")
assert.Error(t, err)
})
}
}
func TestFS_NotFound(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
_, err := m.FS(context.Background(), "nonexistent")
assert.ErrorIs(t, err, workspace.ErrNotFound)
})
}
}

319
workspace/manager.go Normal file
View file

@ -0,0 +1,319 @@
package workspace
import (
"context"
"fmt"
"os"
"sync"
"time"
"github.com/yaoapp/yao/tai"
taiworkspace "github.com/yaoapp/yao/tai/workspace"
)
// Manager owns workspace CRUD, file I/O, and node management.
// Pools are shared with sandbox.Manager — both reference the same tai.Client instances.
type Manager struct {
pools map[string]*tai.Client
mu sync.RWMutex
}
// NewManager creates a workspace manager with the given pools.
func NewManager(pools map[string]*tai.Client) *Manager {
if pools == nil {
pools = make(map[string]*tai.Client)
}
return &Manager{pools: pools}
}
// Create allocates storage on the target node and persists metadata.
func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, error) {
if opts.Node == "" {
return nil, ErrNodeMissing
}
client, err := m.getClient(opts.Node)
if err != nil {
return nil, err
}
id := opts.ID
if id == "" {
id = generateID()
}
now := time.Now().UTC()
ws := &Workspace{
ID: id,
Name: opts.Name,
Owner: opts.Owner,
Node: opts.Node,
Labels: opts.Labels,
CreatedAt: now,
UpdatedAt: now,
}
vol := client.Volume()
if err := vol.MkdirAll(ctx, id, "."); err != nil {
return nil, fmt.Errorf("workspace: create directory: %w", err)
}
data, err := marshalMeta(ws)
if err != nil {
return nil, err
}
if err := vol.WriteFile(ctx, id, metadataFile, data, 0644); err != nil {
return nil, fmt.Errorf("workspace: write metadata: %w", err)
}
return ws, nil
}
// Get returns a workspace by ID.
// If the node is unknown, scans all pools.
func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for nodeName, client := range m.pools {
ws, err := m.readMeta(ctx, client, id)
if err != nil {
continue
}
if ws.Node == "" {
ws.Node = nodeName
}
return ws, nil
}
return nil, ErrNotFound
}
// List returns workspaces, optionally filtered by owner and/or node.
func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*Workspace
for nodeName, client := range m.pools {
if opts.Node != "" && nodeName != opts.Node {
continue
}
entries, err := client.Volume().ListDir(ctx, "", ".")
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir {
continue
}
ws, err := m.readMeta(ctx, client, e.Path)
if err != nil {
continue
}
if ws.Node == "" {
ws.Node = nodeName
}
if opts.Owner != "" && ws.Owner != opts.Owner {
continue
}
result = append(result, ws)
}
}
return result, nil
}
// Update modifies workspace metadata (Name, Labels).
// Node and Owner are immutable after creation.
func (m *Manager) Update(ctx context.Context, id string, opts UpdateOptions) (*Workspace, error) {
ws, client, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
if opts.Name != nil {
ws.Name = *opts.Name
}
if opts.Labels != nil {
ws.Labels = opts.Labels
}
ws.UpdatedAt = time.Now().UTC()
data, err := marshalMeta(ws)
if err != nil {
return nil, err
}
if err := client.Volume().WriteFile(ctx, id, metadataFile, data, 0644); err != nil {
return nil, fmt.Errorf("workspace: write metadata: %w", err)
}
return ws, nil
}
// Delete removes workspace storage from the node.
func (m *Manager) Delete(ctx context.Context, id string, force bool) error {
_, client, err := m.resolve(ctx, id)
if err != nil {
return err
}
vol := client.Volume()
if err := vol.Remove(ctx, id, ".", true); err != nil {
return fmt.Errorf("workspace: remove: %w", err)
}
return nil
}
// Nodes returns all configured Tai nodes with their online status.
func (m *Manager) Nodes() []NodeInfo {
m.mu.RLock()
defer m.mu.RUnlock()
nodes := make([]NodeInfo, 0, len(m.pools))
for name := range m.pools {
nodes = append(nodes, NodeInfo{
Name: name,
Online: true,
})
}
return nodes
}
// FS returns an fs.FS-compatible filesystem for the given workspace.
func (m *Manager) FS(ctx context.Context, id string) (taiworkspace.FS, error) {
ws, client, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
_ = ws
return client.Workspace(id), nil
}
// ReadFile reads a file from the workspace.
func (m *Manager) ReadFile(ctx context.Context, id string, path string) ([]byte, error) {
_, client, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
data, _, err := client.Volume().ReadFile(ctx, id, path)
return data, err
}
// WriteFile writes a file to the workspace.
func (m *Manager) WriteFile(ctx context.Context, id string, path string, data []byte, perm os.FileMode) error {
_, client, err := m.resolve(ctx, id)
if err != nil {
return err
}
return client.Volume().WriteFile(ctx, id, path, data, perm)
}
// ListDir lists entries in a workspace directory.
func (m *Manager) ListDir(ctx context.Context, id string, path string) ([]DirEntry, error) {
_, client, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
entries, err := client.Volume().ListDir(ctx, id, path)
if err != nil {
return nil, err
}
result := make([]DirEntry, len(entries))
for i, e := range entries {
result[i] = DirEntry{
Name: e.Path,
IsDir: e.IsDir,
Size: e.Size,
}
}
return result, nil
}
// Remove deletes a file or directory from the workspace.
func (m *Manager) Remove(ctx context.Context, id string, path string) error {
_, client, err := m.resolve(ctx, id)
if err != nil {
return err
}
return client.Volume().Remove(ctx, id, path, true)
}
// AddPool registers a new Tai node.
func (m *Manager) AddPool(name string, client *tai.Client) {
m.mu.Lock()
defer m.mu.Unlock()
m.pools[name] = client
}
// RemovePool unregisters a Tai node.
func (m *Manager) RemovePool(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.pools, name)
}
// NodeForWorkspace returns the node name for a given workspace ID.
// Used by sandbox.Manager to route container creation to the correct pool.
func (m *Manager) NodeForWorkspace(ctx context.Context, id string) (string, error) {
ws, _, err := m.resolve(ctx, id)
if err != nil {
return "", err
}
return ws.Node, nil
}
// MountPath returns the host-side directory path for a workspace,
// suitable for use as a Docker bind mount source.
// For local volumes this is dataDir/{id}; for remote (Tai) the server handles mounts.
func (m *Manager) MountPath(ctx context.Context, id string) (string, error) {
_, client, err := m.resolve(ctx, id)
if err != nil {
return "", err
}
dataDir := client.DataDir()
if dataDir == "" {
return "", nil
}
return dataDir + "/" + id, nil
}
// --- internal ---
func (m *Manager) getClient(node string) (*tai.Client, error) {
m.mu.RLock()
defer m.mu.RUnlock()
client, ok := m.pools[node]
if !ok {
return nil, ErrNodeOffline
}
return client, nil
}
// resolve finds the workspace and its tai.Client by scanning pools.
func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, *tai.Client, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, client := range m.pools {
ws, err := m.readMeta(ctx, client, id)
if err != nil {
continue
}
return ws, client, nil
}
return nil, nil, ErrNotFound
}
func (m *Manager) readMeta(ctx context.Context, client *tai.Client, id string) (*Workspace, error) {
data, _, err := client.Volume().ReadFile(ctx, id, metadataFile)
if err != nil {
return nil, err
}
return unmarshalMeta(data)
}
// DirEntry represents a file or directory entry in a workspace listing.
type DirEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
}

View file

@ -0,0 +1,89 @@
package workspace_test
import (
"context"
"os"
"testing"
"time"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/workspace"
)
type poolConfig struct {
Name string
Addr string
}
func testPools() []poolConfig {
pools := []poolConfig{
{Name: "local", Addr: "local"},
}
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
pools = append(pools, poolConfig{Name: "remote", Addr: addr})
}
return pools
}
func setupManagerForPool(tb testing.TB, pc poolConfig) *workspace.Manager {
tb.Helper()
client := clientForPool(tb, pc)
pools := map[string]*tai.Client{pc.Name: client}
return workspace.NewManager(pools)
}
func clientForPool(tb testing.TB, pc poolConfig) *tai.Client {
tb.Helper()
if pc.Addr == "local" {
return localClient(tb, tb.TempDir())
}
client, err := tai.New(pc.Addr)
if err != nil {
tb.Fatalf("tai.New(%s): %v", pc.Addr, err)
}
tb.Cleanup(func() { client.Close() })
return client
}
func localClient(tb testing.TB, dataDir string) *tai.Client {
tb.Helper()
vol := volume.NewLocal(dataDir)
client, err := tai.New("local", tai.WithVolume(vol), tai.WithDataDir(dataDir))
if err != nil {
tb.Fatalf("tai.New local: %v", err)
}
tb.Cleanup(func() { client.Close() })
return client
}
func setupManagerMultiNode(t *testing.T) *workspace.Manager {
t.Helper()
pools := map[string]*tai.Client{
"node-a": localClient(t, t.TempDir()),
"node-b": localClient(t, t.TempDir()),
}
return workspace.NewManager(pools)
}
func createWorkspace(tb testing.TB, m *workspace.Manager, node string, opts ...func(*workspace.CreateOptions)) *workspace.Workspace {
tb.Helper()
co := workspace.CreateOptions{
Name: "test-workspace",
Owner: "test-user",
Node: node,
}
for _, fn := range opts {
fn(&co)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ws, err := m.Create(ctx, co)
if err != nil {
tb.Fatalf("Create workspace: %v", err)
}
tb.Cleanup(func() {
m.Delete(context.Background(), ws.ID, true)
})
return ws
}

78
workspace/workspace.go Normal file
View file

@ -0,0 +1,78 @@
package workspace
import (
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
// MountMode controls read-write or read-only access when a workspace is
// bind-mounted into a container.
type MountMode string
const (
MountRW MountMode = "rw"
MountRO MountMode = "ro"
)
const metadataFile = ".workspace.json"
// Workspace is a persistent, user-managed storage entity.
// It is pinned to a specific Tai node (host machine) at creation time;
// containers referencing this workspace are automatically routed to that node.
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Node string `json:"node"`
Labels map[string]string `json:"labels,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateOptions configures a new workspace.
type CreateOptions struct {
ID string // explicit ID; empty = auto-generate (uuid)
Name string // human-readable name
Owner string // user ID
Node string // target Tai node (required)
Labels map[string]string // arbitrary metadata
}
// ListOptions filters workspace listing.
type ListOptions struct {
Owner string // filter by owner; empty = all
Node string // filter by node; empty = all
}
// UpdateOptions specifies which metadata fields to change.
// nil fields are left unchanged. Node and Owner are immutable.
type UpdateOptions struct {
Name *string // nil = no change
Labels map[string]string // nil = no change; non-nil replaces all labels
}
// NodeInfo describes a Tai node available for workspace storage.
type NodeInfo struct {
Name string // pool name = node name
Addr string // tai:// address
Online bool // tai client is connected
}
func generateID() string {
return fmt.Sprintf("ws-%s", uuid.New().String()[:12])
}
func marshalMeta(ws *Workspace) ([]byte, error) {
return json.MarshalIndent(ws, "", " ")
}
func unmarshalMeta(data []byte) (*Workspace, error) {
var ws Workspace
if err := json.Unmarshal(data, &ws); err != nil {
return nil, fmt.Errorf("workspace: invalid metadata: %w", err)
}
return &ws, nil
}

323
workspace/workspace_test.go Normal file
View file

@ -0,0 +1,323 @@
package workspace_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/workspace"
)
func TestCreate(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
assert.NotEmpty(t, ws.ID)
assert.Equal(t, "test-workspace", ws.Name)
assert.Equal(t, "test-user", ws.Owner)
assert.Equal(t, pc.Name, ws.Node)
assert.False(t, ws.CreatedAt.IsZero())
assert.False(t, ws.UpdatedAt.IsZero())
})
}
}
func TestCreate_AutoID(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
assert.True(t, len(ws.ID) > 0)
assert.Contains(t, ws.ID, "ws-")
})
}
}
func TestCreate_ExplicitID(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) {
co.ID = "my-custom-id"
})
assert.Equal(t, "my-custom-id", ws.ID)
})
}
}
func TestCreate_WithLabels(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) {
co.Labels = map[string]string{"project": "frontend", "env": "dev"}
})
assert.Equal(t, "frontend", ws.Labels["project"])
assert.Equal(t, "dev", ws.Labels["env"])
})
}
}
func TestCreate_InvalidNode(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
_, err := m.Create(context.Background(), workspace.CreateOptions{
Name: "bad",
Owner: "user",
Node: "",
})
assert.ErrorIs(t, err, workspace.ErrNodeMissing)
})
}
}
func TestCreate_NodeNotFound(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
_, err := m.Create(context.Background(), workspace.CreateOptions{
Name: "bad",
Owner: "user",
Node: "nonexistent-node",
})
assert.ErrorIs(t, err, workspace.ErrNodeOffline)
})
}
}
func TestGet(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
got, err := m.Get(context.Background(), ws.ID)
require.NoError(t, err)
assert.Equal(t, ws.ID, got.ID)
assert.Equal(t, ws.Name, got.Name)
assert.Equal(t, ws.Owner, got.Owner)
assert.Equal(t, ws.Node, got.Node)
})
}
}
func TestGet_NotFound(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
_, err := m.Get(context.Background(), "nonexistent")
assert.ErrorIs(t, err, workspace.ErrNotFound)
})
}
}
func TestList(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) { co.Name = "ws-1" })
createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) { co.Name = "ws-2" })
list, err := m.List(context.Background(), workspace.ListOptions{})
require.NoError(t, err)
assert.GreaterOrEqual(t, len(list), 2)
})
}
}
func TestList_FilterOwner(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) {
co.Owner = "alice"
co.Name = "alice-ws"
})
createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) {
co.Owner = "bob"
co.Name = "bob-ws"
})
list, err := m.List(context.Background(), workspace.ListOptions{Owner: "alice"})
require.NoError(t, err)
assert.Len(t, list, 1)
assert.Equal(t, "alice", list[0].Owner)
})
}
}
func TestList_FilterNode(t *testing.T) {
m := setupManagerMultiNode(t)
ctx := context.Background()
_, err := m.Create(ctx, workspace.CreateOptions{Name: "a", Owner: "u", Node: "node-a"})
require.NoError(t, err)
_, err = m.Create(ctx, workspace.CreateOptions{Name: "b", Owner: "u", Node: "node-b"})
require.NoError(t, err)
list, err := m.List(ctx, workspace.ListOptions{Node: "node-a"})
require.NoError(t, err)
assert.Len(t, list, 1)
assert.Equal(t, "node-a", list[0].Node)
}
func TestUpdate_Name(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
newName := "renamed-workspace"
updated, err := m.Update(context.Background(), ws.ID, workspace.UpdateOptions{
Name: &newName,
})
require.NoError(t, err)
assert.Equal(t, newName, updated.Name)
assert.Equal(t, ws.Owner, updated.Owner)
assert.True(t, updated.UpdatedAt.After(ws.UpdatedAt) || updated.UpdatedAt.Equal(ws.UpdatedAt))
})
}
}
func TestUpdate_Labels(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) {
co.Labels = map[string]string{"old": "value"}
})
updated, err := m.Update(context.Background(), ws.ID, workspace.UpdateOptions{
Labels: map[string]string{"new": "label"},
})
require.NoError(t, err)
assert.Equal(t, "label", updated.Labels["new"])
assert.Empty(t, updated.Labels["old"])
})
}
}
func TestUpdate_NotFound(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
_, err := m.Update(context.Background(), "nonexistent", workspace.UpdateOptions{})
assert.ErrorIs(t, err, workspace.ErrNotFound)
})
}
}
func TestDelete(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws, err := m.Create(context.Background(), workspace.CreateOptions{
Name: "to-delete", Owner: "user", Node: pc.Name,
})
require.NoError(t, err)
err = m.Delete(context.Background(), ws.ID, false)
require.NoError(t, err)
_, err = m.Get(context.Background(), ws.ID)
assert.ErrorIs(t, err, workspace.ErrNotFound)
})
}
}
func TestDelete_NotFound(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
err := m.Delete(context.Background(), "nonexistent", false)
assert.ErrorIs(t, err, workspace.ErrNotFound)
})
}
}
func TestNodes(t *testing.T) {
m := setupManagerMultiNode(t)
nodes := m.Nodes()
assert.Len(t, nodes, 2)
names := make(map[string]bool)
for _, n := range nodes {
names[n.Name] = true
assert.True(t, n.Online)
}
assert.True(t, names["node-a"])
assert.True(t, names["node-b"])
}
func TestNodeForWorkspace(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
ws := createWorkspace(t, m, pc.Name)
node, err := m.NodeForWorkspace(context.Background(), ws.ID)
require.NoError(t, err)
assert.Equal(t, pc.Name, node)
})
}
}
func TestNodeForWorkspace_NotFound(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
_, err := m.NodeForWorkspace(context.Background(), "nonexistent")
assert.ErrorIs(t, err, workspace.ErrNotFound)
})
}
}
func TestAddPool(t *testing.T) {
for _, pc := range testPools() {
t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc)
assert.Len(t, m.Nodes(), 1)
vol := volume.NewLocal(t.TempDir())
client, err := tai.New("local", tai.WithVolume(vol))
require.NoError(t, err)
defer client.Close()
m.AddPool("new-node", client)
assert.Len(t, m.Nodes(), 2)
})
}
}
func TestRemovePool(t *testing.T) {
m := setupManagerMultiNode(t)
assert.Len(t, m.Nodes(), 2)
m.RemovePool("node-b")
assert.Len(t, m.Nodes(), 1)
}
func TestMountPath(t *testing.T) {
m := setupManagerForPool(t, poolConfig{Name: "local", Addr: "local"})
ws := createWorkspace(t, m, "local")
mountPath, err := m.MountPath(context.Background(), ws.ID)
require.NoError(t, err)
assert.Contains(t, mountPath, ws.ID)
}
func TestMountPath_NotFound(t *testing.T) {
m := setupManagerForPool(t, poolConfig{Name: "local", Addr: "local"})
_, err := m.MountPath(context.Background(), "nonexistent")
assert.ErrorIs(t, err, workspace.ErrNotFound)
}