mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 20:17:20 +00:00
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:
1 parent
3f2b6e258e
commit
2c5ae04c01
8 files changed
+129
-40
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "高级版",
|
||||
|
||||
Reference in new issue
Block a user