package tools import ( "context" "encoding/json" "fmt" "sync" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/store" ) const listPageSize = 30 // blockerSummary is a compact view of a blocker task for blocked_by resolution. type blockerSummary struct { ID uuid.UUID `json:"id"` Subject string `json:"subject"` Status string `json:"status"` OwnerAgentKey string `json:"owner_agent_key,omitempty"` } // taskListItem is the slim view returned by list/search actions. // Excludes UUIDs (owner_agent_id, created_by_agent_id) and task_number — model uses // agent keys and identifier instead. type taskListItem struct { ID uuid.UUID `json:"id"` Identifier string `json:"identifier"` Subject string `json:"subject"` Status string `json:"status"` OwnerAgentKey string `json:"owner_agent_key,omitempty"` OwnerDisplayName string `json:"owner_display_name,omitempty"` CreatedByAgentKey string `json:"created_by_agent_key,omitempty"` CreatedByDisplayName string `json:"created_by_display_name,omitempty"` ProgressPercent int `json:"progress_percent,omitempty"` ProgressStep string `json:"progress_step,omitempty"` BlockedBy []blockerSummary `json:"blocked_by,omitempty"` CreatedAt time.Time `json:"created_at"` } // taskDetailItem is the slim view returned by the get action. type taskDetailItem struct { ID uuid.UUID `json:"id"` Identifier string `json:"identifier"` Subject string `json:"subject"` Description string `json:"description,omitempty"` Status string `json:"status"` Result *string `json:"result,omitempty"` OwnerAgentKey string `json:"owner_agent_key,omitempty"` OwnerDisplayName string `json:"owner_display_name,omitempty"` CreatedByAgentKey string `json:"created_by_agent_key,omitempty"` CreatedByDisplayName string `json:"created_by_display_name,omitempty"` ProgressPercent int `json:"progress_percent,omitempty"` ProgressStep string `json:"progress_step,omitempty"` BlockedBy []blockerSummary `json:"blocked_by,omitempty"` Priority int `json:"priority"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // slimComment is the slim comment view for get response. type slimComment struct { AgentKey string `json:"agent_key"` Content string `json:"content"` CreatedAt time.Time `json:"created_at"` } // slimEvent is the slim event view for get response. type slimEvent struct { EventType string `json:"event_type"` ActorID string `json:"actor_id"` CreatedAt time.Time `json:"created_at"` } // teamCreateLocks serializes list→create flows per (teamID:chatID) pair. var teamCreateLocks sync.Map // key: "teamID:chatID" → *sync.Mutex func getTeamCreateLock(teamID, chatID string) *sync.Mutex { key := teamID + ":" + chatID v, _ := teamCreateLocks.LoadOrStore(key, &sync.Mutex{}) return v.(*sync.Mutex) } // resolveBlockers batch-loads blocker tasks and returns slim summaries. func (t *TeamTasksTool) resolveBlockers(ctx context.Context, blockedBy []uuid.UUID) []blockerSummary { if len(blockedBy) == 0 { return nil } tasks, err := t.manager.teamStore.GetTasksByIDs(ctx, blockedBy) if err != nil { return nil } out := make([]blockerSummary, 0, len(tasks)) for _, task := range tasks { out = append(out, blockerSummary{ ID: task.ID, Subject: task.Subject, Status: task.Status, OwnerAgentKey: task.OwnerAgentKey, }) } return out } func (t *TeamTasksTool) toListItem(ctx context.Context, task store.TeamTaskData) taskListItem { return taskListItem{ ID: task.ID, Identifier: task.Identifier, Subject: task.Subject, Status: task.Status, OwnerAgentKey: task.OwnerAgentKey, OwnerDisplayName: t.manager.agentDisplayName(ctx, task.OwnerAgentKey), CreatedByAgentKey: task.CreatedByAgentKey, CreatedByDisplayName: t.manager.agentDisplayName(ctx, task.CreatedByAgentKey), ProgressPercent: task.ProgressPercent, ProgressStep: task.ProgressStep, BlockedBy: t.resolveBlockers(ctx, task.BlockedBy), CreatedAt: task.CreatedAt, } } func (t *TeamTasksTool) toDetailItem(ctx context.Context, task *store.TeamTaskData) taskDetailItem { return taskDetailItem{ ID: task.ID, Identifier: task.Identifier, Subject: task.Subject, Description: task.Description, Status: task.Status, Result: task.Result, OwnerAgentKey: task.OwnerAgentKey, OwnerDisplayName: t.manager.agentDisplayName(ctx, task.OwnerAgentKey), CreatedByAgentKey: task.CreatedByAgentKey, CreatedByDisplayName: t.manager.agentDisplayName(ctx, task.CreatedByAgentKey), ProgressPercent: task.ProgressPercent, ProgressStep: task.ProgressStep, BlockedBy: t.resolveBlockers(ctx, task.BlockedBy), Priority: task.Priority, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, } } func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *Result { team, _, err := t.manager.resolveTeam(ctx) if err != nil { return ErrorResult(err.Error()) } statusFilter, _ := args["status"].(string) page := 1 if p, ok := args["page"].(float64); ok && int(p) > 1 { page = int(p) } offset := (page - 1) * listPageSize // Teammate/system channels see all tasks; end users only see their own. filterUserID := "" channel := ToolChannelFromCtx(ctx) if channel != ChannelTeammate && channel != ChannelSystem { 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() { lock := getTeamCreateLock(team.ID.String(), chatID) lock.Lock() ptd.SetTeamLock(lock) ptd.MarkListed() } tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", listChatID, 0, offset) if err != nil { return ErrorResult("failed to list tasks: " + err.Error()) } hasMore := len(tasks) > listPageSize if hasMore { tasks = tasks[:listPageSize] } items := make([]taskListItem, 0, len(tasks)) for _, task := range tasks { items = append(items, t.toListItem(ctx, task)) } resp := map[string]any{ "tasks": items, "count": len(items), "page": page, } if hasMore { resp["has_more"] = true } out, _ := json.Marshal(resp) return SilentResult(string(out)) } // resolveTaskID extracts and validates the task_id from tool arguments. // Falls back to the dispatched task ID from context when task_id is empty or // not a valid UUID (agents often pass task_number like "1" instead of the UUID). func resolveTaskID(ctx context.Context, args map[string]any) (uuid.UUID, error) { taskIDStr, _ := args["task_id"].(string) // Try parsing as UUID first. if taskIDStr != "" { if id, err := uuid.Parse(taskIDStr); err == nil { return id, nil } } // Fall back to the dispatched team task ID from context. if ctxID := TeamTaskIDFromCtx(ctx); ctxID != "" { if id, err := uuid.Parse(ctxID); err == nil { return id, nil } } if taskIDStr == "" { return uuid.Nil, fmt.Errorf("task_id is required") } return uuid.Nil, fmt.Errorf("invalid task_id %q — use the UUID from task list, not the task number", taskIDStr) } func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Result { team, _, err := t.manager.resolveTeam(ctx) if err != nil { return ErrorResult(err.Error()) } taskID, err := resolveTaskID(ctx, args) if err != nil { return ErrorResult(err.Error()) } task, err := t.manager.teamStore.GetTask(ctx, taskID) if err != nil { return ErrorResult("failed to get task: " + err.Error()) } if task.TeamID != team.ID { return ErrorResult("task does not belong to your team") } // Truncate result for context protection const maxResultRunes = 8000 if task.Result != nil { r := []rune(*task.Result) if len(r) > maxResultRunes { s := string(r[:maxResultRunes]) + "..." task.Result = &s } } detail := t.toDetailItem(ctx, task) // Load and slim comments/events/attachments resp := map[string]any{"task": detail} if comments, _ := t.manager.teamStore.ListTaskComments(ctx, taskID); len(comments) > 0 { slim := make([]slimComment, 0, len(comments)) for _, c := range comments { key := "" if c.AgentID != nil { key = t.manager.agentKeyFromID(ctx, *c.AgentID) } slim = append(slim, slimComment{ AgentKey: key, Content: c.Content, CreatedAt: c.CreatedAt, }) } resp["comments"] = slim } if events, _ := t.manager.teamStore.ListTaskEvents(ctx, taskID); len(events) > 0 { slim := make([]slimEvent, 0, len(events)) for _, e := range events { slim = append(slim, slimEvent{ EventType: e.EventType, ActorID: e.ActorID, CreatedAt: e.CreatedAt, }) } resp["events"] = slim } if attachments, _ := t.manager.teamStore.ListTaskAttachments(ctx, taskID); len(attachments) > 0 { resp["attachments"] = attachments } out, _ := json.Marshal(resp) return SilentResult(string(out)) } func (t *TeamTasksTool) executeSearch(ctx context.Context, args map[string]any) *Result { team, _, err := t.manager.resolveTeam(ctx) if err != nil { return ErrorResult(err.Error()) } query, _ := args["query"].(string) if query == "" { return ErrorResult("query is required for search action") } // Teammate/system channels see all tasks; end users only see their own. filterUserID := "" channel := ToolChannelFromCtx(ctx) if channel != ChannelTeammate && channel != ChannelSystem { filterUserID = store.UserIDFromContext(ctx) } // Acquire team create lock so search also satisfies the list-before-create gate. chatID := ToolChatIDFromCtx(ctx) if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() { lock := getTeamCreateLock(team.ID.String(), chatID) lock.Lock() ptd.SetTeamLock(lock) ptd.MarkListed() } tasks, err := t.manager.teamStore.SearchTasks(ctx, team.ID, query, listPageSize, filterUserID) if err != nil { return ErrorResult("failed to search tasks: " + err.Error()) } items := make([]taskListItem, 0, len(tasks)) for _, task := range tasks { items = append(items, t.toListItem(ctx, task)) } out, _ := json.Marshal(map[string]any{ "tasks": items, "count": len(items), }) return SilentResult(string(out)) }