mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 22:14:37 +00:00
- Add enrich_auto_linking.go: deterministic auto-link logic for task/delegation contexts - Add team_task_siblings.go (PG + SQLite): find all tasks in same delegation for batch link - Update enrich_worker.go: call enrich_auto_linking Phase 2 hook + skip re-embed for binary - Update team_store.go: add TaskSiblings interface method + TeamStore binding - Add context_keys.go + test: define ContextDelegationIDKey + ContextTaskIDKey for propagation - Update vault_interceptor.go: extract + inject delegation ID from request - Update run_context.go: propagate DelegationID from request - Update loop_context.go: inject DelegationID from run context - Update teams_tasks.go: DeleteTask(s) → cleanup auto-links via vault_source_cleanup - Update teams_tasks_activity.go: DetachFileFromTask → cleanup related auto-links - Update vault_documents_enrichment.go: populate summary from media_summary - Update cmd/gateway.go: wire TeamStore to handler bootstrap
894 lines
29 KiB
Go
894 lines
29 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// ============================================================
|
|
// baseNoopTeamStore — noop implementations for all TeamStore methods
|
|
// ============================================================
|
|
|
|
type baseNoopTeamStore struct{}
|
|
|
|
// TeamCRUDStore
|
|
func (b *baseNoopTeamStore) CreateTeam(_ context.Context, _ *store.TeamData) error {
|
|
return fmt.Errorf("not implemented: CreateTeam")
|
|
}
|
|
func (b *baseNoopTeamStore) GetTeam(_ context.Context, _ uuid.UUID) (*store.TeamData, error) {
|
|
return nil, fmt.Errorf("not implemented: GetTeam")
|
|
}
|
|
func (b *baseNoopTeamStore) GetTeamUnscoped(_ context.Context, _ uuid.UUID) (*store.TeamData, error) {
|
|
return nil, fmt.Errorf("not implemented: GetTeamUnscoped")
|
|
}
|
|
func (b *baseNoopTeamStore) UpdateTeam(_ context.Context, _ uuid.UUID, _ map[string]any) error {
|
|
return fmt.Errorf("not implemented: UpdateTeam")
|
|
}
|
|
func (b *baseNoopTeamStore) DeleteTeam(_ context.Context, _ uuid.UUID) error {
|
|
return fmt.Errorf("not implemented: DeleteTeam")
|
|
}
|
|
func (b *baseNoopTeamStore) ListTeams(_ context.Context) ([]store.TeamData, error) {
|
|
return nil, fmt.Errorf("not implemented: ListTeams")
|
|
}
|
|
func (b *baseNoopTeamStore) AddMember(_ context.Context, _, _ uuid.UUID, _ string) error {
|
|
return fmt.Errorf("not implemented: AddMember")
|
|
}
|
|
func (b *baseNoopTeamStore) RemoveMember(_ context.Context, _, _ uuid.UUID) error {
|
|
return fmt.Errorf("not implemented: RemoveMember")
|
|
}
|
|
func (b *baseNoopTeamStore) ListMembers(_ context.Context, _ uuid.UUID) ([]store.TeamMemberData, error) {
|
|
return nil, fmt.Errorf("not implemented: ListMembers")
|
|
}
|
|
func (b *baseNoopTeamStore) ListIdleMembers(_ context.Context, _ uuid.UUID) ([]store.TeamMemberData, error) {
|
|
return nil, fmt.Errorf("not implemented: ListIdleMembers")
|
|
}
|
|
func (b *baseNoopTeamStore) GetTeamForAgent(_ context.Context, _ uuid.UUID) (*store.TeamData, error) {
|
|
return nil, fmt.Errorf("not implemented: GetTeamForAgent")
|
|
}
|
|
func (b *baseNoopTeamStore) KnownUserIDs(_ context.Context, _ uuid.UUID, _ int) ([]string, error) {
|
|
return nil, fmt.Errorf("not implemented: KnownUserIDs")
|
|
}
|
|
func (b *baseNoopTeamStore) ListTaskScopes(_ context.Context, _ uuid.UUID) ([]store.ScopeEntry, error) {
|
|
return nil, fmt.Errorf("not implemented: ListTaskScopes")
|
|
}
|
|
|
|
// TaskStore
|
|
func (b *baseNoopTeamStore) CreateTask(_ context.Context, _ *store.TeamTaskData) error {
|
|
return fmt.Errorf("not implemented: CreateTask")
|
|
}
|
|
func (b *baseNoopTeamStore) UpdateTask(_ context.Context, _ uuid.UUID, _ map[string]any) error {
|
|
return fmt.Errorf("not implemented: UpdateTask")
|
|
}
|
|
func (b *baseNoopTeamStore) ListTasks(_ context.Context, _ uuid.UUID, _, _, _, _, _ string, _, _ int) ([]store.TeamTaskData, error) {
|
|
return nil, fmt.Errorf("not implemented: ListTasks")
|
|
}
|
|
func (b *baseNoopTeamStore) GetTask(_ context.Context, _ uuid.UUID) (*store.TeamTaskData, error) {
|
|
return nil, fmt.Errorf("not implemented: GetTask")
|
|
}
|
|
func (b *baseNoopTeamStore) GetTasksByIDs(_ context.Context, _ []uuid.UUID) ([]store.TeamTaskData, error) {
|
|
return nil, fmt.Errorf("not implemented: GetTasksByIDs")
|
|
}
|
|
func (b *baseNoopTeamStore) SearchTasks(_ context.Context, _ uuid.UUID, _ string, _ int, _ string) ([]store.TeamTaskData, error) {
|
|
return nil, fmt.Errorf("not implemented: SearchTasks")
|
|
}
|
|
func (b *baseNoopTeamStore) DeleteTask(_ context.Context, _, _ uuid.UUID) error {
|
|
return fmt.Errorf("not implemented: DeleteTask")
|
|
}
|
|
func (b *baseNoopTeamStore) DeleteTasks(_ context.Context, _ []uuid.UUID, _ uuid.UUID) ([]uuid.UUID, error) {
|
|
return nil, fmt.Errorf("not implemented: DeleteTasks")
|
|
}
|
|
func (b *baseNoopTeamStore) ClaimTask(_ context.Context, _, _, _ uuid.UUID) error {
|
|
return fmt.Errorf("not implemented: ClaimTask")
|
|
}
|
|
func (b *baseNoopTeamStore) AssignTask(_ context.Context, _, _, _ uuid.UUID) error {
|
|
return fmt.Errorf("not implemented: AssignTask")
|
|
}
|
|
func (b *baseNoopTeamStore) CompleteTask(_ context.Context, _, _ uuid.UUID, _ string) error {
|
|
return fmt.Errorf("not implemented: CompleteTask")
|
|
}
|
|
func (b *baseNoopTeamStore) CancelTask(_ context.Context, _, _ uuid.UUID, _ string) error {
|
|
return fmt.Errorf("not implemented: CancelTask")
|
|
}
|
|
func (b *baseNoopTeamStore) FailTask(_ context.Context, _, _ uuid.UUID, _ string) error {
|
|
return fmt.Errorf("not implemented: FailTask")
|
|
}
|
|
func (b *baseNoopTeamStore) FailPendingTask(_ context.Context, _, _ uuid.UUID, _ string) error {
|
|
return fmt.Errorf("not implemented: FailPendingTask")
|
|
}
|
|
func (b *baseNoopTeamStore) ReviewTask(_ context.Context, _, _ uuid.UUID) error {
|
|
return fmt.Errorf("not implemented: ReviewTask")
|
|
}
|
|
func (b *baseNoopTeamStore) ApproveTask(_ context.Context, _, _ uuid.UUID, _ string) error {
|
|
return fmt.Errorf("not implemented: ApproveTask")
|
|
}
|
|
func (b *baseNoopTeamStore) RejectTask(_ context.Context, _, _ uuid.UUID, _ string) error {
|
|
return fmt.Errorf("not implemented: RejectTask")
|
|
}
|
|
func (b *baseNoopTeamStore) UpdateTaskProgress(_ context.Context, _, _ uuid.UUID, _ int, _ string) error {
|
|
return fmt.Errorf("not implemented: UpdateTaskProgress")
|
|
}
|
|
func (b *baseNoopTeamStore) RenewTaskLock(_ context.Context, _, _ uuid.UUID) error {
|
|
return fmt.Errorf("not implemented: RenewTaskLock")
|
|
}
|
|
func (b *baseNoopTeamStore) ResetTaskStatus(_ context.Context, _, _ uuid.UUID) error {
|
|
return fmt.Errorf("not implemented: ResetTaskStatus")
|
|
}
|
|
func (b *baseNoopTeamStore) ListActiveTasksByChatID(_ context.Context, _ string) ([]store.TeamTaskData, error) {
|
|
return nil, fmt.Errorf("not implemented: ListActiveTasksByChatID")
|
|
}
|
|
|
|
// TaskCommentStore
|
|
func (b *baseNoopTeamStore) AddTaskComment(_ context.Context, _ *store.TeamTaskCommentData) error {
|
|
return fmt.Errorf("not implemented: AddTaskComment")
|
|
}
|
|
func (b *baseNoopTeamStore) ListTaskComments(_ context.Context, _ uuid.UUID) ([]store.TeamTaskCommentData, error) {
|
|
return nil, fmt.Errorf("not implemented: ListTaskComments")
|
|
}
|
|
func (b *baseNoopTeamStore) ListRecentTaskComments(_ context.Context, _ uuid.UUID, _ int) ([]store.TeamTaskCommentData, error) {
|
|
return nil, fmt.Errorf("not implemented: ListRecentTaskComments")
|
|
}
|
|
func (b *baseNoopTeamStore) RecordTaskEvent(_ context.Context, _ *store.TeamTaskEventData) error {
|
|
return fmt.Errorf("not implemented: RecordTaskEvent")
|
|
}
|
|
func (b *baseNoopTeamStore) ListTaskEvents(_ context.Context, _ uuid.UUID) ([]store.TeamTaskEventData, error) {
|
|
return nil, fmt.Errorf("not implemented: ListTaskEvents")
|
|
}
|
|
func (b *baseNoopTeamStore) ListTeamEvents(_ context.Context, _ uuid.UUID, _, _ int) ([]store.TeamTaskEventData, error) {
|
|
return nil, fmt.Errorf("not implemented: ListTeamEvents")
|
|
}
|
|
func (b *baseNoopTeamStore) AttachFileToTask(_ context.Context, _ *store.TeamTaskAttachmentData) error {
|
|
return fmt.Errorf("not implemented: AttachFileToTask")
|
|
}
|
|
func (b *baseNoopTeamStore) GetAttachment(_ context.Context, _ uuid.UUID) (*store.TeamTaskAttachmentData, error) {
|
|
return nil, fmt.Errorf("not implemented: GetAttachment")
|
|
}
|
|
func (b *baseNoopTeamStore) ListTaskAttachments(_ context.Context, _ uuid.UUID) ([]store.TeamTaskAttachmentData, error) {
|
|
return nil, fmt.Errorf("not implemented: ListTaskAttachments")
|
|
}
|
|
func (b *baseNoopTeamStore) DetachFileFromTask(_ context.Context, _ uuid.UUID, _ string) error {
|
|
return fmt.Errorf("not implemented: DetachFileFromTask")
|
|
}
|
|
func (b *baseNoopTeamStore) BatchGetTaskSiblingsByBasenames(_ context.Context, _ uuid.UUID, _ []string, _ int) (map[string][]store.TaskSibling, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// TaskRecoveryStore
|
|
func (b *baseNoopTeamStore) RecoverAllStaleTasks(_ context.Context) ([]store.RecoveredTaskInfo, error) {
|
|
return nil, fmt.Errorf("not implemented: RecoverAllStaleTasks")
|
|
}
|
|
func (b *baseNoopTeamStore) ForceRecoverAllTasks(_ context.Context) ([]store.RecoveredTaskInfo, error) {
|
|
return nil, fmt.Errorf("not implemented: ForceRecoverAllTasks")
|
|
}
|
|
func (b *baseNoopTeamStore) ListRecoverableTasks(_ context.Context, _ uuid.UUID) ([]store.TeamTaskData, error) {
|
|
return nil, fmt.Errorf("not implemented: ListRecoverableTasks")
|
|
}
|
|
func (b *baseNoopTeamStore) MarkAllStaleTasks(_ context.Context, _ time.Time) ([]store.RecoveredTaskInfo, error) {
|
|
return nil, fmt.Errorf("not implemented: MarkAllStaleTasks")
|
|
}
|
|
func (b *baseNoopTeamStore) MarkInReviewStaleTasks(_ context.Context, _ time.Time) ([]store.RecoveredTaskInfo, error) {
|
|
return nil, fmt.Errorf("not implemented: MarkInReviewStaleTasks")
|
|
}
|
|
func (b *baseNoopTeamStore) FixOrphanedBlockedTasks(_ context.Context) ([]store.RecoveredTaskInfo, error) {
|
|
return nil, fmt.Errorf("not implemented: FixOrphanedBlockedTasks")
|
|
}
|
|
|
|
// TaskFollowupStore
|
|
func (b *baseNoopTeamStore) SetTaskFollowup(_ context.Context, _, _ uuid.UUID, _ time.Time, _ int, _, _, _ string) error {
|
|
return fmt.Errorf("not implemented: SetTaskFollowup")
|
|
}
|
|
func (b *baseNoopTeamStore) ClearTaskFollowup(_ context.Context, _ uuid.UUID) error {
|
|
return fmt.Errorf("not implemented: ClearTaskFollowup")
|
|
}
|
|
func (b *baseNoopTeamStore) ListAllFollowupDueTasks(_ context.Context) ([]store.TeamTaskData, error) {
|
|
return nil, fmt.Errorf("not implemented: ListAllFollowupDueTasks")
|
|
}
|
|
func (b *baseNoopTeamStore) IncrementFollowupCount(_ context.Context, _ uuid.UUID, _ *time.Time) error {
|
|
return fmt.Errorf("not implemented: IncrementFollowupCount")
|
|
}
|
|
func (b *baseNoopTeamStore) ClearFollowupByScope(_ context.Context, _, _ string) (int, error) {
|
|
return 0, fmt.Errorf("not implemented: ClearFollowupByScope")
|
|
}
|
|
func (b *baseNoopTeamStore) SetFollowupForActiveTasks(_ context.Context, _ uuid.UUID, _, _ string, _ time.Time, _ int, _ string) (int, error) {
|
|
return 0, fmt.Errorf("not implemented: SetFollowupForActiveTasks")
|
|
}
|
|
func (b *baseNoopTeamStore) HasActiveMemberTasks(_ context.Context, _ uuid.UUID, _ uuid.UUID) (bool, error) {
|
|
return false, fmt.Errorf("not implemented: HasActiveMemberTasks")
|
|
}
|
|
|
|
// TeamAccessStore
|
|
func (b *baseNoopTeamStore) GrantTeamAccess(_ context.Context, _ uuid.UUID, _, _, _ string) error {
|
|
return fmt.Errorf("not implemented: GrantTeamAccess")
|
|
}
|
|
func (b *baseNoopTeamStore) RevokeTeamAccess(_ context.Context, _ uuid.UUID, _ string) error {
|
|
return fmt.Errorf("not implemented: RevokeTeamAccess")
|
|
}
|
|
func (b *baseNoopTeamStore) ListTeamGrants(_ context.Context, _ uuid.UUID) ([]store.TeamUserGrant, error) {
|
|
return nil, fmt.Errorf("not implemented: ListTeamGrants")
|
|
}
|
|
func (b *baseNoopTeamStore) ListUserTeams(_ context.Context, _ string) ([]store.TeamData, error) {
|
|
return nil, fmt.Errorf("not implemented: ListUserTeams")
|
|
}
|
|
func (b *baseNoopTeamStore) HasTeamAccess(_ context.Context, _ uuid.UUID, _ string) (bool, error) {
|
|
return false, fmt.Errorf("not implemented: HasTeamAccess")
|
|
}
|
|
|
|
// ============================================================
|
|
// mockTaskStore — in-memory TeamStore for unit tests
|
|
// ============================================================
|
|
|
|
type mockTaskStore struct {
|
|
baseNoopTeamStore
|
|
tasks map[uuid.UUID]*store.TeamTaskData
|
|
comments map[uuid.UUID][]store.TeamTaskCommentData
|
|
events map[uuid.UUID][]store.TeamTaskEventData
|
|
attachments map[uuid.UUID][]store.TeamTaskAttachmentData
|
|
team *store.TeamData
|
|
members []store.TeamMemberData
|
|
taskSeq int
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func newMockTaskStore(team *store.TeamData, members []store.TeamMemberData) *mockTaskStore {
|
|
return &mockTaskStore{
|
|
tasks: make(map[uuid.UUID]*store.TeamTaskData),
|
|
comments: make(map[uuid.UUID][]store.TeamTaskCommentData),
|
|
events: make(map[uuid.UUID][]store.TeamTaskEventData),
|
|
attachments: make(map[uuid.UUID][]store.TeamTaskAttachmentData),
|
|
team: team,
|
|
members: members,
|
|
}
|
|
}
|
|
|
|
func (s *mockTaskStore) GetTeam(_ context.Context, teamID uuid.UUID) (*store.TeamData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.team != nil && s.team.ID == teamID {
|
|
return s.team, nil
|
|
}
|
|
return nil, fmt.Errorf("team not found: %s", teamID)
|
|
}
|
|
|
|
func (s *mockTaskStore) GetTeamForAgent(_ context.Context, _ uuid.UUID) (*store.TeamData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.team, nil
|
|
}
|
|
|
|
func (s *mockTaskStore) ListMembers(_ context.Context, _ uuid.UUID) ([]store.TeamMemberData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.members, nil
|
|
}
|
|
|
|
func (s *mockTaskStore) GetTask(_ context.Context, taskID uuid.UUID) (*store.TeamTaskData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return nil, store.ErrTaskNotFound
|
|
}
|
|
cp := *t
|
|
return &cp, nil
|
|
}
|
|
|
|
func (s *mockTaskStore) GetTasksByIDs(_ context.Context, ids []uuid.UUID) ([]store.TeamTaskData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]store.TeamTaskData, 0, len(ids))
|
|
for _, id := range ids {
|
|
if t, ok := s.tasks[id]; ok {
|
|
out = append(out, *t)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *mockTaskStore) CreateTask(_ context.Context, task *store.TeamTaskData) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
now := time.Now()
|
|
task.ID = uuid.New()
|
|
s.taskSeq++
|
|
task.TaskNumber = s.taskSeq
|
|
task.CreatedAt = now
|
|
task.UpdatedAt = now
|
|
if task.Identifier == "" {
|
|
task.Identifier = fmt.Sprintf("T-%d", task.TaskNumber)
|
|
}
|
|
cp := *task
|
|
s.tasks[task.ID] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) UpdateTask(_ context.Context, taskID uuid.UUID, updates map[string]any) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
for k, v := range updates {
|
|
switch k {
|
|
case "status":
|
|
if sv, ok := v.(string); ok {
|
|
t.Status = sv
|
|
}
|
|
case "result":
|
|
if sv, ok := v.(string); ok {
|
|
t.Result = &sv
|
|
}
|
|
case "metadata":
|
|
if mv, ok := v.(map[string]any); ok {
|
|
t.Metadata = mv
|
|
}
|
|
case "description":
|
|
if sv, ok := v.(string); ok {
|
|
t.Description = sv
|
|
}
|
|
case "subject":
|
|
if sv, ok := v.(string); ok {
|
|
t.Subject = sv
|
|
}
|
|
case "blocked_by":
|
|
if ids, ok := v.([]uuid.UUID); ok {
|
|
t.BlockedBy = ids
|
|
}
|
|
case "owner_agent_id":
|
|
if id, ok := v.(uuid.UUID); ok {
|
|
t.OwnerAgentID = &id
|
|
}
|
|
case "locked_at":
|
|
if tv, ok := v.(time.Time); ok {
|
|
t.LockedAt = &tv
|
|
}
|
|
case "lock_expires_at":
|
|
if tv, ok := v.(time.Time); ok {
|
|
t.LockExpiresAt = &tv
|
|
}
|
|
case "progress_percent":
|
|
if iv, ok := v.(int); ok {
|
|
t.ProgressPercent = iv
|
|
}
|
|
case "progress_step":
|
|
if sv, ok := v.(string); ok {
|
|
t.ProgressStep = sv
|
|
}
|
|
case "followup_at":
|
|
if tv, ok := v.(time.Time); ok {
|
|
t.FollowupAt = &tv
|
|
}
|
|
case "followup_count":
|
|
if iv, ok := v.(int); ok {
|
|
t.FollowupCount = iv
|
|
}
|
|
case "followup_max":
|
|
if iv, ok := v.(int); ok {
|
|
t.FollowupMax = iv
|
|
}
|
|
case "followup_message":
|
|
if sv, ok := v.(string); ok {
|
|
t.FollowupMessage = sv
|
|
}
|
|
case "followup_channel":
|
|
if sv, ok := v.(string); ok {
|
|
t.FollowupChannel = sv
|
|
}
|
|
case "followup_chat_id":
|
|
if sv, ok := v.(string); ok {
|
|
t.FollowupChatID = sv
|
|
}
|
|
}
|
|
}
|
|
t.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) ClaimTask(_ context.Context, taskID, agentID, _ uuid.UUID) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
if t.Status != store.TeamTaskStatusPending {
|
|
return fmt.Errorf("cannot claim task with status %q", t.Status)
|
|
}
|
|
if t.OwnerAgentID != nil && *t.OwnerAgentID != agentID {
|
|
return fmt.Errorf("task already owned by another agent")
|
|
}
|
|
now := time.Now()
|
|
t.Status = store.TeamTaskStatusInProgress
|
|
t.OwnerAgentID = &agentID
|
|
t.LockedAt = &now
|
|
t.UpdatedAt = now
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) AssignTask(_ context.Context, taskID, agentID, _ uuid.UUID) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
if t.Status != store.TeamTaskStatusPending {
|
|
return fmt.Errorf("cannot assign task with status %q", t.Status)
|
|
}
|
|
now := time.Now()
|
|
t.Status = store.TeamTaskStatusInProgress
|
|
t.OwnerAgentID = &agentID
|
|
t.LockedAt = &now
|
|
t.UpdatedAt = now
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) CompleteTask(_ context.Context, taskID, _ uuid.UUID, result string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
if t.Status != store.TeamTaskStatusInProgress {
|
|
return fmt.Errorf("cannot complete task with status %q", t.Status)
|
|
}
|
|
t.Status = store.TeamTaskStatusCompleted
|
|
t.Result = &result
|
|
t.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) CancelTask(_ context.Context, taskID, _ uuid.UUID, reason string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
if t.Status == store.TeamTaskStatusCompleted || t.Status == store.TeamTaskStatusCancelled {
|
|
return fmt.Errorf("cannot cancel task with status %q", t.Status)
|
|
}
|
|
t.Status = store.TeamTaskStatusCancelled
|
|
t.Result = &reason
|
|
t.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) FailTask(_ context.Context, taskID, _ uuid.UUID, errMsg string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
if t.Status != store.TeamTaskStatusInProgress {
|
|
return fmt.Errorf("cannot fail task with status %q", t.Status)
|
|
}
|
|
result := "FAILED: " + errMsg
|
|
t.Status = store.TeamTaskStatusFailed
|
|
t.Result = &result
|
|
t.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) FailPendingTask(_ context.Context, taskID, _ uuid.UUID, errMsg string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
if t.Status != store.TeamTaskStatusPending && t.Status != store.TeamTaskStatusBlocked {
|
|
return fmt.Errorf("cannot fail-pending task with status %q", t.Status)
|
|
}
|
|
t.Status = store.TeamTaskStatusFailed
|
|
t.Result = &errMsg
|
|
t.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) ReviewTask(_ context.Context, taskID, _ uuid.UUID) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
if t.Status != store.TeamTaskStatusInProgress {
|
|
return fmt.Errorf("cannot review task with status %q", t.Status)
|
|
}
|
|
t.Status = store.TeamTaskStatusInReview
|
|
t.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) ApproveTask(_ context.Context, taskID, _ uuid.UUID, _ string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
if t.Status != store.TeamTaskStatusInReview {
|
|
return fmt.Errorf("cannot approve task with status %q", t.Status)
|
|
}
|
|
t.Status = store.TeamTaskStatusCompleted
|
|
t.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) RejectTask(_ context.Context, taskID, _ uuid.UUID, reason string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
if t.Status != store.TeamTaskStatusInReview {
|
|
return fmt.Errorf("cannot reject task with status %q", t.Status)
|
|
}
|
|
t.Status = store.TeamTaskStatusCancelled
|
|
t.Result = &reason
|
|
t.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) ResetTaskStatus(_ context.Context, taskID, _ uuid.UUID) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
t.Status = store.TeamTaskStatusPending
|
|
t.LockedAt = nil
|
|
t.LockExpiresAt = nil
|
|
t.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) UpdateTaskProgress(_ context.Context, taskID, _ uuid.UUID, percent int, step string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
if t.Status != store.TeamTaskStatusInProgress {
|
|
return fmt.Errorf("cannot update progress for task with status %q", t.Status)
|
|
}
|
|
t.ProgressPercent = percent
|
|
t.ProgressStep = step
|
|
t.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) RenewTaskLock(_ context.Context, taskID, _ uuid.UUID) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
expires := time.Now().Add(10 * time.Minute)
|
|
t.LockExpiresAt = &expires
|
|
t.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) ListTasks(_ context.Context, teamID uuid.UUID, _, _, _, _, _ string, _, _ int) ([]store.TeamTaskData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var out []store.TeamTaskData
|
|
for _, t := range s.tasks {
|
|
if t.TeamID == teamID {
|
|
out = append(out, *t)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *mockTaskStore) SearchTasks(_ context.Context, teamID uuid.UUID, query string, limit int, _ string) ([]store.TeamTaskData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
q := strings.ToLower(query)
|
|
var out []store.TeamTaskData
|
|
for _, t := range s.tasks {
|
|
if t.TeamID == teamID && strings.Contains(strings.ToLower(t.Subject), q) {
|
|
out = append(out, *t)
|
|
if limit > 0 && len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *mockTaskStore) AddTaskComment(_ context.Context, comment *store.TeamTaskCommentData) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
comment.ID = uuid.New()
|
|
comment.CreatedAt = time.Now()
|
|
cp := *comment
|
|
s.comments[comment.TaskID] = append(s.comments[comment.TaskID], cp)
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) ListTaskComments(_ context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.comments[taskID], nil
|
|
}
|
|
|
|
func (s *mockTaskStore) ListRecentTaskComments(_ context.Context, taskID uuid.UUID, limit int) ([]store.TeamTaskCommentData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
all := s.comments[taskID]
|
|
if limit <= 0 || len(all) <= limit {
|
|
return all, nil
|
|
}
|
|
return all[len(all)-limit:], nil
|
|
}
|
|
|
|
func (s *mockTaskStore) ListTaskEvents(_ context.Context, taskID uuid.UUID) ([]store.TeamTaskEventData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.events[taskID], nil
|
|
}
|
|
|
|
func (s *mockTaskStore) AttachFileToTask(_ context.Context, att *store.TeamTaskAttachmentData) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
att.ID = uuid.New()
|
|
att.CreatedAt = time.Now()
|
|
cp := *att
|
|
s.attachments[att.TaskID] = append(s.attachments[att.TaskID], cp)
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) ListTaskAttachments(_ context.Context, taskID uuid.UUID) ([]store.TeamTaskAttachmentData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.attachments[taskID], nil
|
|
}
|
|
|
|
func (s *mockTaskStore) SetTaskFollowup(_ context.Context, taskID, _ uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
t.FollowupAt = &followupAt
|
|
t.FollowupMax = max
|
|
t.FollowupMessage = message
|
|
t.FollowupChannel = channel
|
|
t.FollowupChatID = chatID
|
|
t.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) ClearTaskFollowup(_ context.Context, taskID uuid.UUID) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t, ok := s.tasks[taskID]
|
|
if !ok {
|
|
return store.ErrTaskNotFound
|
|
}
|
|
t.FollowupAt = nil
|
|
t.FollowupCount = 0
|
|
t.FollowupMax = 0
|
|
t.FollowupMessage = ""
|
|
t.FollowupChannel = ""
|
|
t.FollowupChatID = ""
|
|
t.UpdatedAt = time.Now()
|
|
return nil
|
|
}
|
|
|
|
func (s *mockTaskStore) ListRecoverableTasks(_ context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var out []store.TeamTaskData
|
|
for _, t := range s.tasks {
|
|
if t.TeamID != teamID {
|
|
continue
|
|
}
|
|
if t.Status == store.TeamTaskStatusPending || t.Status == store.TeamTaskStatusStale {
|
|
out = append(out, *t)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ============================================================
|
|
// capturedEvent / capturedDispatch
|
|
// ============================================================
|
|
|
|
type capturedEvent struct {
|
|
Name string
|
|
Payload any
|
|
}
|
|
|
|
type capturedDispatch struct {
|
|
TaskID uuid.UUID
|
|
AgentID uuid.UUID
|
|
}
|
|
|
|
// ============================================================
|
|
// mockBackend — implements TeamToolBackend for unit tests
|
|
// ============================================================
|
|
|
|
type mockBackend struct {
|
|
team *store.TeamData
|
|
members []store.TeamMemberData
|
|
agents map[uuid.UUID]*store.AgentData
|
|
agentKeys map[string]*store.AgentData
|
|
taskStore *mockTaskStore
|
|
events []capturedEvent
|
|
dispatches []capturedDispatch
|
|
inbound []bus.InboundMessage
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func newMockBackend(team *store.TeamData, members []store.TeamMemberData, agents []*store.AgentData) *mockBackend {
|
|
mb := &mockBackend{
|
|
team: team,
|
|
members: members,
|
|
agents: make(map[uuid.UUID]*store.AgentData),
|
|
agentKeys: make(map[string]*store.AgentData),
|
|
taskStore: newMockTaskStore(team, members),
|
|
}
|
|
for _, ag := range agents {
|
|
mb.agents[ag.ID] = ag
|
|
mb.agentKeys[ag.AgentKey] = ag
|
|
}
|
|
return mb
|
|
}
|
|
|
|
func (mb *mockBackend) ResolveTeam(ctx context.Context) (*store.TeamData, uuid.UUID, error) {
|
|
if mb.team == nil {
|
|
return nil, uuid.Nil, fmt.Errorf("no team configured in mock")
|
|
}
|
|
agentID := store.AgentIDFromContext(ctx)
|
|
return mb.team, agentID, nil
|
|
}
|
|
|
|
func (mb *mockBackend) RequireLead(ctx context.Context, team *store.TeamData, agentID uuid.UUID) error {
|
|
channel := ToolChannelFromCtx(ctx)
|
|
if channel == ChannelTeammate || channel == ChannelSystem {
|
|
return nil
|
|
}
|
|
if agentID != team.LeadAgentID {
|
|
return fmt.Errorf("only the team lead can perform this action")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (mb *mockBackend) Store() store.TeamStore { return mb.taskStore }
|
|
|
|
func (mb *mockBackend) ResolveAgentByKey(_ context.Context, key string) (uuid.UUID, error) {
|
|
if ag, ok := mb.agentKeys[key]; ok {
|
|
return ag.ID, nil
|
|
}
|
|
return uuid.Nil, fmt.Errorf("agent not found: %s", key)
|
|
}
|
|
|
|
func (mb *mockBackend) AgentKeyFromID(_ context.Context, id uuid.UUID) string {
|
|
if ag, ok := mb.agents[id]; ok {
|
|
return ag.AgentKey
|
|
}
|
|
return id.String()
|
|
}
|
|
|
|
func (mb *mockBackend) AgentDisplayName(_ context.Context, key string) string {
|
|
if ag, ok := mb.agentKeys[key]; ok {
|
|
return ag.DisplayName
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (mb *mockBackend) CachedListMembers(_ context.Context, _ uuid.UUID, _ uuid.UUID) ([]store.TeamMemberData, error) {
|
|
return mb.members, nil
|
|
}
|
|
|
|
func (mb *mockBackend) CachedGetAgentByID(_ context.Context, id uuid.UUID) (*store.AgentData, error) {
|
|
if ag, ok := mb.agents[id]; ok {
|
|
return ag, nil
|
|
}
|
|
return nil, fmt.Errorf("agent not found: %s", id)
|
|
}
|
|
|
|
func (mb *mockBackend) PreWarmAgentKeyCache(_ context.Context, _ []string) {}
|
|
func (mb *mockBackend) PreWarmAgentIDCache(_ context.Context, _ []uuid.UUID) {}
|
|
|
|
func (mb *mockBackend) BroadcastTeamEvent(_ context.Context, name string, payload any) {
|
|
mb.mu.Lock()
|
|
mb.events = append(mb.events, capturedEvent{Name: name, Payload: payload})
|
|
mb.mu.Unlock()
|
|
}
|
|
|
|
func (mb *mockBackend) DispatchTaskToAgent(_ context.Context, task *store.TeamTaskData, _ *store.TeamData, agentID uuid.UUID) {
|
|
mb.mu.Lock()
|
|
mb.dispatches = append(mb.dispatches, capturedDispatch{TaskID: task.ID, AgentID: agentID})
|
|
mb.mu.Unlock()
|
|
}
|
|
|
|
func (mb *mockBackend) TryPublishInbound(msg bus.InboundMessage) bool {
|
|
mb.mu.Lock()
|
|
mb.inbound = append(mb.inbound, msg)
|
|
mb.mu.Unlock()
|
|
return true
|
|
}
|
|
|
|
func (mb *mockBackend) BuildBlockerResultsSummary(_ context.Context, _ *store.TeamTaskData) string { return "" }
|
|
func (mb *mockBackend) BuildRecentCommentsSummary(_ context.Context, _ uuid.UUID) string { return "" }
|
|
func (mb *mockBackend) RestoreTraceContext(ctx context.Context, _ *store.TeamTaskData) context.Context {
|
|
return ctx
|
|
}
|
|
func (mb *mockBackend) FollowupDelayMinutes(_ *store.TeamData) int { return 30 }
|
|
func (mb *mockBackend) FollowupMaxReminders(_ *store.TeamData) int { return 0 }
|
|
func (mb *mockBackend) DataDir() string { return "/tmp/test" }
|
|
|
|
// ============================================================
|
|
// newTestTeamSetup — standard test fixture
|
|
// ============================================================
|
|
|
|
var (
|
|
testTeamID = uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
testLeadID = uuid.MustParse("00000000-0000-0000-0000-000000000002")
|
|
testMemberID = uuid.MustParse("00000000-0000-0000-0000-000000000003")
|
|
testMember2ID = uuid.MustParse("00000000-0000-0000-0000-000000000004")
|
|
testTenantID = uuid.MustParse("00000000-0000-0000-0000-000000000099")
|
|
)
|
|
|
|
func newTestTeamSetup() (*mockBackend, *TeamTasksTool, uuid.UUID, uuid.UUID, context.Context) {
|
|
team := &store.TeamData{
|
|
BaseModel: store.BaseModel{ID: testTeamID},
|
|
Name: "test-team",
|
|
LeadAgentID: testLeadID,
|
|
Status: store.TeamStatusActive,
|
|
}
|
|
|
|
leadAgent := &store.AgentData{
|
|
BaseModel: store.BaseModel{ID: testLeadID},
|
|
AgentKey: "lead-agent",
|
|
DisplayName: "Lead Agent",
|
|
}
|
|
memberAgent := &store.AgentData{
|
|
BaseModel: store.BaseModel{ID: testMemberID},
|
|
AgentKey: "member-agent",
|
|
DisplayName: "Member Agent",
|
|
}
|
|
member2Agent := &store.AgentData{
|
|
BaseModel: store.BaseModel{ID: testMember2ID},
|
|
AgentKey: "member2-agent",
|
|
DisplayName: "Member 2 Agent",
|
|
}
|
|
|
|
members := []store.TeamMemberData{
|
|
{TeamID: testTeamID, AgentID: testLeadID, Role: store.TeamRoleLead, AgentKey: "lead-agent"},
|
|
{TeamID: testTeamID, AgentID: testMemberID, Role: store.TeamRoleMember, AgentKey: "member-agent"},
|
|
{TeamID: testTeamID, AgentID: testMember2ID, Role: store.TeamRoleMember, AgentKey: "member2-agent"},
|
|
}
|
|
|
|
mb := newMockBackend(team, members, []*store.AgentData{leadAgent, memberAgent, member2Agent})
|
|
tool := NewTeamTasksTool(mb, FullTeamPolicy{})
|
|
|
|
ctx := context.Background()
|
|
ctx = store.WithTenantID(ctx, testTenantID)
|
|
ctx = store.WithAgentID(ctx, testLeadID)
|
|
ctx = WithToolChannel(ctx, ChannelDashboard)
|
|
ctx = WithToolChatID(ctx, testTeamID.String())
|
|
ctx = WithTaskActionFlags(ctx, &TaskActionFlags{})
|
|
|
|
return mb, tool, testLeadID, testMemberID, ctx
|
|
}
|