277677f330
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
718 lines
28 KiB
Go
718 lines
28 KiB
Go
// host_data.go implements pluginHost's Tasks/Sessions/Workspaces/Workflows/
|
|
// AgentProfiles/Repositories accessors — the Host data API (ADR 0043:
|
|
// docs/decisions/0043-plugin-host-data-api.md). Each accessor is
|
|
// capability-gated at the point it is called: a plugin whose manifest lacks
|
|
// the resource's api_read:<resource> capability gets back a reader whose
|
|
// every method returns gRPC PermissionDenied, so a real reader's methods
|
|
// never need to re-check the gate themselves.
|
|
//
|
|
// Reads never touch a repository directly — each real reader is backed by a
|
|
// narrow interface (taskDataSource, workflowLister, workflowStepLister,
|
|
// agentProfileDataSource, sessionCodeStatsSource) satisfied structurally by
|
|
// the real internal/task/service.Service, internal/workflow/service.Service,
|
|
// internal/agent/settings/controller.Controller, and
|
|
// internal/analytics/service.Service that backendapp wires in via
|
|
// Service.SetDataSources — mirroring how internal/plugins/delivery declares
|
|
// its own small Transport/PluginLister interfaces instead of importing this
|
|
// package's full surface.
|
|
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto"
|
|
analyticsmodels "github.com/kandev/kandev/internal/analytics/models"
|
|
"github.com/kandev/kandev/internal/task/repository/repoerrors"
|
|
|
|
githubsvc "github.com/kandev/kandev/internal/github"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
taskservice "github.com/kandev/kandev/internal/task/service"
|
|
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
|
"github.com/kandev/kandev/pkg/pluginsdk"
|
|
)
|
|
|
|
// Resource names gating the Host data API's read RPCs, per ADR 0043: each
|
|
// accessor requires "api_read:<resource>" in the plugin's manifest.
|
|
const (
|
|
resourceTasks = "tasks"
|
|
resourceSessions = "sessions"
|
|
resourceWorkspaces = "workspaces"
|
|
resourceWorkflows = "workflows"
|
|
resourceAgentProfiles = "agent_profiles"
|
|
resourceExecutorProfiles = "executor_profiles"
|
|
resourceRepositories = "repositories"
|
|
resourceMessages = "messages"
|
|
resourceInteractions = "interactions"
|
|
)
|
|
|
|
// apiReadCapability formats resource as the api_read:<resource> capability
|
|
// name permissionDenied expects.
|
|
func apiReadCapability(resource string) string {
|
|
return "api_read:" + resource
|
|
}
|
|
|
|
// apiWriteCapability formats resource as the api_write:<resource> capability
|
|
// name permissionDenied expects for the Host data API's write RPCs (ADR 0043).
|
|
func apiWriteCapability(resource string) string {
|
|
return "api_write:" + resource
|
|
}
|
|
|
|
// Pagination: Page.Cursor is a decimal string offset into the server-side
|
|
// result set. It is an implementation detail plugins must treat as opaque
|
|
// (per ADR 0043's "opaque cursor" convention) — nothing here promises it
|
|
// stays a plain offset. defaultPageLimit/maxPageLimit bound Page.Limit.
|
|
const (
|
|
defaultPageLimit = 50
|
|
maxPageLimit = 200
|
|
)
|
|
|
|
// maxMessageFilterValues bounds the combined number of session/task/type
|
|
// values a ListMessages filter may carry, keeping the resulting IN(...) bind
|
|
// parameters comfortably under SQLite's SQLITE_MAX_VARIABLE_NUMBER (999 on
|
|
// older builds) with headroom for the since/until/limit/offset params.
|
|
const maxMessageFilterValues = 400
|
|
|
|
// normalizePageLimit clamps limit to [1, maxPageLimit], defaulting to
|
|
// defaultPageLimit when unset or invalid.
|
|
func normalizePageLimit(limit int32) int {
|
|
l := int(limit)
|
|
if l <= 0 {
|
|
return defaultPageLimit
|
|
}
|
|
if l > maxPageLimit {
|
|
return maxPageLimit
|
|
}
|
|
return l
|
|
}
|
|
|
|
// pageOffset decodes cursor as the decimal offset it was encoded as; an
|
|
// empty, invalid, or negative cursor starts back at offset 0.
|
|
func pageOffset(cursor string) int {
|
|
if cursor == "" {
|
|
return 0
|
|
}
|
|
n, err := strconv.Atoi(cursor)
|
|
if err != nil || n < 0 {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
// paginate slices an already-fetched, already-ordered items slice per page's
|
|
// offset/limit and builds the PageInfo the RPC hands back.
|
|
func paginate[T any](items []T, page pluginsdk.Page) ([]T, *pluginsdk.PageInfo) {
|
|
limit := normalizePageLimit(page.Limit)
|
|
offset := pageOffset(page.Cursor)
|
|
if offset >= len(items) {
|
|
return []T{}, &pluginsdk.PageInfo{}
|
|
}
|
|
end := offset + limit
|
|
hasMore := end < len(items)
|
|
if end > len(items) {
|
|
end = len(items)
|
|
}
|
|
info := &pluginsdk.PageInfo{HasMore: hasMore}
|
|
if hasMore {
|
|
info.NextCursor = strconv.Itoa(end)
|
|
}
|
|
return items[offset:end], info
|
|
}
|
|
|
|
// ── Narrow data-source interfaces ───────────────────────────────────────
|
|
//
|
|
// Each interface names exactly the methods this file calls, satisfied
|
|
// structurally (no adapter type needed) by the real service kandev already
|
|
// constructs: internal/task/service.Service already has every taskDataSource
|
|
// and workflowLister method; internal/workflow/service.Service already has
|
|
// ListStepsByWorkflow; internal/agent/settings/controller.Controller already
|
|
// has ListAgents; internal/analytics/service.Service already has
|
|
// ListSessionCodeStats.
|
|
|
|
// taskDataSource is the narrow slice of internal/task/service.Service the
|
|
// Tasks/Workspaces/Repositories/Sessions readers need.
|
|
type taskDataSource interface {
|
|
ListWorkspaces(ctx context.Context) ([]*taskmodels.Workspace, error)
|
|
ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*taskmodels.Task, int, error)
|
|
GetTask(ctx context.Context, id string) (*taskmodels.Task, error)
|
|
ListRepositories(ctx context.Context, workspaceID string) ([]*taskmodels.Repository, error)
|
|
ListTaskSessions(ctx context.Context, taskID string) ([]*taskmodels.TaskSession, error)
|
|
GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*taskmodels.ExecutorRunning, error)
|
|
ListAllExecutorProfiles(ctx context.Context) ([]*taskmodels.ExecutorProfile, error)
|
|
GetExecutor(ctx context.Context, id string) (*taskmodels.Executor, error)
|
|
BuildDependencyViews(ctx context.Context, tasks []*taskmodels.Task) map[string]taskservice.DependencyView
|
|
BuildDependencyViewsBounded(ctx context.Context, tasks []*taskmodels.Task) (map[string]taskservice.DependencyView, error)
|
|
}
|
|
|
|
// workflowLister is the narrow slice of internal/task/service.Service the
|
|
// Workflows().List RPC needs (workflows themselves are owned by the task
|
|
// service, not internal/workflow/service — only steps are).
|
|
type workflowLister interface {
|
|
ListWorkflows(ctx context.Context, workspaceID string, includeHidden bool) ([]*taskmodels.Workflow, error)
|
|
}
|
|
|
|
// workflowStepLister is the narrow slice of internal/workflow/service.Service
|
|
// the Workflows().ListSteps RPC needs.
|
|
type workflowStepLister interface {
|
|
ListStepsByWorkflow(ctx context.Context, workflowID string) ([]*wfmodels.WorkflowStep, error)
|
|
}
|
|
|
|
// agentProfileDataSource is the narrow slice of
|
|
// internal/agent/settings/controller.Controller the AgentProfiles().List RPC
|
|
// needs. ListAgents already filters out workspace-scoped (office) profiles
|
|
// (see filterGlobalProfiles), matching the resource's global-instance scope.
|
|
type agentProfileDataSource interface {
|
|
ListAgents(ctx context.Context) (*agentsettingsdto.ListAgentsResponse, error)
|
|
}
|
|
|
|
// taskPRSource is the narrow slice of internal/github.Service the Tasks reader
|
|
// needs to populate Task.PullRequests.
|
|
//
|
|
// SEPARATE FROM taskDataSource ON PURPOSE. A task's pull requests live in
|
|
// github_task_prs, owned by internal/github, not on the task row — so the link
|
|
// is not derivable from anything taskDataSource returns. It is wired separately
|
|
// (SetTaskPRSource) because GitHub is optional and may be available after a
|
|
// host is created; a nil source simply yields tasks with no PullRequests rather
|
|
// than failing the read.
|
|
type taskPRSource interface {
|
|
ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]*githubsvc.TaskPR, error)
|
|
}
|
|
|
|
// sessionCodeStatsSource is the narrow slice of
|
|
// internal/analytics/service.Service the Sessions().CodeStats RPC needs.
|
|
type sessionCodeStatsSource interface {
|
|
ListSessionCodeStats(ctx context.Context, filter analyticsmodels.SessionCodeStatsFilter) ([]*analyticsmodels.SessionCodeStats, error)
|
|
}
|
|
|
|
// messageDataSource is the narrow slice of internal/task/service.Service the
|
|
// Messages().List RPC needs. ListMessagesForPlugin filters by session/task
|
|
// ids and a created_at time range, returning oldest-first with SQL-level
|
|
// limit/offset (Limit is requested as page-limit+1 so the reader can derive
|
|
// HasMore without a second count query).
|
|
type messageDataSource interface {
|
|
ListMessagesForPlugin(ctx context.Context, filter taskmodels.PluginMessageFilter) ([]*taskmodels.Message, error)
|
|
}
|
|
|
|
// ── pluginHost accessors ────────────────────────────────────────────────
|
|
//
|
|
// These shadow the Unimplemented* defaults embedded via
|
|
// pluginsdk.UnimplementedHostData: each checks the resource's api_read
|
|
// capability once, then hands back either the real, service-backed reader or
|
|
// a denied stub whose methods all return PermissionDenied. If the capability
|
|
// is granted but the corresponding data source was never wired (e.g.
|
|
// Service.SetDataSources not called — some tests build a bare pluginHost),
|
|
// this falls back to the embedded Unimplemented reader rather than a nil
|
|
// pointer dereference.
|
|
|
|
// Tasks returns the task accessor. Unlike the read-only accessors below, it
|
|
// mixes read (List/Get, api_read:tasks) and write (Create/Update,
|
|
// api_write:tasks) methods whose capabilities gate independently, so the gate
|
|
// cannot live at the accessor — each method checks its own capability (reads
|
|
// here, writes in host_write.go).
|
|
func (h *pluginHost) Tasks() pluginsdk.TaskReader {
|
|
return taskReader{host: h}
|
|
}
|
|
|
|
func (h *pluginHost) Sessions() pluginsdk.SessionReader {
|
|
if !h.capabilities.CanRead(resourceSessions) {
|
|
return deniedSessionReader{}
|
|
}
|
|
if h.taskData == nil || h.sessionCodeStats == nil {
|
|
return h.UnimplementedHostData.Sessions()
|
|
}
|
|
return sessionReader{host: h}
|
|
}
|
|
|
|
func (h *pluginHost) Workspaces() pluginsdk.WorkspaceReader {
|
|
if !h.capabilities.CanRead(resourceWorkspaces) {
|
|
return deniedWorkspaceReader{}
|
|
}
|
|
if h.taskData == nil {
|
|
return h.UnimplementedHostData.Workspaces()
|
|
}
|
|
return workspaceReader{host: h}
|
|
}
|
|
|
|
func (h *pluginHost) Workflows() pluginsdk.WorkflowReader {
|
|
if !h.capabilities.CanRead(resourceWorkflows) {
|
|
return deniedWorkflowReader{}
|
|
}
|
|
if h.workflows == nil || h.workflowSteps == nil {
|
|
return h.UnimplementedHostData.Workflows()
|
|
}
|
|
return workflowReader{host: h}
|
|
}
|
|
|
|
func (h *pluginHost) AgentProfiles() pluginsdk.AgentProfileReader {
|
|
if !h.capabilities.CanRead(resourceAgentProfiles) {
|
|
return deniedAgentProfileReader{}
|
|
}
|
|
if h.agentProfiles == nil {
|
|
return h.UnimplementedHostData.AgentProfiles()
|
|
}
|
|
return agentProfileReader{host: h}
|
|
}
|
|
|
|
func (h *pluginHost) ExecutorProfiles() pluginsdk.ExecutorProfileReader {
|
|
if !h.capabilities.CanRead(resourceExecutorProfiles) {
|
|
return deniedExecutorProfileReader{}
|
|
}
|
|
if h.taskData == nil {
|
|
return h.UnimplementedHostData.ExecutorProfiles()
|
|
}
|
|
return executorProfileReader{host: h}
|
|
}
|
|
|
|
func (h *pluginHost) Repositories() pluginsdk.RepositoryReader {
|
|
if !h.capabilities.CanRead(resourceRepositories) {
|
|
return deniedRepositoryReader{}
|
|
}
|
|
if h.taskData == nil {
|
|
return h.UnimplementedHostData.Repositories()
|
|
}
|
|
return repositoryReader{host: h}
|
|
}
|
|
|
|
// Messages mixes read (List, api_read:messages) and write (Send,
|
|
// api_write:messages) with independent capabilities, so — like Tasks() — the
|
|
// gate can't live at the accessor; each method checks its own capability (List
|
|
// here, Send in host_write.go).
|
|
func (h *pluginHost) Messages() pluginsdk.MessageReader {
|
|
return messageReader{host: h}
|
|
}
|
|
|
|
// ── Denied readers ──────────────────────────────────────────────────────
|
|
|
|
type deniedSessionReader struct{}
|
|
|
|
func (deniedSessionReader) List(context.Context, pluginsdk.SessionFilter, pluginsdk.Page) ([]pluginsdk.Session, *pluginsdk.PageInfo, error) {
|
|
return nil, nil, permissionDenied(apiReadCapability(resourceSessions))
|
|
}
|
|
|
|
func (deniedSessionReader) CodeStats(context.Context, pluginsdk.SessionFilter, pluginsdk.Page) ([]pluginsdk.SessionCodeStats, *pluginsdk.PageInfo, error) {
|
|
return nil, nil, permissionDenied(apiReadCapability(resourceSessions))
|
|
}
|
|
|
|
type deniedWorkspaceReader struct{}
|
|
|
|
func (deniedWorkspaceReader) List(context.Context, pluginsdk.Page) ([]pluginsdk.Workspace, *pluginsdk.PageInfo, error) {
|
|
return nil, nil, permissionDenied(apiReadCapability(resourceWorkspaces))
|
|
}
|
|
|
|
type deniedWorkflowReader struct{}
|
|
|
|
func (deniedWorkflowReader) List(context.Context, string, pluginsdk.Page) ([]pluginsdk.Workflow, *pluginsdk.PageInfo, error) {
|
|
return nil, nil, permissionDenied(apiReadCapability(resourceWorkflows))
|
|
}
|
|
|
|
func (deniedWorkflowReader) ListSteps(context.Context, string) ([]pluginsdk.WorkflowStep, error) {
|
|
return nil, permissionDenied(apiReadCapability(resourceWorkflows))
|
|
}
|
|
|
|
type deniedAgentProfileReader struct{}
|
|
|
|
func (deniedAgentProfileReader) List(context.Context, pluginsdk.Page) ([]pluginsdk.AgentProfile, *pluginsdk.PageInfo, error) {
|
|
return nil, nil, permissionDenied(apiReadCapability(resourceAgentProfiles))
|
|
}
|
|
|
|
type deniedExecutorProfileReader struct{}
|
|
|
|
func (deniedExecutorProfileReader) List(context.Context, pluginsdk.Page) ([]pluginsdk.ExecutorProfile, *pluginsdk.PageInfo, error) {
|
|
return nil, nil, permissionDenied(apiReadCapability(resourceExecutorProfiles))
|
|
}
|
|
|
|
type deniedRepositoryReader struct{}
|
|
|
|
func (deniedRepositoryReader) List(context.Context, string, pluginsdk.Page) ([]pluginsdk.Repository, *pluginsdk.PageInfo, error) {
|
|
return nil, nil, permissionDenied(apiReadCapability(resourceRepositories))
|
|
}
|
|
|
|
// ── Real readers ────────────────────────────────────────────────────────
|
|
//
|
|
// Only ever returned once the resource's capability gate has passed (see the
|
|
// accessors above), so none of these re-check it.
|
|
|
|
// taskFetchPageSize bounds each individual ListTasksByWorkspace call made
|
|
// while assembling a workspace's tasks for in-memory filter/sort/paginate.
|
|
// fetchTasksForWorkspaces loops pagination to completion per workspace (see
|
|
// its doc comment), so this only bounds one round trip's page size — it does
|
|
// NOT bound how many tasks a workspace can have before Host data API reads
|
|
// start silently dropping them.
|
|
const taskFetchPageSize = 1000
|
|
|
|
type taskReader struct{ host *pluginHost }
|
|
|
|
// fetchTaskPage resolves, filters, sorts, and paginates matching tasks,
|
|
// returning the raw models backing exactly the returned page (not the whole
|
|
// matching set). Both taskReader.List (gRPC ListTasks, which has no further
|
|
// narrowing) and the canvas task-list route (which narrows further by
|
|
// binding scope, and so must derive dependencies AFTER that narrowing
|
|
// rather than trust this method's caller to have already bounded the set)
|
|
// call this to fetch.
|
|
func (h *pluginHost) fetchTaskPage(ctx context.Context, filter pluginsdk.TaskFilter, page pluginsdk.Page) ([]*taskmodels.Task, *pluginsdk.PageInfo, error) {
|
|
workspaceIDs, err := h.resolveWorkspaceIDs(ctx, filter.WorkspaceIDs)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
tasks, err := h.fetchTasksForWorkspaces(ctx, workspaceIDs, filter.IncludeEphemeral, filter.IncludeArchived)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
tasks = filterTasks(tasks, filter)
|
|
sortTasksNewestFirst(tasks)
|
|
models, info := paginate(tasks, page)
|
|
return models, info, nil
|
|
}
|
|
|
|
func (r taskReader) List(ctx context.Context, filter pluginsdk.TaskFilter, page pluginsdk.Page) ([]pluginsdk.Task, *pluginsdk.PageInfo, error) {
|
|
if !r.host.capabilities.CanRead(resourceTasks) {
|
|
return nil, nil, permissionDenied(apiReadCapability(resourceTasks))
|
|
}
|
|
if r.host.taskData == nil {
|
|
return r.host.UnimplementedHostData.Tasks().List(ctx, filter, page)
|
|
}
|
|
models, info, err := r.host.fetchTaskPage(ctx, filter, page)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
items := tasksToDTOs(models)
|
|
// Attached AFTER pagination so the PR lookup and dependency derivation
|
|
// cover one page, not the whole workspace: a campaign-sized read would
|
|
// otherwise fan out over every task kandev holds to fill a list the
|
|
// caller already bounded.
|
|
r.host.attachPullRequests(ctx, items)
|
|
if err := r.host.attachDependencies(ctx, items, models, true, "ListTasks"); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return items, info, nil
|
|
}
|
|
|
|
// attachPullRequests fills Task.PullRequests in place. A missing source or a
|
|
// failed lookup leaves them empty and logs nothing: pull requests are
|
|
// supplementary to a task read, and refusing the whole list over them would
|
|
// take away more than it protects.
|
|
func (h *pluginHost) attachPullRequests(ctx context.Context, tasks []pluginsdk.Task) {
|
|
source := h.taskPRs
|
|
if h.taskPRsDep != nil {
|
|
source = h.taskPRsDep()
|
|
}
|
|
if source == nil || len(tasks) == 0 {
|
|
return
|
|
}
|
|
ids := make([]string, 0, len(tasks))
|
|
for i := range tasks {
|
|
ids = append(ids, tasks[i].ID)
|
|
}
|
|
byTask, err := source.ListTaskPRsByTaskIDs(ctx, ids)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for i := range tasks {
|
|
prs := byTask[tasks[i].ID]
|
|
if len(prs) == 0 {
|
|
continue
|
|
}
|
|
tasks[i].PullRequests = taskPRsToDTOs(prs)
|
|
}
|
|
}
|
|
|
|
// fetchTask resolves id to both wire and raw model forms without attaching
|
|
// pull requests or the dependency projection. model is nil whenever h has no
|
|
// task data source (dto then comes straight from UnimplementedHostData).
|
|
// Callers that only need the task for a scope-match preflight — and discard
|
|
// it immediately after — must use this (or fetchTaskForScopeCheck) rather
|
|
// than taskReader.Get, so a caller that never serializes the task never pays
|
|
// for its dependency derivation either.
|
|
func (h *pluginHost) fetchTask(ctx context.Context, id string) (dto *pluginsdk.Task, model *taskmodels.Task, err error) {
|
|
if !h.capabilities.CanRead(resourceTasks) {
|
|
return nil, nil, permissionDenied(apiReadCapability(resourceTasks))
|
|
}
|
|
if h.taskData == nil {
|
|
dto, err = h.UnimplementedHostData.Tasks().Get(ctx, id)
|
|
return dto, nil, err
|
|
}
|
|
task, err := h.taskData.GetTask(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, repoerrors.ErrTaskNotFound) {
|
|
return nil, nil, taskNotFound(id)
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
converted := taskModelToDTO(task)
|
|
return &converted, task, nil
|
|
}
|
|
|
|
// fetchTaskForScopeCheck resolves id for a scope-match preflight only: the
|
|
// caller discards the task immediately after deciding whether it may act on
|
|
// it, so this must not pay for pull-request or dependency-projection
|
|
// attachment the response will never serialize.
|
|
func (h *pluginHost) fetchTaskForScopeCheck(ctx context.Context, id string) (*pluginsdk.Task, error) {
|
|
dto, _, err := h.fetchTask(ctx, id)
|
|
return dto, err
|
|
}
|
|
|
|
// Get returns a gRPC NotFound error (not a (nil, nil) success) when id
|
|
// doesn't resolve to a task, so the in-process contract matches exactly what
|
|
// a real plugin observes over the wire via grpcHostServer.GetTask.
|
|
func (r taskReader) Get(ctx context.Context, id string) (*pluginsdk.Task, error) {
|
|
dto, model, err := r.host.fetchTask(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if model == nil {
|
|
return dto, nil
|
|
}
|
|
items := []pluginsdk.Task{*dto}
|
|
r.host.attachPullRequests(ctx, items)
|
|
if err := r.host.attachDependencies(ctx, items, []*taskmodels.Task{model}, false, "GetTask"); err != nil {
|
|
return nil, err
|
|
}
|
|
return &items[0], nil
|
|
}
|
|
|
|
type sessionReader struct{ host *pluginHost }
|
|
|
|
// List paginates the raw, already-sorted sessions BEFORE converting to DTOs:
|
|
// sessionToDTO resolves ACPSessionID via resolveACPSessionID, which issues a
|
|
// GetExecutorRunningBySessionID query for any session lacking the id in its
|
|
// metadata. Converting every fetched session (as opposed to just the
|
|
// returned page) would turn that into an O(N) fan-out of DB queries per read
|
|
// instead of O(limit).
|
|
func (r sessionReader) List(ctx context.Context, filter pluginsdk.SessionFilter, page pluginsdk.Page) ([]pluginsdk.Session, *pluginsdk.PageInfo, error) {
|
|
sessions, err := r.host.fetchSessionsForFilter(ctx, filter)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
sessions = filterSessionsByState(sessions, filter.States)
|
|
sortSessionsNewestFirst(sessions)
|
|
|
|
pageSessions, info := paginate(sessions, page)
|
|
dtos := make([]pluginsdk.Session, len(pageSessions))
|
|
for i, s := range pageSessions {
|
|
dtos[i] = r.host.sessionToDTO(ctx, s)
|
|
}
|
|
return dtos, info, nil
|
|
}
|
|
|
|
// CodeStats delegates straight to the analytics service, which already
|
|
// paginates via SQL Limit/Offset (per ADR 0043(b), computed on demand — no
|
|
// in-memory fetch-everything like the other readers). It asks for one extra
|
|
// row past the requested limit to derive HasMore without a second count
|
|
// query; NextCursor is offset+limit exactly like the in-memory paginate
|
|
// helper, keeping cursor semantics uniform across every Host data reader.
|
|
func (r sessionReader) CodeStats(ctx context.Context, filter pluginsdk.SessionFilter, page pluginsdk.Page) ([]pluginsdk.SessionCodeStats, *pluginsdk.PageInfo, error) {
|
|
limit := normalizePageLimit(page.Limit)
|
|
offset := pageOffset(page.Cursor)
|
|
|
|
stats, err := r.host.sessionCodeStats.ListSessionCodeStats(ctx, analyticsmodels.SessionCodeStatsFilter{
|
|
TaskIDs: filter.TaskIDs,
|
|
WorkspaceIDs: filter.WorkspaceIDs,
|
|
States: filter.States,
|
|
Limit: limit + 1,
|
|
Offset: offset,
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
hasMore := len(stats) > limit
|
|
if hasMore {
|
|
stats = stats[:limit]
|
|
}
|
|
dtos := make([]pluginsdk.SessionCodeStats, len(stats))
|
|
for i, s := range stats {
|
|
dtos[i] = sessionCodeStatsModelToDTO(s)
|
|
}
|
|
info := &pluginsdk.PageInfo{HasMore: hasMore}
|
|
if hasMore {
|
|
info.NextCursor = strconv.Itoa(offset + limit)
|
|
}
|
|
return dtos, info, nil
|
|
}
|
|
|
|
type workspaceReader struct{ host *pluginHost }
|
|
|
|
func (r workspaceReader) List(ctx context.Context, page pluginsdk.Page) ([]pluginsdk.Workspace, *pluginsdk.PageInfo, error) {
|
|
workspaces, err := r.host.taskData.ListWorkspaces(ctx)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
dtos := make([]pluginsdk.Workspace, len(workspaces))
|
|
for i, w := range workspaces {
|
|
dtos[i] = workspaceModelToDTO(w)
|
|
}
|
|
items, info := paginate(dtos, page)
|
|
return items, info, nil
|
|
}
|
|
|
|
type workflowReader struct{ host *pluginHost }
|
|
|
|
// List does not surface hidden workflows (e.g. system-only flows like
|
|
// Improve Kandev) — the Host data API's WorkflowReader has no includeHidden
|
|
// filter yet, so this reader defaults to the same "hidden by default"
|
|
// behavior most kandev UI listings use.
|
|
func (r workflowReader) List(ctx context.Context, workspaceID string, page pluginsdk.Page) ([]pluginsdk.Workflow, *pluginsdk.PageInfo, error) {
|
|
workflows, err := r.host.workflows.ListWorkflows(ctx, workspaceID, false)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
dtos := make([]pluginsdk.Workflow, len(workflows))
|
|
for i, w := range workflows {
|
|
dtos[i] = workflowModelToDTO(w)
|
|
}
|
|
items, info := paginate(dtos, page)
|
|
return items, info, nil
|
|
}
|
|
|
|
func (r workflowReader) ListSteps(ctx context.Context, workflowID string) ([]pluginsdk.WorkflowStep, error) {
|
|
steps, err := r.host.workflowSteps.ListStepsByWorkflow(ctx, workflowID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dtos := make([]pluginsdk.WorkflowStep, len(steps))
|
|
for i, s := range steps {
|
|
dtos[i] = workflowStepModelToDTO(s)
|
|
}
|
|
return dtos, nil
|
|
}
|
|
|
|
type agentProfileReader struct{ host *pluginHost }
|
|
|
|
func (r agentProfileReader) List(ctx context.Context, page pluginsdk.Page) ([]pluginsdk.AgentProfile, *pluginsdk.PageInfo, error) {
|
|
resp, err := r.host.agentProfiles.ListAgents(ctx)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var dtos []pluginsdk.AgentProfile
|
|
for _, agent := range resp.Agents {
|
|
for _, profile := range agent.Profiles {
|
|
dtos = append(dtos, agentProfileDTOToSDK(profile))
|
|
}
|
|
}
|
|
items, info := paginate(dtos, page)
|
|
return items, info, nil
|
|
}
|
|
|
|
type executorProfileReader struct{ host *pluginHost }
|
|
|
|
func (r executorProfileReader) List(ctx context.Context, page pluginsdk.Page) ([]pluginsdk.ExecutorProfile, *pluginsdk.PageInfo, error) {
|
|
profiles, err := r.host.taskData.ListAllExecutorProfiles(ctx)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
validProfiles := make([]*taskmodels.ExecutorProfile, 0, len(profiles))
|
|
for _, profile := range profiles {
|
|
if profile != nil {
|
|
validProfiles = append(validProfiles, profile)
|
|
}
|
|
}
|
|
pageProfiles, info := paginate(validProfiles, page)
|
|
dtos := make([]pluginsdk.ExecutorProfile, 0, len(pageProfiles))
|
|
for _, profile := range pageProfiles {
|
|
executor, err := r.host.taskData.GetExecutor(ctx, profile.ExecutorID)
|
|
if err != nil && !errors.Is(err, taskmodels.ErrExecutorNotFound) {
|
|
return nil, nil, err
|
|
}
|
|
executorType := ""
|
|
if executor != nil {
|
|
executorType = string(executor.Type)
|
|
}
|
|
dtos = append(dtos, pluginsdk.ExecutorProfile{
|
|
ID: profile.ID, DisplayName: profile.Name, ExecutorType: executorType,
|
|
})
|
|
}
|
|
return dtos, info, nil
|
|
}
|
|
|
|
type repositoryReader struct{ host *pluginHost }
|
|
|
|
func (r repositoryReader) List(ctx context.Context, workspaceID string, page pluginsdk.Page) ([]pluginsdk.Repository, *pluginsdk.PageInfo, error) {
|
|
repos, err := r.host.taskData.ListRepositories(ctx, workspaceID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
dtos := make([]pluginsdk.Repository, len(repos))
|
|
for i, repository := range repos {
|
|
dtos[i] = repositoryModelToDTO(repository)
|
|
}
|
|
items, info := paginate(dtos, page)
|
|
return items, info, nil
|
|
}
|
|
|
|
type messageReader struct{ host *pluginHost }
|
|
|
|
// List paginates conversation content at the SQL layer (limit/offset, like
|
|
// sessionReader.CodeStats) rather than fetching everything into memory — a
|
|
// task's transcript can be very large. Since/Until are RFC3339; an
|
|
// unparseable value is a gRPC InvalidArgument. Content is sanitized by
|
|
// messageModelToDTO (kandev-system blocks stripped) before it reaches the
|
|
// plugin.
|
|
func (r messageReader) List(ctx context.Context, filter pluginsdk.MessageFilter, page pluginsdk.Page) ([]pluginsdk.Message, *pluginsdk.PageInfo, error) {
|
|
if !r.host.capabilities.CanRead(resourceMessages) {
|
|
return nil, nil, permissionDenied(apiReadCapability(resourceMessages))
|
|
}
|
|
if r.host.messageData == nil {
|
|
return r.host.UnimplementedHostData.Messages().List(ctx, filter, page)
|
|
}
|
|
// Each session/task/type value becomes its own SQL bind parameter; cap
|
|
// their combined count well under SQLite's host-parameter limit
|
|
// (~500-999) so a large filter fails fast with a clear InvalidArgument
|
|
// instead of a cryptic "too many SQL variables" at query execution.
|
|
if n := len(filter.SessionIDs) + len(filter.TaskIDs) + len(filter.Types); n > maxMessageFilterValues {
|
|
return nil, nil, invalidArgument(fmt.Sprintf("message filter has %d session/task/type values, max %d", n, maxMessageFilterValues))
|
|
}
|
|
since, err := parseFilterTime(filter.Since, "since")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
until, err := parseFilterTime(filter.Until, "until")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
limit := normalizePageLimit(page.Limit)
|
|
offset := pageOffset(page.Cursor)
|
|
|
|
messages, err := r.host.messageData.ListMessagesForPlugin(ctx, taskmodels.PluginMessageFilter{
|
|
SessionIDs: filter.SessionIDs,
|
|
TaskIDs: filter.TaskIDs,
|
|
Types: filter.Types,
|
|
Since: since,
|
|
Until: until,
|
|
Limit: limit + 1,
|
|
Offset: offset,
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
hasMore := len(messages) > limit
|
|
if hasMore {
|
|
messages = messages[:limit]
|
|
}
|
|
dtos := make([]pluginsdk.Message, len(messages))
|
|
for i, m := range messages {
|
|
dtos[i] = messageModelToDTO(m)
|
|
}
|
|
info := &pluginsdk.PageInfo{HasMore: hasMore}
|
|
if hasMore {
|
|
info.NextCursor = strconv.Itoa(offset + limit)
|
|
}
|
|
return dtos, info, nil
|
|
}
|
|
|
|
// parseFilterTime parses an optional RFC3339 filter bound, returning a gRPC
|
|
// InvalidArgument error naming the field when a non-nil value doesn't parse.
|
|
func parseFilterTime(value *string, field string) (*time.Time, error) {
|
|
if value == nil || *value == "" {
|
|
return nil, nil
|
|
}
|
|
t, err := time.Parse(time.RFC3339, *value)
|
|
if err != nil {
|
|
return nil, invalidArgument(fmt.Sprintf("%s must be RFC3339: %v", field, err))
|
|
}
|
|
return &t, nil
|
|
}
|