mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-31 12:20:48 +00:00
* feat(workspace): add team shared workspace for file collaboration
- Add workspace_write and workspace_read tools for agents to share files across team members
- Create team_workspaces DB table with migration 000017 (file metadata, pinning, tags)
- Implement PostgreSQL store layer for workspace CRUD operations
- Add RPC handlers for workspace list/read/delete from web UI
- Build React workspace tab with file listing, content preview, and delete
- Propagate workspace channel/chatID scope through delegation chain
- Auto-allow workspace tools in agent tool policy when agent belongs to a team
- Inject team workspace guidance into system prompt for team agents
- Add /reset command handler for clearing session history
- Harden MCP bridge context middleware to reject headers when no gateway token
- Add i18n strings for workspace UI in en/vi/zh locales
* feat(teams): add comprehensive task management with followup reminders and recovery
- Add task followup/reminder system with auto-set on lead agent reply and auto-clear when user responds on channel
- Add task recovery ticker to re-dispatch stale/pending tasks periodically
- Add task scopes, filtering by status/channel/chatID, and task events
- Add WS RPC handlers for task CRUD, assignments, comments, events, and bulk operations (teams_tasks.go)
- Add task detail dialog, settings UI for followup config, and scope filtering in web dashboard
- Add migrations 000018 (team_tasks_v2) and 000019 (task_followup)
- Extend team_tasks_tool with await_reply, clear_followup actions
- Auto-complete/fail team tasks when delegate agent finishes
- Add workspace file listing and team tool manager enhancements
* docs(teams): add team system architecture and playbook ideas documentation
- Add TEAM_SYSTEM.md with full architecture design covering task management, shared workspace, and delegation engine subsystems
- Add TEAM_PLAYBOOK_IDEAS.md outlining future team coordination layers (playbook, member capabilities, auto-learned patterns)
- Document data models, status flows, tool actions, followup reminder system, task ticker, execution locking, and workspace scope model
* fix(teams): resolve 6 critical bugs in team task system
- Fix unblock SQL: check array_length after array_remove (not before)
- Enforce single-team leadership in team creation
- Add requireLead() for approve/reject tool actions
- Validate cross-team dependency references in blocked_by
- Add team_id to handoff route for multi-team isolation
- Set blocked_by DEFAULT '{}' to prevent NULL array issues
* refactor(workspace): use stable userID as scope key instead of connection UUID
Workspace scope changed from (team_id, channel, chat_id) to (team_id, userID).
Fixes workspace fragmentation across WS tab refreshes and reconnections.
* feat(teams): add V1/V2 versioning with feature gating and optimized prompts
- IsTeamV2() helper gates advanced features (locking, followup, review, audit)
- V2 tool actions rejected for V1 teams with clear error message
- Ticker, gateway consumer, delegation hooks respect version flag
- TEAM.md renders v1/v2 sections conditionally
- Tool descriptions and params optimized (~38% token reduction)
- UI: version toggle in settings, V2 Beta badge, conditional rendering
- i18n: version modal keys for en/vi/zh
* fix(migration): use VARCHAR(255) for user ID columns and add metadata JSONB
- assignee_user_id, user_id, actor_id: TEXT → VARCHAR(255)
- Add metadata JSONB to team_task_comments and team_task_attachments
---------
Co-authored-by: Nam Nguyen Ngoc <namnn.0911@gmail.com>
405 lines
12 KiB
Go
405 lines
12 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
const (
|
|
maxFileSizeBytes = 10 * 1024 * 1024 // 10MB
|
|
maxFilesPerScope = 100
|
|
maxBatchSize = 20
|
|
maxVersionsPerFile = 5
|
|
)
|
|
|
|
// WorkspaceWriteTool allows agents to write files to the team shared workspace.
|
|
type WorkspaceWriteTool struct {
|
|
manager *TeamToolManager
|
|
dataDir string
|
|
}
|
|
|
|
func NewWorkspaceWriteTool(manager *TeamToolManager, dataDir string) *WorkspaceWriteTool {
|
|
return &WorkspaceWriteTool{manager: manager, dataDir: dataDir}
|
|
}
|
|
|
|
func (t *WorkspaceWriteTool) Name() string { return "workspace_write" }
|
|
|
|
func (t *WorkspaceWriteTool) Description() string {
|
|
return "Write files to the team shared workspace (visible to all members). Supports batch write and template management (lead only)."
|
|
}
|
|
|
|
func (t *WorkspaceWriteTool) Parameters() map[string]any {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"action": map[string]any{
|
|
"type": "string",
|
|
"description": "'write' (default) or 'set_template' (lead only)",
|
|
},
|
|
"file_name": map[string]any{
|
|
"type": "string",
|
|
"description": "File name (alphanumeric + hyphens/underscores/dots, max 255 chars)",
|
|
},
|
|
"content": map[string]any{
|
|
"type": "string",
|
|
"description": "File content (text)",
|
|
},
|
|
"files": map[string]any{
|
|
"type": "array",
|
|
"description": "Batch write: array of {file_name, content} objects (max 20)",
|
|
"items": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"file_name": map[string]any{"type": "string"},
|
|
"content": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
"scope": map[string]any{
|
|
"type": "string",
|
|
"description": "'channel' (default, per-user) or 'team' (shared, requires workspace_scope=shared)",
|
|
},
|
|
"task_id": map[string]any{
|
|
"type": "string",
|
|
"description": "Link file to a team task ID (optional)",
|
|
},
|
|
"templates": map[string]any{
|
|
"type": "array",
|
|
"description": "For action=set_template: array of {file_name, content}",
|
|
"items": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"file_name": map[string]any{"type": "string"},
|
|
"content": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
type writeFileEntry struct {
|
|
FileName string `json:"file_name"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
func (t *WorkspaceWriteTool) Execute(ctx context.Context, args map[string]any) *Result {
|
|
action, _ := args["action"].(string)
|
|
if action == "" {
|
|
action = "write"
|
|
}
|
|
|
|
team, agentID, err := t.manager.resolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
role, err := t.manager.resolveTeamRole(ctx, team, agentID)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
ws := parseWorkspaceSettings(team.Settings)
|
|
|
|
switch action {
|
|
case "set_template":
|
|
return t.executeSetTemplate(ctx, args, team, role)
|
|
case "write":
|
|
return t.executeWrite(ctx, args, team, agentID, role, ws)
|
|
default:
|
|
return ErrorResult(fmt.Sprintf("unknown action %q (use 'write' or 'set_template')", action))
|
|
}
|
|
}
|
|
|
|
func (t *WorkspaceWriteTool) executeSetTemplate(ctx context.Context, args map[string]any, team *store.TeamData, role string) *Result {
|
|
if role != store.TeamRoleLead {
|
|
return ErrorResult("only the team lead can set workspace templates")
|
|
}
|
|
|
|
// Check escalation policy.
|
|
if esc := t.manager.checkEscalation(team, "set_template"); esc != EscalationNone {
|
|
if esc == EscalationReject {
|
|
return ErrorResult("set_template action is not allowed by team escalation policy")
|
|
}
|
|
agentID := store.AgentIDFromContext(ctx)
|
|
return t.manager.createEscalationTask(ctx, team, agentID,
|
|
"Set workspace templates",
|
|
"Agent requested to update workspace templates.")
|
|
}
|
|
|
|
templatesRaw, ok := args["templates"]
|
|
if !ok {
|
|
return ErrorResult("templates parameter is required for action=set_template")
|
|
}
|
|
templatesJSON, err := json.Marshal(templatesRaw)
|
|
if err != nil {
|
|
return ErrorResult("invalid templates format")
|
|
}
|
|
var templates []writeFileEntry
|
|
if err := json.Unmarshal(templatesJSON, &templates); err != nil {
|
|
return ErrorResult("templates must be array of {file_name, content}")
|
|
}
|
|
|
|
// Validate template file names.
|
|
for _, tmpl := range templates {
|
|
if _, err := sanitizeFileName(tmpl.FileName); err != nil {
|
|
return ErrorResult(fmt.Sprintf("template %q: %s", tmpl.FileName, err))
|
|
}
|
|
}
|
|
|
|
// Update team settings with templates.
|
|
var settings map[string]any
|
|
if team.Settings != nil {
|
|
_ = json.Unmarshal(team.Settings, &settings)
|
|
}
|
|
if settings == nil {
|
|
settings = make(map[string]any)
|
|
}
|
|
settings["workspace_templates"] = templates
|
|
settingsJSON, _ := json.Marshal(settings)
|
|
|
|
if err := t.manager.teamStore.UpdateTeam(ctx, team.ID, map[string]any{"settings": settingsJSON}); err != nil {
|
|
return ErrorResult("failed to save templates: " + err.Error())
|
|
}
|
|
t.manager.InvalidateTeam()
|
|
|
|
return NewResult(fmt.Sprintf("Set %d workspace template(s)", len(templates)))
|
|
}
|
|
|
|
func (t *WorkspaceWriteTool) executeWrite(ctx context.Context, args map[string]any, team *store.TeamData, agentID uuid.UUID, role string, ws workspaceSettings) *Result {
|
|
if role == store.TeamRoleReviewer {
|
|
return ErrorResult("reviewers cannot write to the workspace")
|
|
}
|
|
|
|
// Resolve scope.
|
|
channel, chatID, scopeErr := resolveWorkspaceScopeFromArgs(ctx, args, ws)
|
|
if scopeErr != "" {
|
|
return ErrorResult(scopeErr)
|
|
}
|
|
|
|
// Optional task linkage.
|
|
var taskID *uuid.UUID
|
|
if tid, ok := args["task_id"].(string); ok && tid != "" {
|
|
parsed, err := uuid.Parse(tid)
|
|
if err != nil {
|
|
return ErrorResult("invalid task_id: " + err.Error())
|
|
}
|
|
taskID = &parsed
|
|
}
|
|
|
|
// Normalize input to batch.
|
|
var entries []writeFileEntry
|
|
if filesRaw, ok := args["files"]; ok {
|
|
filesJSON, err := json.Marshal(filesRaw)
|
|
if err != nil {
|
|
return ErrorResult("invalid files format")
|
|
}
|
|
if err := json.Unmarshal(filesJSON, &entries); err != nil {
|
|
return ErrorResult("files must be array of {file_name, content}")
|
|
}
|
|
} else {
|
|
fn, _ := args["file_name"].(string)
|
|
content, _ := args["content"].(string)
|
|
if fn == "" {
|
|
return ErrorResult("file_name is required")
|
|
}
|
|
entries = []writeFileEntry{{FileName: fn, Content: content}}
|
|
}
|
|
|
|
if len(entries) == 0 {
|
|
return ErrorResult("no files to write")
|
|
}
|
|
if len(entries) > maxBatchSize {
|
|
return ErrorResult(fmt.Sprintf("batch size exceeds limit (%d max)", maxBatchSize))
|
|
}
|
|
|
|
// Validate all entries before writing.
|
|
totalNewBytes := int64(0)
|
|
for i, e := range entries {
|
|
name, err := sanitizeFileName(e.FileName)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("file %d: %s", i+1, err))
|
|
}
|
|
entries[i].FileName = name
|
|
|
|
if _, err := inferMimeType(name); err != nil {
|
|
return ErrorResult(fmt.Sprintf("file %q: %s", name, err))
|
|
}
|
|
if len(e.Content) > maxFileSizeBytes {
|
|
return ErrorResult(fmt.Sprintf("file %q exceeds max size (10MB)", name))
|
|
}
|
|
totalNewBytes += int64(len(e.Content))
|
|
}
|
|
|
|
// Check file count limit.
|
|
count, err := t.manager.teamStore.CountWorkspaceFiles(ctx, team.ID, channel, chatID)
|
|
if err != nil {
|
|
return ErrorResult("failed to count workspace files: " + err.Error())
|
|
}
|
|
|
|
// Auto-seed templates on first write to this scope.
|
|
if count == 0 {
|
|
t.seedTemplates(ctx, team, agentID, channel, chatID, ws)
|
|
// Recount after seeding.
|
|
count, _ = t.manager.teamStore.CountWorkspaceFiles(ctx, team.ID, channel, chatID)
|
|
}
|
|
|
|
if count+len(entries) > maxFilesPerScope {
|
|
return ErrorResult(fmt.Sprintf("workspace file limit reached (%d/%d)", count, maxFilesPerScope))
|
|
}
|
|
|
|
// Check quota.
|
|
quotaMB := ws.quotaMB(defaultQuotaMB)
|
|
if quotaMB > 0 {
|
|
totalSize, err := t.manager.teamStore.GetWorkspaceTotalSize(ctx, team.ID)
|
|
if err == nil && totalSize+totalNewBytes > int64(quotaMB)*1024*1024 {
|
|
usedMB := float64(totalSize) / (1024 * 1024)
|
|
return ErrorResult(fmt.Sprintf("team workspace quota exceeded (used %.1f MB / %d MB limit)", usedMB, quotaMB))
|
|
}
|
|
}
|
|
|
|
// Write files.
|
|
dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
var results []string
|
|
var errors []string
|
|
for _, e := range entries {
|
|
mimeType, _ := inferMimeType(e.FileName)
|
|
diskPath := filepath.Join(dir, e.FileName)
|
|
|
|
// Upsert DB metadata with disk I/O inside advisory lock.
|
|
// diskWriteFn runs after lock is acquired — prevents concurrent writes to same file.
|
|
file := &store.TeamWorkspaceFileData{
|
|
TeamID: team.ID,
|
|
Channel: channel,
|
|
ChatID: chatID,
|
|
FileName: e.FileName,
|
|
MimeType: mimeType,
|
|
FilePath: diskPath,
|
|
SizeBytes: int64(len(e.Content)),
|
|
UploadedBy: agentID,
|
|
TaskID: taskID,
|
|
}
|
|
diskWriteFn := func(isNew bool) error {
|
|
// Version existing file before overwrite.
|
|
if !isNew {
|
|
existing, _ := t.manager.teamStore.GetWorkspaceFile(ctx, team.ID, channel, chatID, e.FileName)
|
|
if existing != nil {
|
|
versions, _ := t.manager.teamStore.ListFileVersions(ctx, existing.ID)
|
|
nextVersion := 1
|
|
if len(versions) > 0 {
|
|
nextVersion = versions[0].Version + 1
|
|
}
|
|
versionPath := fmt.Sprintf("%s.v%d", diskPath, nextVersion)
|
|
if _, statErr := os.Stat(diskPath); statErr == nil {
|
|
_ = os.Rename(diskPath, versionPath)
|
|
_ = t.manager.teamStore.CreateFileVersion(ctx, &store.TeamWorkspaceFileVersionData{
|
|
FileID: existing.ID,
|
|
Version: nextVersion,
|
|
FilePath: versionPath,
|
|
SizeBytes: existing.SizeBytes,
|
|
UploadedBy: existing.UploadedBy,
|
|
})
|
|
prunedPaths, _ := t.manager.teamStore.PruneOldVersions(ctx, existing.ID, maxVersionsPerFile)
|
|
for _, p := range prunedPaths {
|
|
_ = os.Remove(p)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return os.WriteFile(diskPath, []byte(e.Content), 0640)
|
|
}
|
|
|
|
_, err := t.manager.teamStore.UpsertWorkspaceFile(ctx, file, diskWriteFn)
|
|
if err != nil {
|
|
errors = append(errors, fmt.Sprintf("%s: %s", e.FileName, err))
|
|
continue
|
|
}
|
|
|
|
results = append(results, fmt.Sprintf("%s (%s)", e.FileName, formatBytes(int64(len(e.Content)))))
|
|
|
|
// Broadcast event.
|
|
t.manager.broadcastTeamEvent(protocol.EventWorkspaceFileChanged, map[string]string{
|
|
"team_id": team.ID.String(),
|
|
"channel": channel,
|
|
"chat_id": chatID,
|
|
"file_name": e.FileName,
|
|
"action": "write",
|
|
})
|
|
}
|
|
|
|
if len(results) == 0 && len(errors) > 0 {
|
|
return ErrorResult("all writes failed:\n" + strings.Join(errors, "\n"))
|
|
}
|
|
|
|
msg := fmt.Sprintf("Written %d file(s) to workspace: %s", len(results), strings.Join(results, ", "))
|
|
if len(errors) > 0 {
|
|
msg += fmt.Sprintf("\n%d failed: %s", len(errors), strings.Join(errors, "; "))
|
|
}
|
|
return NewResult(msg)
|
|
}
|
|
|
|
func (t *WorkspaceWriteTool) seedTemplates(ctx context.Context, team *store.TeamData, agentID uuid.UUID, channel, chatID string, ws workspaceSettings) {
|
|
if len(ws.WorkspaceTemplates) == 0 {
|
|
return
|
|
}
|
|
templates := ws.WorkspaceTemplates
|
|
|
|
dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, tmpl := range templates {
|
|
name, err := sanitizeFileName(tmpl.FileName)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
mimeType, _ := inferMimeType(name)
|
|
diskPath := filepath.Join(dir, name)
|
|
|
|
file := &store.TeamWorkspaceFileData{
|
|
TeamID: team.ID,
|
|
Channel: channel,
|
|
ChatID: chatID,
|
|
FileName: name,
|
|
MimeType: mimeType,
|
|
FilePath: diskPath,
|
|
SizeBytes: int64(len(tmpl.Content)),
|
|
UploadedBy: agentID,
|
|
Tags: []string{"reference"},
|
|
}
|
|
content := tmpl.Content
|
|
if _, err := t.manager.teamStore.UpsertWorkspaceFile(ctx, file, func(_ bool) error {
|
|
return os.WriteFile(diskPath, []byte(content), 0640)
|
|
}); err != nil {
|
|
slog.Warn("workspace: template seed failed", "file", name, "error", err)
|
|
}
|
|
}
|
|
slog.Info("workspace: seeded templates", "count", len(templates), "team", team.ID, "channel", channel, "chat_id", chatID)
|
|
}
|
|
|
|
func formatBytes(b int64) string {
|
|
switch {
|
|
case b >= 1024*1024:
|
|
return fmt.Sprintf("%.1f MB", float64(b)/(1024*1024))
|
|
case b >= 1024:
|
|
return fmt.Sprintf("%.1f KB", float64(b)/1024)
|
|
default:
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
}
|