Files
goclaw/internal/tools/workspace_tool_write.go
T
1a42dc93a6 feat(teams): team system v2 with bug fixes, workspace scope, versioning, and prompt optimization (#183)
* 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>
2026-03-13 22:41:32 +07:00

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)
}
}