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"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
mathrand "math/rand"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
agentContext "github.com/yaoapp/yao/agent/context"
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
||||||
infra "github.com/yaoapp/yao/sandbox/v2"
|
infra "github.com/yaoapp/yao/sandbox/v2"
|
||||||
"github.com/yaoapp/yao/tai"
|
"github.com/yaoapp/yao/tai"
|
||||||
|
"github.com/yaoapp/yao/tai/registry"
|
||||||
"github.com/yaoapp/yao/workspace"
|
"github.com/yaoapp/yao/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -26,7 +29,11 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID, wor
|
||||||
case "session":
|
case "session":
|
||||||
return fmt.Sprintf("%s-%s-%s", ownerID, assistantID, chatID)
|
return fmt.Sprintf("%s-%s-%s", ownerID, assistantID, chatID)
|
||||||
case "longrunning", "persistent":
|
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:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -50,41 +57,46 @@ func ResolveNodeID(ctx *agentContext.Context, cfg *types.SandboxConfig, manager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ownerID := resolveOwnerID(ctx)
|
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)
|
wsNode, err := workspace.M().NodeForWorkspace(context.Background(), workspaceID)
|
||||||
if err == nil && wsNode != "" {
|
if err == nil && wsNode != "" {
|
||||||
|
fmt.Printf("[sandbox/v2] ResolveNodeID: workspace %s -> node %s\n", workspaceID, wsNode)
|
||||||
computerID = wsNode
|
computerID = wsNode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if computerID != "" {
|
if computerID == "" {
|
||||||
if node, ok := tai.GetNodeMeta(computerID); ok {
|
pickedID, err := pickNodeByFilter(cfg.Filter, cfg.Computer.Image)
|
||||||
hasContainerRuntime := node.Capabilities.Docker || node.Capabilities.K8s
|
if err != nil {
|
||||||
if node.Capabilities.HostExec && !hasContainerRuntime {
|
return "", "", fmt.Errorf("auto-select node for ResolveNodeID: %w", err)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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
|
return computerID, "box", nil
|
||||||
}
|
}
|
||||||
|
fmt.Printf("[sandbox/v2] ResolveNodeID: node %q not found in registry, assuming box\n", computerID)
|
||||||
if cfg.Computer.Image == "" {
|
return computerID, "box", nil
|
||||||
nodeID := cfg.NodeID
|
|
||||||
return nodeID, "host", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeID := cfg.NodeID
|
|
||||||
return nodeID, "box", nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetComputer obtains or creates a Computer for the current request.
|
// 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
|
workspaceID = ws
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if workspaceID == "" {
|
|
||||||
workspaceID = ownerID
|
|
||||||
}
|
|
||||||
|
|
||||||
identifier := BuildIdentifier(cfg, ownerID, ctx.ChatID, ctx.AssistantID, workspaceID, ctx.Metadata)
|
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.
|
// the workspace's bound node takes precedence over computer_id.
|
||||||
if workspaceID != "" && workspaceID != ownerID {
|
if workspaceID != "" {
|
||||||
wsNode, err := workspace.M().NodeForWorkspace(context.Background(), workspaceID)
|
wsNode, err := workspace.M().NodeForWorkspace(context.Background(), workspaceID)
|
||||||
if err == nil && wsNode != "" {
|
if err == nil && wsNode != "" {
|
||||||
if computerID != "" && computerID != 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 != "" {
|
if computerID != "" {
|
||||||
|
fmt.Printf("[sandbox/v2] GetComputer: -> resolveComputerByID(%s)\n", computerID)
|
||||||
return resolveComputerByID(cfg, manager, computerID, ownerID, identifier, workspaceID, conn...)
|
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...)
|
return resolveComputerByDSL(cfg, manager, ownerID, identifier, workspaceID, conn...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,9 +162,10 @@ func resolveComputerByID(
|
||||||
if node, ok := tai.GetNodeMeta(computerID); ok {
|
if node, ok := tai.GetNodeMeta(computerID); ok {
|
||||||
cfg.NodeID = computerID
|
cfg.NodeID = computerID
|
||||||
hasContainerRuntime := node.Capabilities.Docker || node.Capabilities.K8s
|
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 {
|
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"
|
cfg.Kind = "host"
|
||||||
host, err := manager.Host(context.Background(), computerID)
|
host, err := manager.Host(context.Background(), computerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -202,23 +215,19 @@ func resolveComputerByDSL(
|
||||||
conn ...connector.Connector,
|
conn ...connector.Connector,
|
||||||
) (infra.Computer, string, error) {
|
) (infra.Computer, string, error) {
|
||||||
|
|
||||||
// Host mode: no image → host computer.
|
fmt.Printf("[sandbox/v2] resolveComputerByDSL: cfgNodeID=%q image=%q\n", cfg.NodeID, cfg.Computer.Image)
|
||||||
if cfg.Computer.Image == "" {
|
|
||||||
cfg.Kind = "host"
|
if cfg.NodeID == "" {
|
||||||
nodeID := cfg.NodeID
|
pickedID, err := pickNodeByFilter(cfg.Filter, cfg.Computer.Image)
|
||||||
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)
|
|
||||||
if err != nil {
|
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)
|
fmt.Printf("[sandbox/v2] resolveComputerByDSL: pickNodeByFilter -> %s\n", pickedID)
|
||||||
return host, identifier, nil
|
cfg.NodeID = pickedID
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.Kind = "box"
|
fmt.Printf("[sandbox/v2] resolveComputerByDSL: -> resolveComputerByID(%s)\n", cfg.NodeID)
|
||||||
return resolveBox(cfg, manager, ownerID, identifier, workspaceID, conn...)
|
return resolveComputerByID(cfg, manager, cfg.NodeID, ownerID, identifier, workspaceID, conn...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveBox reuses or creates a box container.
|
// resolveBox reuses or creates a box container.
|
||||||
|
|
@ -228,6 +237,11 @@ func resolveBox(
|
||||||
conn ...connector.Connector,
|
conn ...connector.Connector,
|
||||||
) (infra.Computer, string, error) {
|
) (infra.Computer, string, error) {
|
||||||
|
|
||||||
|
if workspaceID == "" && cfg.NodeID != "" {
|
||||||
|
workspaceID = workspace.DefaultWorkspaceID(ownerID, cfg.NodeID)
|
||||||
|
cfg.WorkspaceID = workspaceID
|
||||||
|
}
|
||||||
|
|
||||||
// Reuse: non-empty identifier → try Get first.
|
// Reuse: non-empty identifier → try Get first.
|
||||||
if identifier != "" {
|
if identifier != "" {
|
||||||
box, err := manager.Get(context.Background(), identifier)
|
box, err := manager.Get(context.Background(), identifier)
|
||||||
|
|
@ -255,6 +269,7 @@ func resolveBox(
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, identifier, fmt.Errorf("build create options: %w", err)
|
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.
|
// Oneshot with empty identifier: generate a random one.
|
||||||
if createOpts.ID == "" {
|
if createOpts.ID == "" {
|
||||||
|
|
@ -310,6 +325,71 @@ func resolveOwnerID(ctx *agentContext.Context) string {
|
||||||
return "anonymous"
|
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 {
|
func randomID() string {
|
||||||
b := make([]byte, 8)
|
b := make([]byte, 8)
|
||||||
_, _ = rand.Read(b)
|
_, _ = rand.Read(b)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspace
|
||||||
opts := infra.CreateOptions{
|
opts := infra.CreateOptions{
|
||||||
ID: identifier,
|
ID: identifier,
|
||||||
Owner: ownerID,
|
Owner: ownerID,
|
||||||
|
NodeID: cfg.NodeID,
|
||||||
Image: cfg.Computer.Image,
|
Image: cfg.Computer.Image,
|
||||||
WorkDir: cfg.Computer.WorkDir,
|
WorkDir: cfg.Computer.WorkDir,
|
||||||
User: cfg.Computer.User,
|
User: cfg.Computer.User,
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,30 @@ type SandboxConfig struct {
|
||||||
DisplayName string `json:"-" yaml:"-"`
|
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.
|
// ComputerFilter defines the query parameters for GET /computer/options.
|
||||||
// Declared in DSL sandbox.filter; frontend passes it through to the API.
|
// Declared in DSL sandbox.filter; frontend passes it through to the API.
|
||||||
type ComputerFilter struct {
|
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"`
|
Image string `json:"image,omitempty" yaml:"image,omitempty"`
|
||||||
VNC *bool `json:"vnc,omitempty" yaml:"vnc,omitempty"`
|
VNC *bool `json:"vnc,omitempty" yaml:"vnc,omitempty"`
|
||||||
OS string `json:"os,omitempty" yaml:"os,omitempty"`
|
OS string `json:"os,omitempty" yaml:"os,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/openapi/response"
|
"github.com/yaoapp/yao/openapi/response"
|
||||||
|
"github.com/yaoapp/yao/tai/registry"
|
||||||
ws "github.com/yaoapp/yao/workspace"
|
ws "github.com/yaoapp/yao/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -96,13 +98,24 @@ type renameRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type workspaceResponse struct {
|
type workspaceResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Labels map[string]string `json:"labels,omitempty"`
|
NodeName string `json:"node_name,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
NodeOS string `json:"node_os,omitempty"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
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 {
|
func toResponse(w *ws.Workspace) workspaceResponse {
|
||||||
|
|
@ -178,12 +191,13 @@ func handleList(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleOptions returns workspace options for the InputArea selector.
|
// handleOptions returns workspace options for the InputArea selector.
|
||||||
// Reuses the same logic as handleList (Manager.List with owner+node filter).
|
// Each workspace is enriched with its node's display info (name, OS, arch, kind, online).
|
||||||
// Separated as a dedicated endpoint for clear API responsibility boundary.
|
// 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) {
|
func handleOptions(c *gin.Context) {
|
||||||
m := mgr()
|
m := mgr()
|
||||||
if m == nil {
|
if m == nil {
|
||||||
response.RespondWithSuccess(c, http.StatusOK, []workspaceResponse{})
|
response.RespondWithSuccess(c, http.StatusOK, optionsResponse{Data: []workspaceResponse{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,21 +206,95 @@ func handleOptions(c *gin.Context) {
|
||||||
|
|
||||||
list, err := m.List(context.Background(), ws.ListOptions{
|
list, err := m.List(context.Background(), ws.ListOptions{
|
||||||
Owner: owner,
|
Owner: owner,
|
||||||
Node: c.Query("node"),
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nodeMap := buildNodeMap()
|
||||||
|
hasOnline := false
|
||||||
|
for _, n := range nodeMap {
|
||||||
|
if n.online {
|
||||||
|
hasOnline = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result := make([]workspaceResponse, 0, len(list))
|
result := make([]workspaceResponse, 0, len(list))
|
||||||
for _, w := range 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 {
|
sort.Slice(result, func(i, j int) bool {
|
||||||
return result[i].CreatedAt > result[j].CreatedAt
|
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) {
|
func handleCreate(c *gin.Context) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
goruntime "runtime"
|
goruntime "runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -123,15 +124,20 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
|
||||||
if targetNode == "" {
|
if targetNode == "" {
|
||||||
return nil, fmt.Errorf("sandbox: resolve workspace %q: no available node", opts.WorkspaceID)
|
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{
|
_, err = wsm.Create(ctx, workspace.CreateOptions{
|
||||||
ID: opts.WorkspaceID,
|
ID: wsID,
|
||||||
Name: opts.WorkspaceID,
|
Name: defaultWorkspaceName(opts.Locale),
|
||||||
Owner: opts.Owner,
|
Owner: opts.Owner,
|
||||||
Node: targetNode,
|
Node: targetNode,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
nodeID = targetNode
|
||||||
} else {
|
} else {
|
||||||
nodeID = node
|
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
|
// inferSystemInfo derives static SystemInfo for a container from image metadata
|
||||||
// and Tai host resources. OS/Arch/Shell come from the image; Hostname/NumCPU/TotalMem
|
// and Tai host resources. OS/Arch/Shell come from the image; Hostname/NumCPU/TotalMem
|
||||||
// come from the Tai host.
|
// come from the Tai host.
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ type CreateOptions struct {
|
||||||
MountMode string
|
MountMode string
|
||||||
MountPath string
|
MountPath string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
|
Locale string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListOptions struct {
|
type ListOptions struct {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package workspace
|
package workspace
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -65,6 +66,14 @@ func generateID() string {
|
||||||
return fmt.Sprintf("ws-%s", uuid.New().String()[:12])
|
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) {
|
func marshalMeta(ws *Workspace) ([]byte, error) {
|
||||||
return json.MarshalIndent(ws, "", " ")
|
return json.MarshalIndent(ws, "", " ")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue