feat(teams): implement workspace scope setting (shared vs isolated)

- IsSharedWorkspace() reads team.settings.workspace_scope
- Shared: workspace at teams/{teamID}/ (all chats share)
- Isolated (default): workspace at teams/{teamID}/{chatID}/
- Remove _default fallback; isolated mode requires chat_id
- Update loop.go, task creation, task listing, message dispatch,
  workspace API (list/read/delete), task board snapshot
- Update UI descriptions to reflect per-conversation scoping
This commit is contained in:
viettranx committed 2026-03-16 22:46:35 +07:00
1 parent 3f2b6e258e
commit 2c5ae04c01
8 files changed
+129 -40

No files matched your search

+29 -9
View File
@@ -13,6 +13,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -69,15 +70,26 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway.
return
}
// Check if team uses shared workspace (no chatID scoping).
shared := false
if team, err := m.teamStore.GetTeam(ctx, teamID); err == nil {
shared = tools.IsSharedWorkspace(team.Settings)
}
baseDir := teamWorkspaceDir(m.dataDir, teamID, "")
var files []workspaceFileEntry
if params.ChatID != "" {
// Scoped: list files and folders in specific chatID directory.
scopeDir := teamWorkspaceDir(m.dataDir, teamID, params.ChatID)
files = walkDir(scopeDir, "", params.ChatID)
if shared || params.ChatID != "" {
// Shared mode: list team root directly. Scoped mode: list specific chatID.
scopeDir := baseDir
scopeChatID := ""
if !shared && params.ChatID != "" {
scopeDir = teamWorkspaceDir(m.dataDir, teamID, params.ChatID)
scopeChatID = params.ChatID
}
files = walkDir(scopeDir, "", scopeChatID)
} else {
// Unscoped: list all chatID subdirectories with chatID as top-level folder.
// Isolated + unscoped: list all chatID subdirectories with chatID as top-level folder.
entries, err := os.ReadDir(baseDir)
if err != nil {
// Directory doesn't exist = empty workspace.
@@ -186,9 +198,13 @@ func (m *TeamsMethods) handleWorkspaceRead(ctx context.Context, client *gateway.
return
}
// Shared workspace: read from team root. Isolated: require chatID.
chatID := params.ChatID
if chatID == "" {
chatID = "_default"
if team, err := m.teamStore.GetTeam(ctx, teamID); err == nil && tools.IsSharedWorkspace(team.Settings) {
chatID = ""
} else if chatID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "chat_id")))
return
}
scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID)
@@ -258,9 +274,13 @@ func (m *TeamsMethods) handleWorkspaceDelete(ctx context.Context, client *gatewa
return
}
// Shared workspace: delete from team root. Isolated: require chatID.
chatID := params.ChatID
if chatID == "" {
chatID = "_default"
if team, err := m.teamStore.GetTeam(ctx, teamID); err == nil && tools.IsSharedWorkspace(team.Settings) {
chatID = ""
} else if chatID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "chat_id")))
return
}
scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID)
+8 -4
View File
@@ -193,7 +193,7 @@ func (t *TeamMessageTool) executeSend(ctx context.Context, args map[string]any)
// Real-time delivery via message bus
fromKey := t.manager.agentKeyFromID(ctx, agentID)
t.publishTeammateMessage(fromKey, toKey, text, mediaFiles, teamTaskID, team.ID, ctx)
t.publishTeammateMessage(fromKey, toKey, text, mediaFiles, teamTaskID, team.ID, team.Settings, ctx)
preview := text
if len(preview) > 100 {
@@ -249,7 +249,7 @@ func (t *TeamMessageTool) executeBroadcast(ctx context.Context, args map[string]
if m.AgentID == agentID {
continue // don't send to self
}
t.publishTeammateMessage(fromKey, m.AgentKey, text, nil, uuid.Nil, team.ID, ctx)
t.publishTeammateMessage(fromKey, m.AgentKey, text, nil, uuid.Nil, team.ID, team.Settings, ctx)
}
}
@@ -302,7 +302,7 @@ func (t *TeamMessageTool) executeRead(ctx context.Context) *Result {
// publishTeammateMessage sends a real-time notification via the message bus.
// Uses "teammate:{fromKey}" sender prefix so the consumer can route it.
func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, media []bus.MediaFile, teamTaskID uuid.UUID, teamID uuid.UUID, ctx context.Context) {
func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, media []bus.MediaFile, teamTaskID uuid.UUID, teamID uuid.UUID, teamSettings json.RawMessage, ctx context.Context) {
if t.manager.msgBus == nil {
return
}
@@ -337,7 +337,11 @@ func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, me
teamMeta["team_task_id"] = teamTaskID.String()
}
// Pass team workspace so the receiving agent can access shared files.
if ws, err := WorkspaceDir(t.manager.dataDir, teamID, chatID); err == nil {
wsChat := chatID
if IsSharedWorkspace(teamSettings) {
wsChat = ""
}
if ws, err := WorkspaceDir(t.manager.dataDir, teamID, wsChat); err == nil {
teamMeta["team_workspace"] = ws
}
// Propagate trace context so the receiving agent's trace links back.
+14 -3
View File
@@ -107,11 +107,16 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
chatID := ToolChatIDFromCtx(ctx)
// Shared workspace: scope by teamID only. Isolated (default): scope by chatID too.
wsChat := chatID
if IsSharedWorkspace(team.Settings) {
wsChat = ""
}
// Compute the team workspace directory so member agents write files to the
// shared team folder (teams/{teamID}/{chatID}/) instead of their own personal workspace.
// WorkspaceInterceptor redirects write_file/create_image to this path automatically.
// shared team folder instead of their own personal workspace.
taskMeta := make(map[string]any)
if teamWsDir, err := WorkspaceDir(t.manager.dataDir, team.ID, chatID); err == nil {
if teamWsDir, err := WorkspaceDir(t.manager.dataDir, team.ID, wsChat); err == nil {
taskMeta["team_workspace"] = teamWsDir
}
// Preserve original blocked_by list for blocker-result forwarding when task unblocks.
@@ -174,6 +179,8 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: status,
OwnerAgentKey: t.manager.agentKeyFromID(ctx, assigneeID),
UserID: store.UserIDFromContext(ctx),
@@ -196,8 +203,12 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: t.manager.agentKeyFromID(ctx, assigneeID),
Channel: task.Channel,
ChatID: task.ChatID,
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "system",
ActorID: "fallback_dispatch",
+6 -1
View File
@@ -165,6 +165,11 @@ func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *R
filterUserID = store.UserIDFromContext(ctx)
}
chatID := ToolChatIDFromCtx(ctx)
// Shared workspace: show all tasks across chats.
listChatID := chatID
if IsSharedWorkspace(team.Settings) {
listChatID = ""
}
// Acquire team create lock to serialize list→create flows across concurrent goroutines.
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
@@ -174,7 +179,7 @@ func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *R
ptd.MarkListed()
}
tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", chatID, offset)
tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", listChatID, offset)
if err != nil {
return ErrorResult("failed to list tasks: " + err.Error())
}
+21 -5
View File
@@ -1,6 +1,7 @@
package tools
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -15,20 +16,35 @@ const (
)
// WorkspaceDir returns the disk directory for a team workspace scope.
// Pattern: {dataDir}/teams/{teamID}/{chatID}/
// chatID is the system-derived userID (stable across WS reconnects).
// - chatID="" → team root: {dataDir}/teams/{teamID}/ (shared mode)
// - chatID="x" → per-chat: {dataDir}/teams/{teamID}/{chatID}/ (isolated mode)
// Creates directory with 0750 if not exists.
func WorkspaceDir(dataDir string, teamID uuid.UUID, chatID string) (string, error) {
if chatID == "" {
chatID = "_default"
dir := filepath.Join(dataDir, "teams", teamID.String())
if chatID != "" {
dir = filepath.Join(dir, chatID)
}
dir := filepath.Join(dataDir, "teams", teamID.String(), chatID)
if err := os.MkdirAll(dir, 0750); err != nil {
return "", fmt.Errorf("failed to create workspace dir: %w", err)
}
return dir, nil
}
// IsSharedWorkspace returns true if the team's workspace_scope setting is "shared".
// Default (unset or "isolated") returns false.
func IsSharedWorkspace(settings json.RawMessage) bool {
if settings == nil {
return false
}
var s struct {
WorkspaceScope string `json:"workspace_scope"`
}
if json.Unmarshal(settings, &s) != nil {
return false
}
return s.WorkspaceScope == "shared"
}
// blockedExtensions lists executable file types that are not allowed in team workspaces.
var blockedExtensions = map[string]bool{
".exe": true, ".sh": true, ".bat": true, ".cmd": true,
+17 -6
View File
@@ -175,8 +175,19 @@
"deniedChannels": "Denied Channels",
"deniedChannelsHint": "Messages from these channels are always blocked.",
"notifications": "Notifications",
"progressNotifications": "Progress notifications",
"progressNotificationsHint": "Send periodic \"Your team is working on it...\" messages to chat during async delegations.",
"notifyDispatched": "Task dispatched",
"notifyDispatchedHint": "Notify when a task is assigned to a team member.",
"notifyProgress": "Task progress",
"notifyProgressHint": "Notify when a member updates task progress.",
"notifyFailed": "Task failed",
"notifyFailedHint": "Notify when a task fails.",
"notifyMode": "Delivery mode",
"notifyModeHint": "How notifications are delivered to the chat channel.",
"notifyModeDirect": "Direct",
"notifyModeDirectDesc": "Send notifications straight to the chat channel. Fast, no AI processing.",
"notifyModeLeader": "Through Leader",
"notifyModeLeaderDesc": "Leader agent reformulates updates naturally before sending to user.",
"notifyModeLeaderWarning": "Leader mode uses AI to reformulate messages (costs tokens, may be slower).",
"searchUsers": "Search users...",
"selectChannel": "Select channel...",
"escalationPolicy": "Escalation Policy",
@@ -198,12 +209,12 @@
"followupMaxReminders": "Max reminders",
"followupMaxRemindersHint": "Maximum number of reminders per task. 0 = unlimited (remind until user replies).",
"workspace": "Workspace",
"workspaceScope": "Workspace Sharing",
"workspaceScopeHint": "Control how agents access files in the team workspace.",
"workspaceScope": "Workspace Scope",
"workspaceScopeHint": "Control how team workspace files are organized.",
"workspaceScopeIsolated": "Isolated",
"workspaceScopeIsolatedDesc": "Each agent has its own private workspace. Agents cannot read or write files from other agents.",
"workspaceScopeIsolatedDesc": "Each conversation has its own workspace folder. Files from one chat are not visible in another.",
"workspaceScopeShared": "Shared",
"workspaceScopeSharedDesc": "All agents share a single team workspace. Agents can read and write each other's files.",
"workspaceScopeSharedDesc": "All conversations share a single workspace folder. Files created in any chat are visible to all.",
"teamVersion": "Team Version",
"versionBasic": "Basic",
"versionAdvanced": "Advanced",
+17 -6
View File
@@ -175,8 +175,19 @@
"deniedChannels": "Channel bị từ chối",
"deniedChannelsHint": "Tin nhắn từ các channel này luôn bị chặn.",
"notifications": "Thông báo",
"progressNotifications": "Thông báo tiến trình",
"progressNotificationsHint": "Gửi định kỳ thông báo \"Team của bạn đang xử lý...\" trong quá trình ủy quyền bất đồng bộ.",
"notifyDispatched": "Giao nhiệm vụ",
"notifyDispatchedHint": "Thông báo khi nhiệm vụ được giao cho thành viên.",
"notifyProgress": "Tiến trình",
"notifyProgressHint": "Thông báo khi thành viên cập nhật tiến trình.",
"notifyFailed": "Thất bại",
"notifyFailedHint": "Thông báo khi nhiệm vụ thất bại.",
"notifyMode": "Chế độ gửi",
"notifyModeHint": "Cách thông báo được gửi đến kênh chat.",
"notifyModeDirect": "Trực tiếp",
"notifyModeDirectDesc": "Gửi thông báo thẳng đến kênh chat. Nhanh, không qua AI.",
"notifyModeLeader": "Qua Leader",
"notifyModeLeaderDesc": "Leader diễn đạt lại cập nhật một cách tự nhiên trước khi gửi.",
"notifyModeLeaderWarning": "Chế độ Leader dùng AI để diễn đạt lại (tốn token, có thể chậm hơn).",
"searchUsers": "Tìm kiếm người dùng...",
"selectChannel": "Chọn channel...",
"escalationPolicy": "Chính sách Leo thang",
@@ -198,12 +209,12 @@
"followupMaxReminders": "Số lần nhắc tối đa",
"followupMaxRemindersHint": "Số lần nhắc tối đa cho mỗi nhiệm vụ. 0 = không giới hạn (nhắc cho đến khi người dùng trả lời).",
"workspace": "Không gian làm việc",
"workspaceScope": "Chia sẻ không gian làm việc",
"workspaceScopeHint": "Kiểm soát cách các agent truy cập tệp trong không gian làm việc của team.",
"workspaceScope": "Phạm vi workspace",
"workspaceScopeHint": "Kiểm soát cách tổ chức tệp trong workspace của team.",
"workspaceScopeIsolated": "Cô lập",
"workspaceScopeIsolatedDesc": "Mỗi agent có không gian làm việc riêng. Không thể đọc hoặc ghi tệp của agent khác.",
"workspaceScopeIsolatedDesc": "Mỗi cuộc hội thoại có thư mục workspace riêng. Tệp từ chat này không hiển thị ở chat khác.",
"workspaceScopeShared": "Chia sẻ",
"workspaceScopeSharedDesc": "Tất cả agent dùng chung một không gian làm việc. Có thể đọc và ghi tệp của nhau.",
"workspaceScopeSharedDesc": "Tất cả cuộc hội thoại dùng chung một thư mục workspace. Tệp tạo từ bất kỳ chat nào đều hiển thị cho tất cả.",
"teamVersion": "Phiên bản Team",
"versionBasic": "Cơ bản",
"versionAdvanced": "Nâng cao",
+17 -6
View File
@@ -175,8 +175,19 @@
"deniedChannels": "拒绝的Channel",
"deniedChannelsHint": "来自这些Channel的消息始终被阻止。",
"notifications": "通知",
"progressNotifications": "进度通知",
"progressNotificationsHint": "在异步委托期间,定期向对话发送「您的Team正在处理中...」消息。",
"notifyDispatched": "任务分派",
"notifyDispatchedHint": "任务分配给成员时通知。",
"notifyProgress": "任务进度",
"notifyProgressHint": "成员更新进度时通知。",
"notifyFailed": "任务失败",
"notifyFailedHint": "任务失败时通知。",
"notifyMode": "发送方式",
"notifyModeHint": "通知如何发送到聊天频道。",
"notifyModeDirect": "直接发送",
"notifyModeDirectDesc": "直接将通知发送到聊天频道。快速,无需AI处理。",
"notifyModeLeader": "通过Leader",
"notifyModeLeaderDesc": "Leader代理在发送前自然地重新表述更新内容。",
"notifyModeLeaderWarning": "Leader模式使用AI重新表述消息(消耗token,可能更慢)。",
"searchUsers": "搜索用户...",
"selectChannel": "选择Channel...",
"escalationPolicy": "升级策略",
@@ -198,12 +209,12 @@
"followupMaxReminders": "最大提醒次数",
"followupMaxRemindersHint": "每个任务的最大提醒次数。0 = 无限制(持续提醒直到用户回复)。",
"workspace": "工作区",
"workspaceScope": "工作区共享",
"workspaceScopeHint": "控制代理如何访问团队工作区中的文件。",
"workspaceScope": "工作区范围",
"workspaceScopeHint": "控制团队工作区文件的组织方式。",
"workspaceScopeIsolated": "隔离",
"workspaceScopeIsolatedDesc": "每个代理拥有独立的工作区。无法读取或写入其他代理的文件。",
"workspaceScopeIsolatedDesc": "每个对话有独立的工作区文件夹。一个聊天中的文件在另一个聊天中不可见。",
"workspaceScopeShared": "共享",
"workspaceScopeSharedDesc": "所有代理共享同一个工作区。可以读取和写入彼此的文件。",
"workspaceScopeSharedDesc": "所有对话共享一个工作区文件夹。在任何聊天中创建的文件对所有人可见。",
"teamVersion": "团队版本",
"versionBasic": "基础版",
"versionAdvanced": "高级版",