From 6f1cc27baf8bc23db91d860a37089b31654a77e4 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 21 Mar 2026 20:10:08 +0800 Subject: [PATCH] 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. --- agent/sandbox/v2/lifecycle.go | 172 ++++++++++++++++++++++--------- agent/sandbox/v2/options.go | 1 + agent/sandbox/v2/types/config.go | 22 +++- openapi/workspace/workspace.go | 114 +++++++++++++++++--- sandbox/v2/manager.go | 19 +++- sandbox/v2/types.go | 1 + workspace/workspace.go | 9 ++ 7 files changed, 275 insertions(+), 63 deletions(-) diff --git a/agent/sandbox/v2/lifecycle.go b/agent/sandbox/v2/lifecycle.go index 4419bb7b..24fe92b3 100644 --- a/agent/sandbox/v2/lifecycle.go +++ b/agent/sandbox/v2/lifecycle.go @@ -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) diff --git a/agent/sandbox/v2/options.go b/agent/sandbox/v2/options.go index 4be74d31..5080c7ee 100644 --- a/agent/sandbox/v2/options.go +++ b/agent/sandbox/v2/options.go @@ -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, diff --git a/agent/sandbox/v2/types/config.go b/agent/sandbox/v2/types/config.go index d5ec1196..6a790004 100644 --- a/agent/sandbox/v2/types/config.go +++ b/agent/sandbox/v2/types/config.go @@ -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"` diff --git a/openapi/workspace/workspace.go b/openapi/workspace/workspace.go index f3eade52..1719e84b 100644 --- a/openapi/workspace/workspace.go +++ b/openapi/workspace/workspace.go @@ -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) { diff --git a/sandbox/v2/manager.go b/sandbox/v2/manager.go index 1e58bd07..70ce5ebd 100644 --- a/sandbox/v2/manager.go +++ b/sandbox/v2/manager.go @@ -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. diff --git a/sandbox/v2/types.go b/sandbox/v2/types.go index 109c25b4..312aa54b 100644 --- a/sandbox/v2/types.go +++ b/sandbox/v2/types.go @@ -110,6 +110,7 @@ type CreateOptions struct { MountMode string MountPath string DisplayName string + Locale string } type ListOptions struct { diff --git a/workspace/workspace.go b/workspace/workspace.go index 8f273198..0a77370e 100644 --- a/workspace/workspace.go +++ b/workspace/workspace.go @@ -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, "", " ") }