Files
kandev/apps/backend/internal/plugins/host_data.go
T
João Salavisa 78cae96bb1 feat(plugins): host-side conversation reads + utility-agent invoke (#1852)
* feat(plugins): add capability-gated Host reader for conversation content

Plugins can now read historical user/agent message content over the Host
data API, gated by a new `api_read:messages` capability (ADR 0047). This
unblocks "summarize yesterday"-style plugins that the live `message.added`
bus event alone cannot serve.

- proto: `ListMessages` RPC + `Message`/`MessageFilter` on `service Host`.
- SDK: `Host.Messages() MessageReader`, Go-native `Message`/`MessageFilter`.
- host: `api_read:messages` accessor + denied reader; content sanitized via
  `sysprompt.StripSystemContent` (no raw system prompts); RFC3339 `since`/
  `until` → `InvalidArgument` on bad input.
- data: new `ListMessagesForPlugin` filtered query (session/task ids, type,
  created_at range) through the task service, never the DB file.
- tests: in-process + wire capability gating, content stripping, filter/time
  passthrough, pagination, and the SQLite query.
- docs: ADR 0047, spec, plugins-manifest, plugins-authoring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugins): add Host.InvokeUtilityAgent for settings-selectable utility agent

Plugins can now delegate a one-shot LLM step to an operator-configured agent,
gated by a new `agent_invoke` capability (ADR 0048), so a plugin needs no
provider API key of its own.

- setting: new `utility_agent_profile_id` user setting (Settings > System >
  Utility Agent), a profile dropdown cloned from the existing utility-agent
  path; selectable in the UI.
- capability: boolean `agent_invoke`, enforced like `state`/`secrets`.
- proto/SDK: `InvokeUtilityAgent(prompt) -> text` RPC and
  `Host.InvokeUtilityAgent(ctx, prompt)`.
- backend: reuses the sessionless host-utility inference tier (ADR 0002) via a
  thin adapter — no agent loop, no task/session/workspace; resolves the
  configured profile to (agent type, model). Unconfigured / deleted profile ->
  gRPC FailedPrecondition.
- tests: capability gating, completion path (asserts the configured profile is
  used and text returned), no-utility-agent and stale-profile error paths,
  SDK/wire round-trips, and the settings apply/trim behavior.
- docs: ADR 0048, spec, plugins-manifest, plugins-authoring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): resolve utility-agent deps live and bound message filter size

Address CI + review feedback on the host plugin capabilities:

- docs: register the new /settings/system/utility-agent route in
  docs/public/coverage.json (fixes the "Validate public docs" CI failure).
- utility agent (ADR 0048): hostUtilityMgr is wired late in boot — after
  StartActivePlugins has already spawned boot-active plugins — so snapshotting
  the utility deps in hostForPlugin stranded those hosts with nil deps and made
  InvokeUtilityAgent return Unimplemented for their lifetime. Read the deps live
  from the Service (mutex-guarded) at call time instead; add a regression test
  for late wiring reaching an already-spawned host.
- messages (ADR 0047): cap the combined session/task/type filter values at 400
  so an oversized filter fails fast with InvalidArgument instead of hitting
  SQLite's host-parameter limit at query execution; add a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 00:20:09 +01:00

993 lines
36 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"
"sort"
"strconv"
"time"
agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto"
analyticsmodels "github.com/kandev/kandev/internal/analytics/models"
"github.com/kandev/kandev/internal/sysprompt"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
taskmodels "github.com/kandev/kandev/internal/task/models"
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"
resourceRepositories = "repositories"
resourceMessages = "messages"
)
// apiReadCapability formats resource as the api_read:<resource> capability
// name permissionDenied expects.
func apiReadCapability(resource string) string {
return "api_read:" + 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)
}
// 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)
}
// 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.
func (h *pluginHost) Tasks() pluginsdk.TaskReader {
if !h.capabilities.CanRead(resourceTasks) {
return deniedTaskReader{}
}
if h.taskData == nil {
return h.UnimplementedHostData.Tasks()
}
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) Repositories() pluginsdk.RepositoryReader {
if !h.capabilities.CanRead(resourceRepositories) {
return deniedRepositoryReader{}
}
if h.taskData == nil {
return h.UnimplementedHostData.Repositories()
}
return repositoryReader{host: h}
}
func (h *pluginHost) Messages() pluginsdk.MessageReader {
if !h.capabilities.CanRead(resourceMessages) {
return deniedMessageReader{}
}
if h.messageData == nil {
return h.UnimplementedHostData.Messages()
}
return messageReader{host: h}
}
// ── Denied readers ──────────────────────────────────────────────────────
type deniedTaskReader struct{}
func (deniedTaskReader) List(context.Context, pluginsdk.TaskFilter, pluginsdk.Page) ([]pluginsdk.Task, *pluginsdk.PageInfo, error) {
return nil, nil, permissionDenied(apiReadCapability(resourceTasks))
}
func (deniedTaskReader) Get(context.Context, string) (*pluginsdk.Task, error) {
return nil, permissionDenied(apiReadCapability(resourceTasks))
}
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 deniedRepositoryReader struct{}
func (deniedRepositoryReader) List(context.Context, string, pluginsdk.Page) ([]pluginsdk.Repository, *pluginsdk.PageInfo, error) {
return nil, nil, permissionDenied(apiReadCapability(resourceRepositories))
}
type deniedMessageReader struct{}
func (deniedMessageReader) List(context.Context, pluginsdk.MessageFilter, pluginsdk.Page) ([]pluginsdk.Message, *pluginsdk.PageInfo, error) {
return nil, nil, permissionDenied(apiReadCapability(resourceMessages))
}
// ── 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 }
func (r taskReader) List(ctx context.Context, filter pluginsdk.TaskFilter, page pluginsdk.Page) ([]pluginsdk.Task, *pluginsdk.PageInfo, error) {
workspaceIDs, err := r.host.resolveWorkspaceIDs(ctx, filter.WorkspaceIDs)
if err != nil {
return nil, nil, err
}
tasks, err := r.host.fetchTasksForWorkspaces(ctx, workspaceIDs, filter.IncludeEphemeral, false)
if err != nil {
return nil, nil, err
}
tasks = filterTasks(tasks, filter)
sortTasksNewestFirst(tasks)
items, info := paginate(tasksToDTOs(tasks), page)
return items, info, nil
}
// 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) {
task, err := r.host.taskData.GetTask(ctx, id)
if err != nil {
if errors.Is(err, repoerrors.ErrTaskNotFound) {
return nil, taskNotFound(id)
}
return nil, err
}
dto := taskModelToDTO(task)
return &dto, 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 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) {
// 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
}
// ── 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
})
}
// sessionToDTO maps a TaskSession to the Go-native Session DTO, resolving
// ACPSessionID via resolveACPSessionID's metadata-then-executors_running
// fallback.
func (h *pluginHost) sessionToDTO(ctx context.Context, s *taskmodels.TaskSession) pluginsdk.Session {
return pluginsdk.Session{
ID: s.ID,
TaskID: s.TaskID,
AgentProfileID: s.AgentProfileID,
AgentDisplayName: sessionSnapshotString(s.AgentProfileSnapshot, "agent_display_name"),
AgentProfileName: sessionSnapshotString(s.AgentProfileSnapshot, "name"),
Model: sessionSnapshotModel(s.AgentProfileSnapshot),
ACPSessionID: h.resolveACPSessionID(ctx, s),
State: string(s.State),
StartedAt: s.StartedAt.UTC().Format(time.RFC3339),
EndedAt: timePtrToRFC3339(s.CompletedAt),
}
}
// resolveACPSessionID replicates the source agent-stats plugin's join key
// (docs/decisions/0043-plugin-host-data-api.md, "A real plugin exposed the
// gap"): the agent CLI's own session UUID at
// TaskSession.Metadata["acp"]["session_id"], populated once the agent emits
// a session_info frame. executors_running.resume_token carries the same id
// and survives on sessions that never got that far, so it is a best-effort
// fallback — a lookup failure (including "no such row") is silently treated
// as "no id available" rather than failing the whole read.
func (h *pluginHost) resolveACPSessionID(ctx context.Context, s *taskmodels.TaskSession) string {
if id := acpSessionIDFromMetadata(s.Metadata); id != "" {
return id
}
running, err := h.taskData.GetExecutorRunningBySessionID(ctx, s.ID)
if err != nil || running == nil {
return ""
}
return running.ResumeToken
}
func acpSessionIDFromMetadata(metadata map[string]any) string {
acp, ok := metadata["acp"].(map[string]any)
if !ok {
return ""
}
id, _ := acp["session_id"].(string)
return id
}
func sessionSnapshotString(snapshot map[string]any, key string) string {
if snapshot == nil {
return ""
}
v, _ := snapshot[key].(string)
return v
}
// sessionSnapshotModel mirrors the source plugin's fallback chain for the
// agent-profile snapshot's model field, which has varied key names across
// agent types over time.
func sessionSnapshotModel(snapshot map[string]any) string {
for _, key := range []string{"model", "model_name", "llm"} {
if v := sessionSnapshotString(snapshot, key); v != "" {
return v
}
}
return ""
}
func timePtrToRFC3339(t *time.Time) *string {
if t == nil {
return nil
}
s := t.UTC().Format(time.RFC3339)
return &s
}
func stringPtrOrNil(s string) *string {
if s == "" {
return nil
}
return &s
}
// ── Internal model → pluginsdk DTO mapping ──────────────────────────────
func taskModelToDTO(t *taskmodels.Task) pluginsdk.Task {
repos := make([]pluginsdk.TaskRepository, len(t.Repositories))
for i, r := range t.Repositories {
repos[i] = pluginsdk.TaskRepository{
ID: r.ID,
RepositoryID: r.RepositoryID,
BaseBranch: r.BaseBranch,
Position: int32(r.Position),
}
}
return pluginsdk.Task{
ID: t.ID,
WorkspaceID: t.WorkspaceID,
WorkflowID: t.WorkflowID,
Title: t.Title,
Description: t.Description,
State: string(t.State),
Priority: t.Priority,
// CreatedBy: kandev's Task model has no creating-user column — Origin
// ("manual"/"agent_created"/"routine"/"automation_run") is the
// closest analogue and is what this surfaces.
CreatedBy: t.Origin,
CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.UTC().Format(time.RFC3339),
// StartedAt/CompletedAt: the Task model has no started_at/completed_at
// columns (ArchivedAt is a different concept); left nil in v1.
ParentID: stringPtrOrNil(t.ParentID),
Identifier: t.Identifier,
IsEphemeral: t.IsEphemeral,
Repositories: repos,
Metadata: t.Metadata,
}
}
func workspaceModelToDTO(w *taskmodels.Workspace) pluginsdk.Workspace {
return pluginsdk.Workspace{
ID: w.ID,
Name: w.Name,
Description: stringPtrOrNil(w.Description),
OwnerID: w.OwnerID,
DefaultExecutorID: w.DefaultExecutorID,
DefaultAgentProfileID: w.DefaultAgentProfileID,
CreatedAt: w.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: w.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func workflowModelToDTO(w *taskmodels.Workflow) pluginsdk.Workflow {
return pluginsdk.Workflow{
ID: w.ID,
WorkspaceID: w.WorkspaceID,
Name: w.Name,
Description: stringPtrOrNil(w.Description),
SortOrder: int32(w.SortOrder),
CreatedAt: w.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: w.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func workflowStepModelToDTO(s *wfmodels.WorkflowStep) pluginsdk.WorkflowStep {
return pluginsdk.WorkflowStep{
ID: s.ID,
WorkflowID: s.WorkflowID,
Name: s.Name,
Position: int32(s.Position),
StageType: string(s.StageType),
}
}
func repositoryModelToDTO(r *taskmodels.Repository) pluginsdk.Repository {
return pluginsdk.Repository{
ID: r.ID,
WorkspaceID: r.WorkspaceID,
Name: r.Name,
DefaultBranch: stringPtrOrNil(r.DefaultBranch),
}
}
func agentProfileDTOToSDK(p agentsettingsdto.AgentProfileDTO) pluginsdk.AgentProfile {
return pluginsdk.AgentProfile{
ID: p.ID,
AgentID: p.AgentID,
DisplayName: p.AgentDisplayName,
Name: p.Name,
Model: p.Model,
Mode: p.Mode,
}
}
// messageModelToDTO maps a stored message to the Go-native Message DTO. It
// strips kandev-injected <kandev-system> blocks from content via
// sysprompt.StripSystemContent — the same sanitization the message.added bus
// event applies — so a plugin never sees raw system prompts. Type defaults to
// "message" (matching Message.ToAPI and the repository) when empty.
func messageModelToDTO(m *taskmodels.Message) pluginsdk.Message {
msgType := string(m.Type)
if msgType == "" {
msgType = string(taskmodels.MessageTypeMessage)
}
return pluginsdk.Message{
ID: m.ID,
SessionID: m.TaskSessionID,
TaskID: m.TaskID,
TurnID: m.TurnID,
AuthorType: string(m.AuthorType),
Content: sysprompt.StripSystemContent(m.Content),
Type: msgType,
CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
}
}
func sessionCodeStatsModelToDTO(s *analyticsmodels.SessionCodeStats) pluginsdk.SessionCodeStats {
return pluginsdk.SessionCodeStats{
SessionID: s.SessionID,
LinesAddedCommitted: s.LinesAddedCommitted,
LinesDeletedCommitted: s.LinesDeletedCommitted,
LinesAddedPeakPending: s.LinesAddedPeakPending,
LinesDeletedPeakPending: s.LinesDeletedPeakPending,
}
}