mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-05 10:13:27 +00:00
6-phase pure refactor — zero behavior change, all tests pass. Phase 1: DRY event payloads + metadata keys - BuildTaskEventPayload() helper with functional options replaces 24 inline constructions - 26 metadata string constants replace ~66 magic strings across dispatch/consumer Phase 2: Consumer handler decomposition - ConsumerDeps struct collapses 11-13 positional params to 3 per handler - Extract startTaskLockRenewal() and resolveTeamTaskOutcome() Phase 3: loop.go extraction (978→686 LOC) - enrichInputMedia(): media persistence, ref collection, tag enrichment - injectTeamTaskReminders(): leader/member task context injection - buildFilteredTools(): policy, bootstrap, channel, iteration filtering - collectRefsByKind() helper eliminates 3x copy-paste Phase 4: team_tasks_mutations split (630→318 LOC) - executeCreate extracted to team_tasks_create.go - resolveTeamAndTask() helper used by 13 execute* functions Phase 5: MCP LoadForAgent split (~90→~15 LOC body) - resolveServerCredentials() + connectAndFilter() helpers Phase 6: Comments + DI - 5 inaccurate comments fixed - ToolExecutor interface for dependency inversion (Loop.tools field) - FilterTools accepts ToolExecutor instead of concrete *Registry
319 lines
11 KiB
Go
319 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// resolveTeamAndTask is the common preamble for task mutation actions:
|
|
// resolve team context + parse task ID from args.
|
|
func (t *TeamTasksTool) resolveTeamAndTask(ctx context.Context, args map[string]any) (*store.TeamData, uuid.UUID, uuid.UUID, error) {
|
|
team, agentID, err := t.manager.ResolveTeam(ctx)
|
|
if err != nil {
|
|
return nil, uuid.Nil, uuid.Nil, err
|
|
}
|
|
taskID, err := resolveTaskID(ctx, args)
|
|
if err != nil {
|
|
return nil, uuid.Nil, uuid.Nil, err
|
|
}
|
|
return team, agentID, taskID, nil
|
|
}
|
|
|
|
func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any) *Result {
|
|
team, agentID, taskID, err := t.resolveTeamAndTask(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.Store().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")
|
|
}
|
|
|
|
commentType, _ := args["type"].(string)
|
|
if commentType != "blocker" {
|
|
commentType = "note"
|
|
}
|
|
|
|
if err := t.manager.Store().AddTaskComment(ctx, &store.TeamTaskCommentData{
|
|
TaskID: taskID,
|
|
AgentID: &agentID,
|
|
Content: text,
|
|
CommentType: commentType,
|
|
}); err != nil {
|
|
return ErrorResult("failed to add comment: " + err.Error())
|
|
}
|
|
|
|
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCommented, BuildTaskEventPayload(
|
|
team.ID.String(), taskID.String(),
|
|
"",
|
|
"agent", t.manager.AgentKeyFromID(ctx, agentID),
|
|
WithTaskInfo(task.TaskNumber, task.Subject),
|
|
WithCommentText(truncatePreview(text, 500)),
|
|
WithContextInfo(ctx),
|
|
))
|
|
|
|
// Record action flag after successful store operation.
|
|
recordTaskAction(ctx, func(f *TaskActionFlags) {
|
|
f.Commented = true
|
|
if commentType == "blocker" {
|
|
f.Escalated = true
|
|
}
|
|
})
|
|
|
|
// Blocker escalation: auto-fail task + notify leader.
|
|
if commentType == "blocker" {
|
|
return t.handleBlockerComment(ctx, team, task, taskID, agentID, text)
|
|
}
|
|
|
|
isLead := agentID == team.LeadAgentID
|
|
msg := fmt.Sprintf("Comment added to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID)
|
|
switch {
|
|
case isLead && task.Status == store.TeamTaskStatusInProgress:
|
|
msg += " Note: the assignee is currently working and won't see this comment during execution. To redirect, wait for completion then use retry action with this task_id."
|
|
case isLead && task.Status == store.TeamTaskStatusCompleted:
|
|
msg += " Task is completed. Use retry action with this task_id to reopen and re-dispatch with your feedback."
|
|
case isLead && task.Status == store.TeamTaskStatusFailed:
|
|
msg += " Task failed. Use retry action with this task_id to re-dispatch with your feedback."
|
|
case !isLead && task.OwnerAgentID != nil && *task.OwnerAgentID == agentID:
|
|
msg += " Your comment will be included in the task report sent to the leader. Continue working on the task."
|
|
}
|
|
return NewResult(msg)
|
|
}
|
|
|
|
func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any) *Result {
|
|
team, agentID, taskID, err := t.resolveTeamAndTask(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.Store().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.")
|
|
}
|
|
|
|
// Early exit: task already terminal — skip DB write entirely.
|
|
// Reuses the task fetched above so no extra query needed.
|
|
switch task.Status {
|
|
case store.TeamTaskStatusCompleted, store.TeamTaskStatusFailed, store.TeamTaskStatusCancelled:
|
|
return SilentResult(fmt.Sprintf("Task already %s — progress update skipped.", task.Status))
|
|
}
|
|
|
|
// Prevent progress regression — keep the higher value.
|
|
if percent < task.ProgressPercent {
|
|
percent = task.ProgressPercent
|
|
}
|
|
|
|
if err := t.manager.Store().UpdateTaskProgress(ctx, taskID, team.ID, percent, step); err != nil {
|
|
// Status may have changed between GetTask and UpdateTaskProgress (race with completeTask).
|
|
// Fast path: check in-memory turn flags before hitting DB again.
|
|
if flags := TaskActionFlagsFromCtx(ctx); flags != nil && flags.Completed {
|
|
return SilentResult("Task already completed — progress update skipped.")
|
|
}
|
|
// Slow path: re-query for stale recovery (pending) or concurrent status change.
|
|
if current, getErr := t.manager.Store().GetTask(ctx, taskID); getErr == nil && current != nil {
|
|
switch current.Status {
|
|
case store.TeamTaskStatusCompleted, store.TeamTaskStatusFailed, store.TeamTaskStatusCancelled:
|
|
return SilentResult(fmt.Sprintf("Task already %s — progress update skipped.", current.Status))
|
|
case store.TeamTaskStatusPending:
|
|
// Task was reset by stale recovery — re-assign and retry once.
|
|
if t.manager.Store().AssignTask(ctx, taskID, agentID, team.ID) == nil {
|
|
if t.manager.Store().UpdateTaskProgress(ctx, taskID, team.ID, percent, step) == nil {
|
|
slog.Info("executeProgress: re-assigned stale-recovered task", "task_id", taskID)
|
|
recordTaskAction(ctx, func(f *TaskActionFlags) { f.Progressed = true })
|
|
return SilentResult(fmt.Sprintf("Progress updated: %d%% %s", percent, step))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ErrorResult("failed to update progress: " + err.Error())
|
|
}
|
|
// Record action flag after successful store operation.
|
|
recordTaskAction(ctx, func(f *TaskActionFlags) { f.Progressed = true })
|
|
|
|
ownerKey := ""
|
|
if task.OwnerAgentID != nil {
|
|
ownerKey = t.manager.AgentKeyFromID(ctx, *task.OwnerAgentID)
|
|
}
|
|
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskProgress, BuildTaskEventPayload(
|
|
team.ID.String(), taskID.String(),
|
|
store.TeamTaskStatusInProgress,
|
|
"", "",
|
|
WithTaskInfo(task.TaskNumber, task.Subject),
|
|
WithOwnerAgentKey(ownerKey),
|
|
WithProgress(percent, step),
|
|
WithContextInfo(ctx),
|
|
))
|
|
|
|
return SilentResult(fmt.Sprintf("Progress updated: %d%% %s", percent, step))
|
|
}
|
|
|
|
func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any) *Result {
|
|
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
filePath, _ := args["path"].(string)
|
|
if filePath == "" {
|
|
return ErrorResult("path is required for attach action")
|
|
}
|
|
|
|
// Resolve to absolute path within team workspace.
|
|
if !filepath.IsAbs(filePath) {
|
|
if ws := ToolTeamWorkspaceFromCtx(ctx); ws != "" {
|
|
filePath = filepath.Join(ws, filePath)
|
|
}
|
|
}
|
|
filePath = filepath.Clean(filePath)
|
|
|
|
// Verify task belongs to team.
|
|
task, err := t.manager.Store().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")
|
|
}
|
|
|
|
chatID := ToolChatIDFromCtx(ctx)
|
|
if err := t.manager.Store().AttachFileToTask(ctx, &store.TeamTaskAttachmentData{
|
|
TaskID: taskID,
|
|
TeamID: team.ID,
|
|
ChatID: chatID,
|
|
Path: filePath,
|
|
CreatedByAgentID: &agentID,
|
|
}); err != nil {
|
|
return ErrorResult("failed to attach file: " + err.Error())
|
|
}
|
|
|
|
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskAttachmentAdded, BuildTaskEventPayload(
|
|
team.ID.String(), taskID.String(),
|
|
"",
|
|
"agent", t.manager.AgentKeyFromID(ctx, agentID),
|
|
WithTaskInfo(task.TaskNumber, task.Subject),
|
|
WithContextInfo(ctx),
|
|
))
|
|
|
|
return NewResult(fmt.Sprintf("File attached to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID))
|
|
}
|
|
|
|
func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) *Result {
|
|
team, agentID, taskID, err := t.resolveTeamAndTask(ctx, args)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
// Verify task belongs to this team (prevent cross-team update).
|
|
task, err := t.manager.Store().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.Store().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.Store().UpdateTask(ctx, taskID, updates); err != nil {
|
|
return ErrorResult("failed to update task: " + err.Error())
|
|
}
|
|
|
|
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskUpdated, BuildTaskEventPayload(
|
|
team.ID.String(), taskID.String(),
|
|
task.Status,
|
|
"agent", t.manager.AgentKeyFromID(ctx, agentID),
|
|
WithSubject(task.Subject),
|
|
WithContextInfo(ctx),
|
|
))
|
|
|
|
return NewResult(fmt.Sprintf("Task #%d \"%s\" updated (id: %s).", task.TaskNumber, task.Subject, taskID))
|
|
}
|