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:
parent
4f7989d796
commit
6f1cc27baf
7 changed files with 275 additions and 63 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ type CreateOptions struct {
|
|||
MountMode string
|
||||
MountPath string
|
||||
DisplayName string
|
||||
Locale string
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
|
|
|
|||
|
|
@ -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, "", " ")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue