- Added detailed trace logging in the Assistant's Stream function to capture computer and workspace information. - Replaced fmt.Printf statements with kunlog.Trace in the Telegram polling adapter for consistent logging. - Updated sandbox lifecycle management functions to utilize kunlog for logging workspace and node resolution details. - Enhanced error handling in the Claude parser with trace logging for JSON unmarshal and scanner errors. - Improved file handling logging in the workspace API to provide clearer insights into file read operations.
530 lines
13 KiB
Go
530 lines
13 KiB
Go
package workspace
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/yaoapp/kun/log"
|
|
"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"
|
|
)
|
|
|
|
// Attach registers workspace management routes on the given group.
|
|
// - GET / — list workspaces (filtered by owner from token)
|
|
// - POST / — create workspace (owner from token)
|
|
// - GET /:id — get workspace (owner check)
|
|
// - PUT /:id — update workspace (owner check)
|
|
// - DELETE /:id — delete workspace (owner check)
|
|
// - GET /:id/files — list files
|
|
// - GET /:id/files/*path — read file
|
|
// - PUT /:id/files/*path — write file
|
|
// - DELETE /:id/files/*path — delete file
|
|
// - POST /:id/mkdir — create directory
|
|
// - POST /:id/rename — rename file/directory
|
|
// - GET /:id/rootdir — get workspace root directory absolute path
|
|
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
|
group.Use(oauth.Guard)
|
|
|
|
group.GET("", handleList)
|
|
group.GET("/options", handleOptions)
|
|
group.POST("", handleCreate)
|
|
group.GET("/:id", handleGet)
|
|
group.PUT("/:id", handleUpdate)
|
|
group.DELETE("/:id", handleDelete)
|
|
|
|
group.GET("/:id/rootdir", handleRootDir)
|
|
group.GET("/:id/files", handleListFiles)
|
|
group.GET("/:id/files/*path", handleReadFile)
|
|
group.PUT("/:id/files/*path", handleWriteFile)
|
|
group.DELETE("/:id/files/*path", handleDeleteFile)
|
|
group.POST("/:id/mkdir", handleMkdir)
|
|
group.POST("/:id/rename", handleRename)
|
|
}
|
|
|
|
// resolveOwner returns TeamID if present, otherwise UserID.
|
|
func resolveOwner(authInfo *types.AuthorizedInfo) string {
|
|
if authInfo != nil && authInfo.TeamID != "" {
|
|
return authInfo.TeamID
|
|
}
|
|
if authInfo != nil {
|
|
return authInfo.UserID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// checkWSOwner verifies the caller owns the workspace.
|
|
func checkWSOwner(c *gin.Context, w *ws.Workspace, owner string) bool {
|
|
if owner == "" {
|
|
return true
|
|
}
|
|
if w.Owner != "" && w.Owner != owner {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "no permission to access this workspace"})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// --- request / response types ---
|
|
|
|
type createRequest struct {
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name" binding:"required"`
|
|
Node string `json:"node" binding:"required"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
type updateRequest struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
type mkdirRequest struct {
|
|
Path string `json:"path" binding:"required"`
|
|
}
|
|
|
|
type renameRequest struct {
|
|
OldPath string `json:"old_path" binding:"required"`
|
|
NewPath string `json:"new_path" binding:"required"`
|
|
}
|
|
|
|
type workspaceResponse struct {
|
|
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 {
|
|
return workspaceResponse{
|
|
ID: w.ID,
|
|
Name: w.Name,
|
|
Owner: w.Owner,
|
|
Node: w.Node,
|
|
Labels: w.Labels,
|
|
CreatedAt: w.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
UpdatedAt: w.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
}
|
|
|
|
func mgr() *ws.Manager {
|
|
return ws.M()
|
|
}
|
|
|
|
// resolveAndCheckWS fetches the workspace and verifies owner permission.
|
|
func resolveAndCheckWS(c *gin.Context) (*ws.Workspace, bool) {
|
|
m := mgr()
|
|
if m == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "workspace service not available"})
|
|
return nil, false
|
|
}
|
|
|
|
w, err := m.Get(context.Background(), c.Param("id"))
|
|
if err != nil {
|
|
if err == ws.ErrNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
|
return nil, false
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return nil, false
|
|
}
|
|
|
|
authInfo := authorized.GetInfo(c)
|
|
if !checkWSOwner(c, w, resolveOwner(authInfo)) {
|
|
return nil, false
|
|
}
|
|
return w, true
|
|
}
|
|
|
|
// --- handlers ---
|
|
|
|
func handleList(c *gin.Context) {
|
|
m := mgr()
|
|
if m == nil {
|
|
response.RespondWithSuccess(c, http.StatusOK, []workspaceResponse{})
|
|
return
|
|
}
|
|
|
|
authInfo := authorized.GetInfo(c)
|
|
owner := resolveOwner(authInfo)
|
|
|
|
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
|
|
}
|
|
|
|
result := make([]workspaceResponse, 0, len(list))
|
|
for _, w := range list {
|
|
result = append(result, toResponse(w))
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].CreatedAt > result[j].CreatedAt
|
|
})
|
|
response.RespondWithSuccess(c, http.StatusOK, result)
|
|
}
|
|
|
|
// handleOptions returns workspace options for the InputArea selector.
|
|
// 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, optionsResponse{Data: []workspaceResponse{}})
|
|
return
|
|
}
|
|
|
|
authInfo := authorized.GetInfo(c)
|
|
owner := resolveOwner(authInfo)
|
|
|
|
list, err := m.List(context.Background(), ws.ListOptions{
|
|
Owner: owner,
|
|
})
|
|
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 {
|
|
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, 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) {
|
|
m := mgr()
|
|
if m == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "workspace service not available"})
|
|
return
|
|
}
|
|
|
|
var req createRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
authInfo := authorized.GetInfo(c)
|
|
owner := resolveOwner(authInfo)
|
|
|
|
w, err := m.Create(context.Background(), ws.CreateOptions{
|
|
ID: req.ID,
|
|
Name: req.Name,
|
|
Owner: owner,
|
|
Node: req.Node,
|
|
Labels: req.Labels,
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
response.RespondWithSuccess(c, http.StatusCreated, toResponse(w))
|
|
}
|
|
|
|
func handleGet(c *gin.Context) {
|
|
w, ok := resolveAndCheckWS(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
response.RespondWithSuccess(c, http.StatusOK, toResponse(w))
|
|
}
|
|
|
|
func handleUpdate(c *gin.Context) {
|
|
_, ok := resolveAndCheckWS(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req updateRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
w, err := mgr().Update(context.Background(), c.Param("id"), ws.UpdateOptions{
|
|
Name: req.Name,
|
|
Labels: req.Labels,
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
response.RespondWithSuccess(c, http.StatusOK, toResponse(w))
|
|
}
|
|
|
|
func handleDelete(c *gin.Context) {
|
|
_, ok := resolveAndCheckWS(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
force := c.Query("force") == "true"
|
|
if err := mgr().Delete(context.Background(), c.Param("id"), force); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func handleRootDir(c *gin.Context) {
|
|
_, ok := resolveAndCheckWS(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
rootDir, err := mgr().MountPath(context.Background(), c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
response.RespondWithSuccess(c, http.StatusOK, gin.H{"root_dir": rootDir})
|
|
}
|
|
|
|
func handleListFiles(c *gin.Context) {
|
|
_, ok := resolveAndCheckWS(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
dir := c.DefaultQuery("path", ".")
|
|
entries, err := mgr().ListDir(context.Background(), c.Param("id"), dir)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
response.RespondWithSuccess(c, http.StatusOK, entries)
|
|
}
|
|
|
|
func handleReadFile(c *gin.Context) {
|
|
_, ok := resolveAndCheckWS(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
path := c.Param("path")
|
|
if len(path) > 0 && path[0] == '/' {
|
|
path = path[1:]
|
|
}
|
|
|
|
log.Trace("[workspace] handleReadFile id=%s path=%q", c.Param("id"), path)
|
|
|
|
data, err := mgr().ReadFile(context.Background(), c.Param("id"), path)
|
|
if err != nil {
|
|
log.Trace("[workspace] ReadFile error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
log.Trace("[workspace] ReadFile ok, size=%d, encoding=%q", len(data), c.Query("encoding"))
|
|
|
|
if c.Query("encoding") == "base64" {
|
|
response.RespondWithSuccess(c, http.StatusOK, gin.H{
|
|
"content": base64.StdEncoding.EncodeToString(data),
|
|
"encoding": "base64",
|
|
})
|
|
return
|
|
}
|
|
|
|
ext := filepath.Ext(path)
|
|
mimeType := mime.TypeByExtension(ext)
|
|
if mimeType == "" {
|
|
mimeType = "application/octet-stream"
|
|
}
|
|
log.Trace("[workspace] serving ext=%q mime=%q size=%d", ext, mimeType, len(data))
|
|
c.Data(http.StatusOK, mimeType, data)
|
|
}
|
|
|
|
func handleWriteFile(c *gin.Context) {
|
|
_, ok := resolveAndCheckWS(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
path := c.Param("path")
|
|
if len(path) > 0 && path[0] == '/' {
|
|
path = path[1:]
|
|
}
|
|
|
|
data, err := io.ReadAll(c.Request.Body)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
|
|
return
|
|
}
|
|
|
|
if err := mgr().WriteFile(context.Background(), c.Param("id"), path, data, 0644); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func handleDeleteFile(c *gin.Context) {
|
|
_, ok := resolveAndCheckWS(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
path := c.Param("path")
|
|
if len(path) > 0 && path[0] == '/' {
|
|
path = path[1:]
|
|
}
|
|
|
|
if err := mgr().Remove(context.Background(), c.Param("id"), path); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func handleMkdir(c *gin.Context) {
|
|
_, ok := resolveAndCheckWS(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req mkdirRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := mgr().MkdirAll(context.Background(), c.Param("id"), req.Path); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func handleRename(c *gin.Context) {
|
|
_, ok := resolveAndCheckWS(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req renameRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := mgr().Rename(context.Background(), c.Param("id"), req.OldPath, req.NewPath); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|