Files
goclaw/internal/store/team_store.go
T
viettranx b231878a85 feat(teams): add limit param to ListTasks + lightweight get-light endpoint
- Add limit parameter to ListTasks interface (dashboard=200, agent=30)
- Add teams.tasks.get-light WS method returning task only (no comments/events)
- Truncate dashboard response to exact limit (fix off-by-one from limit+1)
- Update all 7 ListTasks callers with explicit limit values
2026-03-17 18:03:10 +07:00

463 lines
21 KiB
Go

package store
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
)
// RecoveredTaskInfo contains minimal info for leader notification after batch recovery/stale.
type RecoveredTaskInfo struct {
ID uuid.UUID
TeamID uuid.UUID
TaskNumber int
Subject string
Channel string // task's origin channel for notification routing
ChatID string // task scope for notification routing
}
// ErrFileLocked is returned when a workspace file is being written by another agent.
var ErrFileLocked = errors.New("file is being written by another agent, try again shortly")
// ErrTaskNotFound is returned when a task does not exist.
var ErrTaskNotFound = errors.New("task not found")
// Team status constants.
const (
TeamStatusActive = "active"
TeamStatusArchived = "archived"
)
// Team member role constants.
const (
TeamRoleLead = "lead"
TeamRoleMember = "member"
TeamRoleReviewer = "reviewer"
)
// Team task status constants.
const (
TeamTaskStatusPending = "pending"
TeamTaskStatusInProgress = "in_progress"
TeamTaskStatusCompleted = "completed"
TeamTaskStatusBlocked = "blocked"
TeamTaskStatusFailed = "failed"
TeamTaskStatusInReview = "in_review"
TeamTaskStatusCancelled = "cancelled"
TeamTaskStatusStale = "stale"
)
// Team task list filter constants (for ListTasks statusFilter parameter).
const (
TeamTaskFilterActive = "active" // pending + in_progress + blocked
TeamTaskFilterInReview = "in_review" // only in_review tasks
TeamTaskFilterCompleted = "completed" // only completed tasks
TeamTaskFilterAll = "all" // all statuses (default when "" passed)
)
// Team message type constants.
const (
TeamMessageTypeChat = "chat"
TeamMessageTypeBroadcast = "broadcast"
)
// TeamData represents an agent team.
type TeamData struct {
BaseModel
Name string `json:"name"`
LeadAgentID uuid.UUID `json:"lead_agent_id"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
Settings json.RawMessage `json:"settings,omitempty"`
CreatedBy string `json:"created_by"`
// Joined fields (populated by queries that JOIN agents table)
LeadAgentKey string `json:"lead_agent_key,omitempty"`
LeadDisplayName string `json:"lead_display_name,omitempty"`
// Enriched fields (populated by ListTeams)
MemberCount int `json:"member_count"`
Members []TeamMemberData `json:"members,omitempty"`
}
// TeamMemberData represents a team member.
type TeamMemberData struct {
TeamID uuid.UUID `json:"team_id"`
AgentID uuid.UUID `json:"agent_id"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
// Joined fields
AgentKey string `json:"agent_key,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Frontmatter string `json:"frontmatter,omitempty"`
Emoji string `json:"emoji,omitempty"`
}
// TeamTaskData represents a task in the team's shared task list.
type TeamTaskData struct {
BaseModel
TeamID uuid.UUID `json:"team_id"`
Subject string `json:"subject"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
OwnerAgentID *uuid.UUID `json:"owner_agent_id,omitempty"`
BlockedBy []uuid.UUID `json:"blocked_by,omitempty"`
Priority int `json:"priority"`
Result *string `json:"result,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
UserID string `json:"user_id,omitempty"`
Channel string `json:"channel,omitempty"`
// V2 fields
TaskType string `json:"task_type"`
TaskNumber int `json:"task_number,omitempty"`
Identifier string `json:"identifier,omitempty"`
CreatedByAgentID *uuid.UUID `json:"created_by_agent_id,omitempty"`
AssigneeUserID string `json:"assignee_user_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
ChatID string `json:"chat_id,omitempty"`
LockedAt *time.Time `json:"locked_at,omitempty"`
LockExpiresAt *time.Time `json:"lock_expires_at,omitempty"`
ProgressPercent int `json:"progress_percent,omitempty"`
ProgressStep string `json:"progress_step,omitempty"`
// Follow-up reminder fields
FollowupAt *time.Time `json:"followup_at,omitempty"`
FollowupCount int `json:"followup_count,omitempty"`
FollowupMax int `json:"followup_max,omitempty"`
FollowupMessage string `json:"followup_message,omitempty"`
FollowupChannel string `json:"followup_channel,omitempty"`
FollowupChatID string `json:"followup_chat_id,omitempty"`
// Joined fields
OwnerAgentKey string `json:"owner_agent_key,omitempty"`
CreatedByAgentKey string `json:"created_by_agent_key,omitempty"`
}
// TeamTaskCommentData represents a comment on a team task.
type TeamTaskCommentData struct {
ID uuid.UUID `json:"id"`
TaskID uuid.UUID `json:"task_id"`
AgentID *uuid.UUID `json:"agent_id,omitempty"`
UserID string `json:"user_id,omitempty"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
// Joined
AgentKey string `json:"agent_key,omitempty"`
}
// TeamTaskEventData represents an audit event on a team task.
type TeamTaskEventData struct {
ID uuid.UUID `json:"id"`
TaskID uuid.UUID `json:"task_id"`
EventType string `json:"event_type"`
ActorType string `json:"actor_type"` // "agent" | "human"
ActorID string `json:"actor_id"`
Data json.RawMessage `json:"data,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// TeamTaskAttachmentData represents a workspace file attached to a team task.
type TeamTaskAttachmentData struct {
ID uuid.UUID `json:"id"`
TaskID uuid.UUID `json:"task_id"`
FileID uuid.UUID `json:"file_id"`
AddedBy *uuid.UUID `json:"added_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
// Joined
FileName string `json:"file_name,omitempty"`
}
// DelegationHistoryData represents a persisted delegation record.
type DelegationHistoryData struct {
BaseModel
SourceAgentID uuid.UUID `json:"source_agent_id"`
TargetAgentID uuid.UUID `json:"target_agent_id"`
TeamID *uuid.UUID `json:"team_id,omitempty"`
TeamTaskID *uuid.UUID `json:"team_task_id,omitempty"`
UserID string `json:"user_id,omitempty"`
Task string `json:"task"`
Mode string `json:"mode"`
Status string `json:"status"`
Result *string `json:"result,omitempty"`
Error *string `json:"error,omitempty"`
Iterations int `json:"iterations"`
TraceID *uuid.UUID `json:"trace_id,omitempty"`
DurationMS int `json:"duration_ms"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
// Joined fields
SourceAgentKey string `json:"source_agent_key,omitempty"`
TargetAgentKey string `json:"target_agent_key,omitempty"`
}
// DelegationHistoryListOpts configures delegation history queries.
type DelegationHistoryListOpts struct {
SourceAgentID *uuid.UUID
TargetAgentID *uuid.UUID
TeamID *uuid.UUID
UserID string
Status string // "completed", "failed", "" = all
Limit int
Offset int
}
// TeamMessageData represents a message in the team mailbox.
type TeamMessageData struct {
ID uuid.UUID `json:"id"`
TeamID uuid.UUID `json:"team_id"`
FromAgentID uuid.UUID `json:"from_agent_id"`
ToAgentID *uuid.UUID `json:"to_agent_id,omitempty"`
Content string `json:"content"`
MessageType string `json:"message_type"`
Read bool `json:"read"`
TaskID *uuid.UUID `json:"task_id,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
// Joined fields
FromAgentKey string `json:"from_agent_key,omitempty"`
ToAgentKey string `json:"to_agent_key,omitempty"`
}
// TeamWorkspaceFileData represents a file in the team's shared workspace.
type TeamWorkspaceFileData struct {
ID uuid.UUID `json:"id"`
TeamID uuid.UUID `json:"team_id"`
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
FileName string `json:"file_name"`
MimeType string `json:"mime_type,omitempty"`
FilePath string `json:"-"`
SizeBytes int64 `json:"size_bytes"`
UploadedBy uuid.UUID `json:"uploaded_by"`
TaskID *uuid.UUID `json:"task_id,omitempty"`
Pinned bool `json:"pinned"`
Tags []string `json:"tags,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Joined
UploadedByKey string `json:"uploaded_by_key,omitempty"`
}
// TeamWorkspaceFileVersionData represents a version of a workspace file.
type TeamWorkspaceFileVersionData struct {
ID uuid.UUID `json:"id"`
FileID uuid.UUID `json:"file_id"`
Version int `json:"version"`
FilePath string `json:"-"`
SizeBytes int64 `json:"size_bytes"`
UploadedBy uuid.UUID `json:"uploaded_by"`
CreatedAt time.Time `json:"created_at"`
// Joined
UploadedByKey string `json:"uploaded_by_key,omitempty"`
}
// TeamWorkspaceCommentData represents a comment on a workspace file.
type TeamWorkspaceCommentData struct {
ID uuid.UUID `json:"id"`
FileID uuid.UUID `json:"file_id"`
AgentID uuid.UUID `json:"agent_id"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
// Joined
AgentKey string `json:"agent_key,omitempty"`
}
// ScopeEntry represents a unique channel+chatID scope across tasks and workspace.
type ScopeEntry struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
}
// TeamStore manages agent teams, tasks, and messages.
type TeamStore interface {
// Team CRUD
CreateTeam(ctx context.Context, team *TeamData) error
GetTeam(ctx context.Context, teamID uuid.UUID) (*TeamData, error)
UpdateTeam(ctx context.Context, teamID uuid.UUID, updates map[string]any) error
DeleteTeam(ctx context.Context, teamID uuid.UUID) error
ListTeams(ctx context.Context) ([]TeamData, error)
// Members
AddMember(ctx context.Context, teamID, agentID uuid.UUID, role string) error
RemoveMember(ctx context.Context, teamID, agentID uuid.UUID) error
ListMembers(ctx context.Context, teamID uuid.UUID) ([]TeamMemberData, error)
// ListIdleMembers returns team members (non-lead) that have no in_progress tasks.
ListIdleMembers(ctx context.Context, teamID uuid.UUID) ([]TeamMemberData, error)
// GetTeamForAgent returns the team that the given agent belongs to.
// Returns nil, nil if the agent is not in any team.
GetTeamForAgent(ctx context.Context, agentID uuid.UUID) (*TeamData, error)
// KnownUserIDs returns distinct user IDs from sessions of team member agents.
// Used by team settings UI to populate user select boxes.
KnownUserIDs(ctx context.Context, teamID uuid.UUID, limit int) ([]string, error)
// Scopes (channel+chatID discovery across tasks and workspace)
ListTaskScopes(ctx context.Context, teamID uuid.UUID) ([]ScopeEntry, error)
// Tasks (shared task list)
CreateTask(ctx context.Context, task *TeamTaskData) error
UpdateTask(ctx context.Context, taskID uuid.UUID, updates map[string]any) error
// ListTasks returns tasks for a team. orderBy: "priority" or "newest".
// statusFilter: "" = non-completed (default), "completed", "all".
// userID: if non-empty, filter to tasks created by this user.
// channel+chatID: if either is non-empty, filter to that exact scope.
// limit: max rows to return; 0 uses the default (30).
ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string, userID string, channel string, chatID string, limit int, offset int) ([]TeamTaskData, error)
// GetTask returns a single task by ID with joined agent info.
GetTask(ctx context.Context, taskID uuid.UUID) (*TeamTaskData, error)
// GetTasksByIDs returns multiple tasks by IDs in a single query.
GetTasksByIDs(ctx context.Context, ids []uuid.UUID) ([]TeamTaskData, error)
// SearchTasks performs FTS search over task subject+description.
// userID: if non-empty, filter to tasks created by this user.
SearchTasks(ctx context.Context, teamID uuid.UUID, query string, limit int, userID string) ([]TeamTaskData, error)
// DeleteTask permanently removes a terminal-status task (completed/failed/cancelled).
// Returns ErrTaskNotFound if the task does not exist or is not in a terminal status.
DeleteTask(ctx context.Context, taskID, teamID uuid.UUID) error
// DeleteTasks permanently removes multiple terminal-status tasks in a single query.
// Returns the list of IDs that were actually deleted.
DeleteTasks(ctx context.Context, taskIDs []uuid.UUID, teamID uuid.UUID) ([]uuid.UUID, error)
// ClaimTask atomically transitions a task from pending to in_progress.
// Only one agent can claim a given task (row-level lock, race-safe).
// teamID is validated in the WHERE clause to prevent cross-team task claiming.
ClaimTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error
// AssignTask admin-assigns a pending task to a specific agent.
// teamID is validated in the WHERE clause to prevent cross-team assignment.
AssignTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error
// CompleteTask marks a task as completed and unblocks dependent tasks.
// teamID is validated in the WHERE clause to prevent cross-team task completion.
CompleteTask(ctx context.Context, taskID, teamID uuid.UUID, result string) error
// CancelTask marks a non-completed task as cancelled (status=completed, result="CANCELLED: ..."),
// unblocks dependent tasks, and transitions blocked→pending when all blockers are resolved.
// Returns error if the task is already completed or not found.
CancelTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error
// FailTask marks an in_progress task as failed and stores the error message.
// Unblocks dependent tasks so they aren't stuck.
FailTask(ctx context.Context, taskID, teamID uuid.UUID, errMsg string) error
// FailPendingTask marks a pending or blocked task as failed (post-turn validation).
// Unlike FailTask, accepts pending/blocked source statuses.
FailPendingTask(ctx context.Context, taskID, teamID uuid.UUID, errMsg string) error
// Review workflow
ReviewTask(ctx context.Context, taskID, teamID uuid.UUID) error
ApproveTask(ctx context.Context, taskID, teamID uuid.UUID, comment string) error
RejectTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error
// Task comments
AddTaskComment(ctx context.Context, comment *TeamTaskCommentData) error
ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]TeamTaskCommentData, error)
// Audit events
RecordTaskEvent(ctx context.Context, event *TeamTaskEventData) error
ListTaskEvents(ctx context.Context, taskID uuid.UUID) ([]TeamTaskEventData, error)
// ListTeamEvents returns recent events across all tasks in a team.
ListTeamEvents(ctx context.Context, teamID uuid.UUID, limit, offset int) ([]TeamTaskEventData, error)
// Attachments
AttachFileToTask(ctx context.Context, att *TeamTaskAttachmentData) error
ListTaskAttachments(ctx context.Context, taskID uuid.UUID) ([]TeamTaskAttachmentData, error)
DetachFileFromTask(ctx context.Context, taskID, fileID uuid.UUID) error
// Follow-up reminders
SetTaskFollowup(ctx context.Context, taskID, teamID uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error
ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) error
// ListAllFollowupDueTasks returns due followup tasks across all v2 active teams (batch).
ListAllFollowupDueTasks(ctx context.Context) ([]TeamTaskData, error)
IncrementFollowupCount(ctx context.Context, taskID uuid.UUID, nextAt *time.Time) error
// Auto follow-up guardrails (system-level, no LLM dependency)
// ClearFollowupByScope clears followup on all in_progress tasks for a given channel+chatID scope.
ClearFollowupByScope(ctx context.Context, channel, chatID string) (int, error)
// SetFollowupForActiveTasks sets followup on in_progress tasks that don't already have one.
// Matches tasks scoped to the given channel+chatID, or unscoped tasks in the same team.
SetFollowupForActiveTasks(ctx context.Context, teamID uuid.UUID, channel, chatID string, followupAt time.Time, max int, message string) (int, error)
// HasActiveMemberTasks returns true if there are pending/in_progress/blocked tasks
// assigned to agents other than the given agent (typically the lead).
// Used to suppress auto-followup when the lead is waiting for teammates, not the user.
HasActiveMemberTasks(ctx context.Context, teamID uuid.UUID, excludeAgentID uuid.UUID) (bool, error)
// Progress
UpdateTaskProgress(ctx context.Context, taskID, teamID uuid.UUID, percent int, step string) error
// Lock renewal (heartbeat to prevent stale recovery of long-running tasks)
RenewTaskLock(ctx context.Context, taskID, teamID uuid.UUID) error
// Stale recovery (batch — all v2 active teams in single query)
// RecoverAllStaleTasks resets in_progress tasks with expired locks back to pending.
RecoverAllStaleTasks(ctx context.Context) ([]RecoveredTaskInfo, error)
// ForceRecoverAllTasks resets ALL in_progress tasks back to pending (ignoring lock expiry).
// Used on startup when no agents are running.
ForceRecoverAllTasks(ctx context.Context) ([]RecoveredTaskInfo, error)
// ListRecoverableTasks returns all pending tasks (including stale in_progress with expired locks).
// Per-team: used by DispatchUnblockedTasks after task completion.
ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]TeamTaskData, error)
// MarkAllStaleTasks sets pending tasks older than olderThan to stale status across all v2 active teams.
MarkAllStaleTasks(ctx context.Context, olderThan time.Time) ([]RecoveredTaskInfo, error)
// ResetTaskStatus resets a stale or failed task back to pending for retry.
ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error
// Delegation history
SaveDelegationHistory(ctx context.Context, record *DelegationHistoryData) error
ListDelegationHistory(ctx context.Context, opts DelegationHistoryListOpts) ([]DelegationHistoryData, int, error)
GetDelegationHistory(ctx context.Context, id uuid.UUID) (*DelegationHistoryData, error)
// Messages (mailbox)
SendMessage(ctx context.Context, msg *TeamMessageData) error
GetUnread(ctx context.Context, teamID, agentID uuid.UUID) ([]TeamMessageData, error)
MarkRead(ctx context.Context, messageID uuid.UUID) error
// ListMessages returns paginated team messages ordered by created_at DESC.
ListMessages(ctx context.Context, teamID uuid.UUID, limit, offset int) ([]TeamMessageData, int, error)
// Workspace files
// UpsertWorkspaceFile acquires an advisory lock, calls diskWriteFn (if non-nil)
// to perform disk I/O under that lock, then upserts DB metadata — all within one tx.
// diskWriteFn receives isNew (true if this is a new file, false if update).
UpsertWorkspaceFile(ctx context.Context, file *TeamWorkspaceFileData, diskWriteFn func(isNew bool) error) (isNew bool, err error)
GetWorkspaceFile(ctx context.Context, teamID uuid.UUID, channel, chatID, fileName string) (*TeamWorkspaceFileData, error)
// ListWorkspaceFiles returns non-archived files for a team.
// When both channel and chatID are empty, all files for the team are returned
// regardless of scope (used by the Workspace UI tab).
// When either is non-empty, results are filtered to that exact channel+chatID scope.
ListWorkspaceFiles(ctx context.Context, teamID uuid.UUID, channel, chatID string) ([]TeamWorkspaceFileData, error)
DeleteWorkspaceFile(ctx context.Context, teamID uuid.UUID, channel, chatID, fileName string) (filePath string, err error)
CountWorkspaceFiles(ctx context.Context, teamID uuid.UUID, channel, chatID string) (int, error)
PinWorkspaceFile(ctx context.Context, teamID uuid.UUID, channel, chatID, fileName string, pinned bool) error
TagWorkspaceFile(ctx context.Context, teamID uuid.UUID, channel, chatID, fileName string, tags []string) error
ListDeliverableFiles(ctx context.Context, teamID uuid.UUID, channel, chatID string) ([]TeamWorkspaceFileData, error)
ArchiveWorkspaceFilesByTask(ctx context.Context, taskID uuid.UUID) error
ListOrphanWorkspaceFiles(ctx context.Context, teamID uuid.UUID, olderThan time.Time) ([]TeamWorkspaceFileData, error)
CopyFilesToTeam(ctx context.Context, fileIDs []uuid.UUID, targetTeamID uuid.UUID, targetChannel, targetChatID, dataDir string) error
// Workspace versioning
CreateFileVersion(ctx context.Context, version *TeamWorkspaceFileVersionData) error
ListFileVersions(ctx context.Context, fileID uuid.UUID) ([]TeamWorkspaceFileVersionData, error)
GetFileVersion(ctx context.Context, fileID uuid.UUID, version int) (*TeamWorkspaceFileVersionData, error)
PruneOldVersions(ctx context.Context, fileID uuid.UUID, keepN int) ([]string, error) // returns pruned file paths
// Workspace comments
AddFileComment(ctx context.Context, comment *TeamWorkspaceCommentData) error
ListFileComments(ctx context.Context, fileID uuid.UUID) ([]TeamWorkspaceCommentData, error)
// Workspace quota
GetWorkspaceTotalSize(ctx context.Context, teamID uuid.UUID) (int64, error)
}