package plugins import ( "context" "errors" "fmt" "sort" "github.com/kandev/kandev/internal/task/repository/repoerrors" taskmodels "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/pkg/pluginsdk" ) // ── Fetch/filter/sort helpers (v1 scoping: ADR 0043(a) "global-with-hook") ─ // resolveWorkspaceIDs returns requested unchanged when non-empty (an // explicit filter always narrows), otherwise every workspace the instance // holds — this is the "global reads, filters narrow results" v1 scoping // rule, and the single hook a future per-plugin/per-user workspace // restriction would replace. func (h *pluginHost) resolveWorkspaceIDs(ctx context.Context, requested []string) ([]string, error) { if len(requested) > 0 { return requested, nil } workspaces, err := h.taskData.ListWorkspaces(ctx) if err != nil { return nil, fmt.Errorf("plugins: list workspaces: %w", err) } ids := make([]string, len(workspaces)) for i, w := range workspaces { ids[i] = w.ID } return ids, nil } // fetchTasksForWorkspaces concatenates up to taskFetchPageSize tasks per // workspace in workspaceIDs. excludeConfig is always true: office config-mode // tasks (json_extract(metadata, '$.config_mode')) are internal bookkeeping, // not plugin-visible work items. func (h *pluginHost) fetchTasksForWorkspaces(ctx context.Context, workspaceIDs []string, includeEphemeral, includeArchived bool) ([]*taskmodels.Task, error) { var all []*taskmodels.Task for _, workspaceID := range workspaceIDs { tasks, err := h.fetchAllTasksForWorkspace(ctx, workspaceID, includeEphemeral, includeArchived) if err != nil { return nil, err } all = append(all, tasks...) } return all, nil } // fetchAllTasksForWorkspace loops ListTasksByWorkspace's page/pageSize // pagination to completion for a single workspace, so a workspace with more // tasks than one taskFetchPageSize page is never silently truncated (and // this reader's downstream HasMore/paginate stays accurate). Bounded by // ListTasksByWorkspace's own returned total, plus a break on an empty page // as a defensive guard against ever looping forever on an inconsistent total. func (h *pluginHost) fetchAllTasksForWorkspace(ctx context.Context, workspaceID string, includeEphemeral, includeArchived bool) ([]*taskmodels.Task, error) { var out []*taskmodels.Task for page := 1; ; page++ { tasks, total, err := h.taskData.ListTasksByWorkspace( ctx, workspaceID, "", "", "", page, taskFetchPageSize, "", includeArchived, includeEphemeral, false, true, ) if err != nil { return nil, fmt.Errorf("plugins: list tasks for workspace %q: %w", workspaceID, err) } if len(tasks) == 0 { break } out = append(out, tasks...) if len(out) >= total { break } } return out, nil } // filterTasks applies TaskFilter's WorkflowIDs/States/ParentID narrowing // on top of the already-workspace-scoped tasks fetchTasksForWorkspaces // returned (WorkspaceIDs and IncludeEphemeral are applied earlier, at fetch // time). func filterTasks(tasks []*taskmodels.Task, filter pluginsdk.TaskFilter) []*taskmodels.Task { if len(filter.WorkflowIDs) == 0 && len(filter.States) == 0 && filter.ParentID == nil { return tasks } workflowSet := toSet(filter.WorkflowIDs) stateSet := toSet(filter.States) out := make([]*taskmodels.Task, 0, len(tasks)) for _, t := range tasks { if len(workflowSet) > 0 && !workflowSet[t.WorkflowID] { continue } if len(stateSet) > 0 && !stateSet[string(t.State)] { continue } if filter.ParentID != nil && t.ParentID != *filter.ParentID { continue } out = append(out, t) } return out } func toSet(values []string) map[string]bool { if len(values) == 0 { return nil } set := make(map[string]bool, len(values)) for _, v := range values { set[v] = true } return set } // sortTasksNewestFirst orders by CreatedAt descending, tie-broken by ID // ascending: sort.Slice is unstable, and offset-cursor pagination needs a // total order across calls — two tasks with equal CreatedAt (a plausible // seed/batch-import scenario) must land in the same relative position on // every call, or an offset page can skip or duplicate one across reads. func sortTasksNewestFirst(tasks []*taskmodels.Task) { sort.Slice(tasks, func(i, j int) bool { if !tasks[i].CreatedAt.Equal(tasks[j].CreatedAt) { return tasks[i].CreatedAt.After(tasks[j].CreatedAt) } return tasks[i].ID < tasks[j].ID }) } func tasksToDTOs(tasks []*taskmodels.Task) []pluginsdk.Task { out := make([]pluginsdk.Task, len(tasks)) for i, t := range tasks { out[i] = taskModelToDTO(t) } return out } // fetchSessionsForFilter resolves the task ids to list sessions for (see // resolveSessionTaskIDs) and lists each task's sessions — a Host data API // session read is, unavoidably, an N+1 fan-out over the resolved tasks in v1 // (no session listing endpoint spans multiple tasks directly at the service // layer today). func (h *pluginHost) fetchSessionsForFilter(ctx context.Context, filter pluginsdk.SessionFilter) ([]*taskmodels.TaskSession, error) { taskIDs, err := h.resolveSessionTaskIDs(ctx, filter) if err != nil { return nil, err } var sessions []*taskmodels.TaskSession for _, taskID := range taskIDs { s, err := h.taskData.ListTaskSessions(ctx, taskID) if err != nil { return nil, fmt.Errorf("plugins: list sessions for task %q: %w", taskID, err) } sessions = append(sessions, s...) } return sessions, nil } // resolveSessionTaskIDs returns the task ids fetchSessionsForFilter should // list sessions for. filter.TaskIDs and filter.WorkspaceIDs are ANDed // together, never one bypassing the other: with both set, a task id whose // task lives outside the requested workspaces is dropped, so it can't leak // sessions from a workspace the caller didn't ask for. With only TaskIDs set, // they're used as-is (unscoped by design — an explicit task id is itself a // narrowing filter). With neither set (or only WorkspaceIDs), every task // across resolveWorkspaceIDs(filter.WorkspaceIDs) is enumerated. // includeEphemeral AND includeArchived when enumerating by workspace: a // session is still a real session from the Sessions resource's point of view // whether its task is a quick-chat or has since been archived. CodeStats // makes the same call at the SQL layer (no task-state filtering), so the two // session reads stay consistent. func (h *pluginHost) resolveSessionTaskIDs(ctx context.Context, filter pluginsdk.SessionFilter) ([]string, error) { if len(filter.TaskIDs) == 0 { workspaceIDs, err := h.resolveWorkspaceIDs(ctx, filter.WorkspaceIDs) if err != nil { return nil, err } tasks, err := h.fetchTasksForWorkspaces(ctx, workspaceIDs, true, true) if err != nil { return nil, err } taskIDs := make([]string, len(tasks)) for i, t := range tasks { taskIDs[i] = t.ID } return taskIDs, nil } if len(filter.WorkspaceIDs) == 0 { return filter.TaskIDs, nil } return h.filterTaskIDsByWorkspace(ctx, filter.TaskIDs, filter.WorkspaceIDs) } // filterTaskIDsByWorkspace keeps only the taskIDs whose task's WorkspaceID is // in workspaceIDs; a taskID that no longer resolves to a task is dropped // (not an error) — a session read shouldn't fail just because a stale id was // passed in TaskIDs. func (h *pluginHost) filterTaskIDsByWorkspace(ctx context.Context, taskIDs, workspaceIDs []string) ([]string, error) { allowed := toSet(workspaceIDs) out := make([]string, 0, len(taskIDs)) for _, taskID := range taskIDs { task, err := h.taskData.GetTask(ctx, taskID) if err != nil { if errors.Is(err, repoerrors.ErrTaskNotFound) { continue } return nil, fmt.Errorf("plugins: get task %q: %w", taskID, err) } if allowed[task.WorkspaceID] { out = append(out, taskID) } } return out, nil } func filterSessionsByState(sessions []*taskmodels.TaskSession, states []string) []*taskmodels.TaskSession { if len(states) == 0 { return sessions } set := toSet(states) out := make([]*taskmodels.TaskSession, 0, len(sessions)) for _, s := range sessions { if set[string(s.State)] { out = append(out, s) } } return out } // sortSessionsNewestFirst mirrors sortTasksNewestFirst's ID tie-break, for // the same offset-cursor pagination stability reason. func sortSessionsNewestFirst(sessions []*taskmodels.TaskSession) { sort.Slice(sessions, func(i, j int) bool { if !sessions[i].StartedAt.Equal(sessions[j].StartedAt) { return sessions[i].StartedAt.After(sessions[j].StartedAt) } return sessions[i].ID < sessions[j].ID }) }