feat(sandbox): enhance workspace and node handling in lifecycle management

- Updated the BuildIdentifier function to use the ownerID as a fallback for workspaceID, improving identifier generation consistency.
- Enhanced ResolveNodeID to auto-select nodes based on filters and added detailed logging for better traceability.
- Modified GetComputer to include improved logging and streamlined node resolution logic.
- Introduced StringOrArray type for flexible handling of computer filter parameters in the SandboxConfig.
- Enriched workspace response structure to include detailed node information, enhancing API response clarity.
- Implemented default workspace ID generation based on owner and node, ensuring consistent workspace identification.
- Added locale support in CreateOptions for better internationalization.
This commit is contained in:
Max 2026-03-21 20:10:08 +08:00
parent 4f7989d796
commit 6f1cc27baf
7 changed files with 275 additions and 63 deletions

View file

@ -6,12 +6,15 @@ import (
"encoding/hex"
"fmt"
"log"
mathrand "math/rand"
"strings"
"github.com/yaoapp/gou/connector"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
infra "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/workspace"
)
@ -26,7 +29,11 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID, wor
case "session":
return fmt.Sprintf("%s-%s-%s", ownerID, assistantID, chatID)
case "longrunning", "persistent":
return fmt.Sprintf("%s-%s.%s", ownerID, assistantID, workspaceID)
wsKey := workspaceID
if wsKey == "" {
wsKey = ownerID
}
return fmt.Sprintf("%s-%s.%s", ownerID, assistantID, wsKey)
default:
return ""
}
@ -50,41 +57,46 @@ func ResolveNodeID(ctx *agentContext.Context, cfg *types.SandboxConfig, manager
}
}
ownerID := resolveOwnerID(ctx)
if workspaceID == "" {
workspaceID = ownerID
}
if workspaceID != "" && workspaceID != ownerID {
fmt.Printf("[sandbox/v2] ResolveNodeID: computerID=%q workspaceID=%q ownerID=%q image=%q\n", computerID, workspaceID, ownerID, cfg.Computer.Image)
if workspaceID != "" {
wsNode, err := workspace.M().NodeForWorkspace(context.Background(), workspaceID)
if err == nil && wsNode != "" {
fmt.Printf("[sandbox/v2] ResolveNodeID: workspace %s -> node %s\n", workspaceID, wsNode)
computerID = wsNode
}
}
if computerID != "" {
if node, ok := tai.GetNodeMeta(computerID); ok {
hasContainerRuntime := node.Capabilities.Docker || node.Capabilities.K8s
if node.Capabilities.HostExec && !hasContainerRuntime {
return computerID, "host", nil
}
if node.Capabilities.HostExec && hasContainerRuntime && cfg.Computer.Image == "" {
return computerID, "host", nil
}
if !hasContainerRuntime {
return "", "", fmt.Errorf("node %q has no container runtime and no host_exec capability", computerID)
}
return computerID, "box", nil
if computerID == "" {
pickedID, err := pickNodeByFilter(cfg.Filter, cfg.Computer.Image)
if err != nil {
return "", "", fmt.Errorf("auto-select node for ResolveNodeID: %w", err)
}
fmt.Printf("[sandbox/v2] ResolveNodeID: pickNodeByFilter -> %s\n", pickedID)
computerID = pickedID
cfg.NodeID = pickedID
}
if node, ok := tai.GetNodeMeta(computerID); ok {
hasContainerRuntime := node.Capabilities.Docker || node.Capabilities.K8s
fmt.Printf("[sandbox/v2] ResolveNodeID: node=%q HostExec=%v Docker=%v K8s=%v hasContainer=%v\n", computerID, node.Capabilities.HostExec, node.Capabilities.Docker, node.Capabilities.K8s, hasContainerRuntime)
if node.Capabilities.HostExec && !hasContainerRuntime {
fmt.Println("[sandbox/v2] ResolveNodeID: -> host (host-only node)")
return computerID, "host", nil
}
if node.Capabilities.HostExec && hasContainerRuntime && cfg.Computer.Image == "" {
fmt.Println("[sandbox/v2] ResolveNodeID: -> host (dual-capable, no image)")
return computerID, "host", nil
}
if !hasContainerRuntime {
return "", "", fmt.Errorf("node %q has no container runtime and no host_exec capability", computerID)
}
fmt.Println("[sandbox/v2] ResolveNodeID: -> box")
return computerID, "box", nil
}
if cfg.Computer.Image == "" {
nodeID := cfg.NodeID
return nodeID, "host", nil
}
nodeID := cfg.NodeID
return nodeID, "box", nil
fmt.Printf("[sandbox/v2] ResolveNodeID: node %q not found in registry, assuming box\n", computerID)
return computerID, "box", nil
}
// GetComputer obtains or creates a Computer for the current request.
@ -99,9 +111,6 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i
workspaceID = ws
}
}
if workspaceID == "" {
workspaceID = ownerID
}
identifier := BuildIdentifier(cfg, ownerID, ctx.ChatID, ctx.AssistantID, workspaceID, ctx.Metadata)
@ -118,9 +127,9 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i
}
}
// Workspace-wins rule: when both workspace_id and computer_id are present,
// Workspace-wins rule: when workspace_id is present,
// the workspace's bound node takes precedence over computer_id.
if workspaceID != "" && workspaceID != ownerID {
if workspaceID != "" {
wsNode, err := workspace.M().NodeForWorkspace(context.Background(), workspaceID)
if err == nil && wsNode != "" {
if computerID != "" && computerID != wsNode {
@ -130,11 +139,14 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i
}
}
fmt.Printf("[sandbox/v2] GetComputer: computerID=%q workspaceID=%q ownerID=%q cfgNodeID=%q image=%q\n", computerID, workspaceID, ownerID, cfg.NodeID, cfg.Computer.Image)
if computerID != "" {
fmt.Printf("[sandbox/v2] GetComputer: -> resolveComputerByID(%s)\n", computerID)
return resolveComputerByID(cfg, manager, computerID, ownerID, identifier, workspaceID, conn...)
}
// No computer_id: fall back to DSL-based dispatch (original logic).
fmt.Println("[sandbox/v2] GetComputer: -> resolveComputerByDSL (no computerID)")
return resolveComputerByDSL(cfg, manager, ownerID, identifier, workspaceID, conn...)
}
@ -150,9 +162,10 @@ func resolveComputerByID(
if node, ok := tai.GetNodeMeta(computerID); ok {
cfg.NodeID = computerID
hasContainerRuntime := node.Capabilities.Docker || node.Capabilities.K8s
fmt.Printf("[sandbox/v2] resolveComputerByID: node=%q found=true HostExec=%v Docker=%v K8s=%v hasContainer=%v image=%q\n", computerID, node.Capabilities.HostExec, node.Capabilities.Docker, node.Capabilities.K8s, hasContainerRuntime, cfg.Computer.Image)
if node.Capabilities.HostExec && !hasContainerRuntime {
// Host-only node: must use host mode regardless of DSL image config.
fmt.Println("[sandbox/v2] resolveComputerByID: -> host (host-only node)")
cfg.Kind = "host"
host, err := manager.Host(context.Background(), computerID)
if err != nil {
@ -202,23 +215,19 @@ func resolveComputerByDSL(
conn ...connector.Connector,
) (infra.Computer, string, error) {
// Host mode: no image → host computer.
if cfg.Computer.Image == "" {
cfg.Kind = "host"
nodeID := cfg.NodeID
if nodeID == "" {
return nil, identifier, fmt.Errorf("host mode requires a nodeID (set in sandbox.yao or workspace)")
}
host, err := manager.Host(context.Background(), nodeID)
fmt.Printf("[sandbox/v2] resolveComputerByDSL: cfgNodeID=%q image=%q\n", cfg.NodeID, cfg.Computer.Image)
if cfg.NodeID == "" {
pickedID, err := pickNodeByFilter(cfg.Filter, cfg.Computer.Image)
if err != nil {
return nil, identifier, fmt.Errorf("get host computer: %w", err)
return nil, identifier, fmt.Errorf("auto-select node: %w", err)
}
host.BindWorkplace(workspaceID)
return host, identifier, nil
fmt.Printf("[sandbox/v2] resolveComputerByDSL: pickNodeByFilter -> %s\n", pickedID)
cfg.NodeID = pickedID
}
cfg.Kind = "box"
return resolveBox(cfg, manager, ownerID, identifier, workspaceID, conn...)
fmt.Printf("[sandbox/v2] resolveComputerByDSL: -> resolveComputerByID(%s)\n", cfg.NodeID)
return resolveComputerByID(cfg, manager, cfg.NodeID, ownerID, identifier, workspaceID, conn...)
}
// resolveBox reuses or creates a box container.
@ -228,6 +237,11 @@ func resolveBox(
conn ...connector.Connector,
) (infra.Computer, string, error) {
if workspaceID == "" && cfg.NodeID != "" {
workspaceID = workspace.DefaultWorkspaceID(ownerID, cfg.NodeID)
cfg.WorkspaceID = workspaceID
}
// Reuse: non-empty identifier → try Get first.
if identifier != "" {
box, err := manager.Get(context.Background(), identifier)
@ -255,6 +269,7 @@ func resolveBox(
if err != nil {
return nil, identifier, fmt.Errorf("build create options: %w", err)
}
fmt.Printf("[sandbox/v2] resolveBox: createOpts NodeID=%q Image=%q WorkspaceID=%q ID=%q Owner=%q\n", createOpts.NodeID, createOpts.Image, createOpts.WorkspaceID, createOpts.ID, createOpts.Owner)
// Oneshot with empty identifier: generate a random one.
if createOpts.ID == "" {
@ -310,6 +325,71 @@ func resolveOwnerID(ctx *agentContext.Context) string {
return "anonymous"
}
// pickNodeByFilter selects a random online node that satisfies the given filter
// and image requirement. If image is non-empty, candidate nodes must have a
// container runtime (Docker or K8s).
func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error) {
reg := registry.Global()
if reg == nil {
return "", fmt.Errorf("tai registry not initialized")
}
nodes := reg.List()
var candidates []string
for _, n := range nodes {
if n.Status != "online" && n.Status != "" {
continue
}
if filter != nil {
if filter.OS != "" && !strings.EqualFold(n.System.OS, filter.OS) {
continue
}
if filter.Arch != "" && !strings.EqualFold(n.System.Arch, filter.Arch) {
continue
}
if len(filter.Kind) > 0 {
matched := false
for _, k := range filter.Kind {
switch strings.ToLower(k) {
case "host":
if n.Capabilities.HostExec {
matched = true
}
case "box":
if n.Capabilities.Docker || n.Capabilities.K8s {
matched = true
}
}
}
if !matched {
continue
}
}
}
if image != "" && !(n.Capabilities.Docker || n.Capabilities.K8s) {
continue
}
candidates = append(candidates, n.TaiID)
}
if len(candidates) == 0 {
kind := ""
os := ""
arch := ""
if filter != nil {
kind = fmt.Sprintf("%v", []string(filter.Kind))
os = filter.OS
arch = filter.Arch
}
return "", fmt.Errorf("no online node matches filter (kind=%s os=%s arch=%s image=%s)", kind, os, arch, image)
}
return candidates[mathrand.Intn(len(candidates))], nil
}
func randomID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)

View file

@ -27,6 +27,7 @@ func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspace
opts := infra.CreateOptions{
ID: identifier,
Owner: ownerID,
NodeID: cfg.NodeID,
Image: cfg.Computer.Image,
WorkDir: cfg.Computer.WorkDir,
User: cfg.Computer.User,

View file

@ -35,10 +35,30 @@ type SandboxConfig struct {
DisplayName string `json:"-" yaml:"-"`
}
// StringOrArray accepts both a single string and an array of strings in JSON/YAML.
//
// "host" → ["host"]
// ["host", "box"] → ["host", "box"]
type StringOrArray []string
func (s *StringOrArray) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err == nil {
*s = []string{str}
return nil
}
var arr []string
if err := json.Unmarshal(data, &arr); err == nil {
*s = arr
return nil
}
return fmt.Errorf("StringOrArray: expected a string or an array of strings")
}
// ComputerFilter defines the query parameters for GET /computer/options.
// Declared in DSL sandbox.filter; frontend passes it through to the API.
type ComputerFilter struct {
Kind string `json:"kind,omitempty" yaml:"kind,omitempty"`
Kind StringOrArray `json:"kind,omitempty" yaml:"kind,omitempty"`
Image string `json:"image,omitempty" yaml:"image,omitempty"`
VNC *bool `json:"vnc,omitempty" yaml:"vnc,omitempty"`
OS string `json:"os,omitempty" yaml:"os,omitempty"`

View file

@ -9,11 +9,13 @@ import (
"net/http"
"path/filepath"
"sort"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth/authorized"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/tai/registry"
ws "github.com/yaoapp/yao/workspace"
)
@ -96,13 +98,24 @@ type renameRequest struct {
}
type workspaceResponse 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 string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Node string `json:"node"`
NodeName string `json:"node_name,omitempty"`
NodeOS string `json:"node_os,omitempty"`
NodeArch string `json:"node_arch,omitempty"`
NodeKind string `json:"node_kind,omitempty"`
NodeOnline bool `json:"node_online"`
NodeCapabilities map[string]bool `json:"node_capabilities,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type optionsResponse struct {
Data []workspaceResponse `json:"data"`
HasOnlineNodes bool `json:"has_online_nodes"`
}
func toResponse(w *ws.Workspace) workspaceResponse {
@ -178,12 +191,13 @@ func handleList(c *gin.Context) {
}
// handleOptions returns workspace options for the InputArea selector.
// Reuses the same logic as handleList (Manager.List with owner+node filter).
// Separated as a dedicated endpoint for clear API responsibility boundary.
// Each workspace is enriched with its node's display info (name, OS, arch, kind, online).
// The response also includes has_online_nodes so the frontend can determine sendBlocked
// even when the workspace list is empty.
func handleOptions(c *gin.Context) {
m := mgr()
if m == nil {
response.RespondWithSuccess(c, http.StatusOK, []workspaceResponse{})
response.RespondWithSuccess(c, http.StatusOK, optionsResponse{Data: []workspaceResponse{}})
return
}
@ -192,21 +206,95 @@ func handleOptions(c *gin.Context) {
list, err := m.List(context.Background(), ws.ListOptions{
Owner: owner,
Node: c.Query("node"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
nodeMap := buildNodeMap()
hasOnline := false
for _, n := range nodeMap {
if n.online {
hasOnline = true
break
}
}
result := make([]workspaceResponse, 0, len(list))
for _, w := range list {
result = append(result, toResponse(w))
r := toResponse(w)
if info, ok := nodeMap[w.Node]; ok {
r.NodeName = info.displayName
r.NodeOS = info.os
r.NodeArch = info.arch
r.NodeKind = info.kind
r.NodeOnline = info.online
r.NodeCapabilities = info.capabilities
}
result = append(result, r)
}
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt > result[j].CreatedAt
})
response.RespondWithSuccess(c, http.StatusOK, result)
response.RespondWithSuccess(c, http.StatusOK, optionsResponse{
Data: result,
HasOnlineNodes: hasOnline,
})
}
type nodeInfo struct {
displayName string
os string
arch string
kind string
online bool
capabilities map[string]bool
}
func buildNodeMap() map[string]nodeInfo {
reg := registry.Global()
if reg == nil {
return nil
}
nodes := reg.List()
m := make(map[string]nodeInfo, len(nodes))
for _, n := range nodes {
kind := "node"
if n.Mode == "local" {
kind = "host"
}
name := n.DisplayName
if name == "" {
name = n.System.Hostname
}
if name == "" {
name = n.TaiID
}
caps := map[string]bool{}
if n.Capabilities.HostExec {
caps["host_exec"] = true
}
if n.Capabilities.Docker {
caps["docker"] = true
}
if n.Capabilities.K8s {
caps["k8s"] = true
}
if n.Capabilities.VNC {
caps["vnc"] = true
}
m[n.TaiID] = nodeInfo{
displayName: name,
os: strings.ToLower(n.System.OS),
arch: strings.ToLower(n.System.Arch),
kind: kind,
online: n.Status == "online" || n.Status == "",
capabilities: caps,
}
}
return m
}
func handleCreate(c *gin.Context) {

View file

@ -6,6 +6,7 @@ import (
"log"
goruntime "runtime"
"strconv"
"strings"
"sync"
"time"
@ -123,15 +124,20 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
if targetNode == "" {
return nil, fmt.Errorf("sandbox: resolve workspace %q: no available node", opts.WorkspaceID)
}
wsID := opts.WorkspaceID
if wsID == opts.Owner {
wsID = workspace.DefaultWorkspaceID(opts.Owner, targetNode)
}
_, err = wsm.Create(ctx, workspace.CreateOptions{
ID: opts.WorkspaceID,
Name: opts.WorkspaceID,
ID: wsID,
Name: defaultWorkspaceName(opts.Locale),
Owner: opts.Owner,
Node: targetNode,
})
if err != nil {
return nil, fmt.Errorf("sandbox: auto-create workspace %q: %w", opts.WorkspaceID, err)
return nil, fmt.Errorf("sandbox: auto-create workspace %q: %w", wsID, err)
}
opts.WorkspaceID = wsID
nodeID = targetNode
} else {
nodeID = node
@ -482,6 +488,13 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn
}
}
func defaultWorkspaceName(locale string) string {
if strings.HasPrefix(strings.ToLower(locale), "zh") {
return "默认工作区"
}
return "Default Workspace"
}
// inferSystemInfo derives static SystemInfo for a container from image metadata
// and Tai host resources. OS/Arch/Shell come from the image; Hostname/NumCPU/TotalMem
// come from the Tai host.

View file

@ -110,6 +110,7 @@ type CreateOptions struct {
MountMode string
MountPath string
DisplayName string
Locale string
}
type ListOptions struct {

View file

@ -1,6 +1,7 @@
package workspace
import (
"crypto/sha256"
"encoding/json"
"fmt"
"time"
@ -65,6 +66,14 @@ func generateID() string {
return fmt.Sprintf("ws-%s", uuid.New().String()[:12])
}
// DefaultWorkspaceID returns a deterministic workspace ID for the given
// owner+node pair. The same inputs always produce the same ID, while
// different nodes produce different IDs.
func DefaultWorkspaceID(ownerID, nodeID string) string {
h := sha256.Sum256([]byte(ownerID + ":" + nodeID))
return fmt.Sprintf("ws-%x", h[:6])
}
func marshalMeta(ws *Workspace) ([]byte, error) {
return json.MarshalIndent(ws, "", " ")
}