Files
goclaw/internal/tools/team_tasks_mutations.go
T
viettranx c7d0bc19f8 fix(teams): auto-copy media files to team workspace on task creation, scope task_number per chat
- Add RunMediaPaths context key to track media files from current run
- Collect persisted media paths in agent loop after enrichment
- Auto-copy media files to {workspace}/attachments/ when leader creates task
- Append attached files hint in dispatch content so members know what to read
- Scope task_number per (team_id, chat_id) instead of global per team
- Fix NULL chat_id comparison with COALESCE
- Use hard link first, copy fallback to save disk space
- Validate filenames and use restrictive file permissions (0640)
2026-03-18 12:58:09 +07:00

479 lines
16 KiB
Go

package tools
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
// Gate: must list tasks before creating to prevent duplicates in concurrent group chat.
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
return ErrorResult("You must check existing tasks first. Call team_tasks(action=\"search\", query=\"<keywords>\") to check for similar tasks before creating — this saves tokens vs listing all. Alternatively use action=\"list\" to see the full board.")
}
subject, _ := args["subject"].(string)
if subject == "" {
return ErrorResult("subject is required for create action")
}
description, _ := args["description"].(string)
priority := 0
if p, ok := args["priority"].(float64); ok {
priority = int(p)
}
var blockedBy []uuid.UUID
if raw, ok := args["blocked_by"].([]any); ok {
for _, v := range raw {
if s, ok := v.(string); ok {
id, err := uuid.Parse(s)
if err != nil {
return ErrorResult(fmt.Sprintf("blocked_by contains invalid task ID %q — must be a real task UUID from a previous create call. Create dependency tasks first, then use their IDs.", s))
}
blockedBy = append(blockedBy, id)
}
}
}
// Validate that all blocked_by tasks belong to the same team and are not terminal.
for _, depID := range blockedBy {
depTask, err := t.manager.teamStore.GetTask(ctx, depID)
if err != nil {
return ErrorResult(fmt.Sprintf("blocked_by task %s not found: %v", depID, err))
}
if depTask.TeamID != team.ID {
return ErrorResult(fmt.Sprintf("blocked_by task %s belongs to a different team", depID))
}
switch depTask.Status {
case store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, store.TeamTaskStatusFailed:
return ErrorResult(fmt.Sprintf(
"blocked_by task %s (%s) is already %s. "+
"Do not block on finished tasks — create this task without blocked_by, "+
"or pass the completed task's result in the description instead.",
depID, depTask.Subject, depTask.Status))
}
}
// Resolve assignee (agent key → UUID). Required — every task must be assigned.
assigneeKey, _ := args["assignee"].(string)
if assigneeKey == "" {
return ErrorResult("assignee is required — specify which team member should handle this task")
}
assigneeID, err := t.manager.resolveAgentByKey(assigneeKey)
if err != nil {
return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err))
}
// Verify assignee is a member of this team.
members, err := t.manager.cachedListMembers(ctx, team.ID, agentID)
if err != nil {
return ErrorResult("failed to verify team membership: " + err.Error())
}
isMember := false
for _, m := range members {
if m.AgentID == assigneeID {
isMember = true
break
}
}
if !isMember {
return ErrorResult(fmt.Sprintf("agent %q is not a member of this team", assigneeKey))
}
requireApproval, _ := args["require_approval"].(bool)
status := store.TeamTaskStatusPending
if requireApproval {
status = store.TeamTaskStatusInReview
} else if len(blockedBy) > 0 {
status = store.TeamTaskStatusBlocked
}
// Assigned tasks without blockers stay pending — dispatched after the turn
// ends via post-turn processing (avoids race with blocked_by setup).
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 instead of their own personal workspace.
taskMeta := make(map[string]any)
if teamWsDir, err := WorkspaceDir(t.manager.dataDir, team.ID, wsChat); err == nil {
taskMeta["team_workspace"] = teamWsDir
}
// Auto-collect media files from current run to team workspace.
// When leader received files from user and creates a task, copy those
// files to the team workspace so members can access them via read_file.
// Also rewrite any media paths in the description to point to the workspace copy,
// since members can't access the original .media/ paths outside their workspace.
if mediaPaths := RunMediaPathsFromCtx(ctx); len(mediaPaths) > 0 {
if wsDir, _ := taskMeta["team_workspace"].(string); wsDir != "" {
nameMap := RunMediaNamesFromCtx(ctx)
if copiedPaths := copyMediaToWorkspace(mediaPaths, wsDir, nameMap); len(copiedPaths) > 0 {
// Store as []any so type assertion works both before and after JSON round-trip.
files := make([]any, len(copiedPaths))
for i, p := range copiedPaths {
files[i] = p
}
taskMeta["attached_files"] = files
// Rewrite media paths in description so members see workspace paths.
for i, src := range mediaPaths {
if i < len(copiedPaths) {
description = strings.ReplaceAll(description, src, copiedPaths[i])
}
}
}
}
}
// Preserve original blocked_by list for blocker-result forwarding when task unblocks.
if len(blockedBy) > 0 {
ids := make([]string, len(blockedBy))
for i, id := range blockedBy {
ids[i] = id.String()
}
taskMeta["original_blocked_by"] = ids
}
// Store peer kind so dispatches preserve the correct session scope (group vs direct).
if pk := ToolPeerKindFromCtx(ctx); pk != "" {
taskMeta["peer_kind"] = pk
}
// Store local key so forum-topic routing works on deferred/unblocked dispatches.
if lk := ToolLocalKeyFromCtx(ctx); lk != "" {
taskMeta["local_key"] = lk
}
// Store leader's trace context so unblocked dispatch links back to the leader's trace.
if traceID := tracing.TraceIDFromContext(ctx); traceID != uuid.Nil {
taskMeta["origin_trace_id"] = traceID.String()
}
if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil {
taskMeta["origin_root_span_id"] = rootSpanID.String()
}
task := &store.TeamTaskData{
TeamID: team.ID,
Subject: subject,
Description: description,
Status: status,
BlockedBy: blockedBy,
Priority: priority,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
TaskType: "general",
CreatedByAgentID: &agentID,
ChatID: chatID,
Metadata: taskMeta,
}
task.OwnerAgentID = &assigneeID
if err := t.manager.teamStore.CreateTask(ctx, task); err != nil {
return ErrorResult("failed to create task: " + err.Error())
}
agentKey := t.manager.agentKeyFromID(ctx, agentID)
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
Subject: subject,
Status: status,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: chatID,
Timestamp: task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: agentKey,
})
// Track for post-turn dispatch. If no post-turn hook (e.g. HTTP API), dispatch immediately.
if status == store.TeamTaskStatusPending {
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil {
ptd.Add(team.ID, task.ID)
} else {
// Fallback: assign (pending → in_progress + lock) then dispatch.
if err := t.manager.teamStore.AssignTask(ctx, task.ID, assigneeID, team.ID); err != nil {
slog.Warn("executeCreate: fallback assign failed", "task_id", task.ID, "error", err)
} else {
t.manager.broadcastTeamEvent(protocol.EventTeamTaskDispatched, 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",
})
t.manager.dispatchTaskToAgent(ctx, task, team.ID, assigneeID)
}
}
}
assigneeName := t.manager.agentDisplayName(ctx, t.manager.agentKeyFromID(ctx, assigneeID))
if assigneeName == "" {
assigneeName = t.manager.agentKeyFromID(ctx, assigneeID)
}
return NewResult(fmt.Sprintf("Task created: %s (id=%s, task_number=%d, status=%s, assignee=%s)", subject, task.ID, task.TaskNumber, status, assigneeName))
}
func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
text, _ := args["text"].(string)
if text == "" {
return ErrorResult("text is required for comment action")
}
if len(text) > 10000 {
return ErrorResult("comment text too long (max 10000 chars)")
}
// Verify task belongs to team.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
if err := t.manager.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
TaskID: taskID,
AgentID: &agentID,
Content: text,
}); err != nil {
return ErrorResult("failed to add comment: " + err.Error())
}
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
return NewResult(fmt.Sprintf("Comment added to task %s.", taskID))
}
func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
percent := 0
if p, ok := args["percent"].(float64); ok {
percent = int(p)
}
if percent < 0 || percent > 100 {
return ErrorResult("percent must be 0-100")
}
step, _ := args["text"].(string)
// Verify ownership.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
if task.OwnerAgentID == nil || *task.OwnerAgentID != agentID {
return ErrorResult("only the assigned task owner can update progress. As team lead, task results arrive automatically when members complete their work.")
}
if err := t.manager.teamStore.UpdateTaskProgress(ctx, taskID, team.ID, percent, step); err != nil {
return ErrorResult("failed to update progress: " + err.Error())
}
ownerKey := ""
if task.OwnerAgentID != nil {
ownerKey = t.manager.agentKeyFromID(ctx, *task.OwnerAgentID)
}
t.manager.broadcastTeamEvent(protocol.EventTeamTaskProgress, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: ownerKey,
ProgressPercent: percent,
ProgressStep: step,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
return SilentResult(fmt.Sprintf("Progress updated: %d%% %s", percent, step))
}
func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
fileIDStr, _ := args["file_id"].(string)
if fileIDStr == "" {
return ErrorResult("file_id is required for attach action")
}
fileID, err := uuid.Parse(fileIDStr)
if err != nil {
return ErrorResult("invalid file_id")
}
// Verify task belongs to team.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
if err := t.manager.teamStore.AttachFileToTask(ctx, &store.TeamTaskAttachmentData{
TaskID: taskID,
FileID: fileID,
AddedBy: &agentID,
}); err != nil {
return ErrorResult("failed to attach file: " + err.Error())
}
return NewResult(fmt.Sprintf("File attached to task %s.", taskID))
}
func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
// Verify task belongs to this team (prevent cross-team update).
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("task not found: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
updates := map[string]any{}
if desc, ok := args["description"].(string); ok {
updates["description"] = desc
}
if subj, ok := args["subject"].(string); ok && subj != "" {
updates["subject"] = subj
}
if raw, ok := args["blocked_by"].([]any); ok {
var blockedBy []uuid.UUID
for _, v := range raw {
if s, ok := v.(string); ok {
id, err := uuid.Parse(s)
if err != nil {
return ErrorResult(fmt.Sprintf("blocked_by contains invalid task ID %q — must be a real task UUID.", s))
}
blockedBy = append(blockedBy, id)
}
}
// Batch-validate all blocker tasks in one query.
if len(blockedBy) > 0 {
depTasks, err := t.manager.teamStore.GetTasksByIDs(ctx, blockedBy)
if err != nil {
return ErrorResult("failed to validate blocked_by: " + err.Error())
}
depMap := make(map[uuid.UUID]*store.TeamTaskData, len(depTasks))
for i := range depTasks {
depMap[depTasks[i].ID] = &depTasks[i]
}
for _, id := range blockedBy {
dt, ok := depMap[id]
if !ok {
return ErrorResult(fmt.Sprintf("blocked_by task %s not found", id))
}
if dt.TeamID != team.ID {
return ErrorResult(fmt.Sprintf("blocked_by task %s belongs to a different team", id))
}
switch dt.Status {
case store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, store.TeamTaskStatusFailed:
return ErrorResult(fmt.Sprintf(
"blocked_by task %s (%s) is already %s. "+
"Remove it from blocked_by — finished tasks cannot block new work.",
id, dt.Subject, dt.Status))
}
}
}
updates["blocked_by"] = blockedBy
}
if len(updates) == 0 {
return ErrorResult("no updates provided (set description, subject, or blocked_by)")
}
if err := t.manager.teamStore.UpdateTask(ctx, taskID, updates); err != nil {
return ErrorResult("failed to update task: " + err.Error())
}
t.manager.broadcastTeamEvent(protocol.EventTeamTaskUpdated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
Status: task.Status,
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: ToolChatIDFromCtx(ctx),
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: t.manager.agentKeyFromID(ctx, agentID),
})
return NewResult(fmt.Sprintf("Task %s updated.", taskID))
}