Files
kandev/apps/backend/internal/plugins/host_data.go
T
João Salavisa 279e15b318 feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/SendMessage) (#1970)
* feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/CreateComment)

The api_write manifest capability was a stored no-op. Wire the deferred
phase-2 write RPCs (ADR 0043) so closed-loop plugins (Sentry->task,
Slack slash-command task creation, refactor fan-out) can create and
update tasks and post comments.

- SDK: Host.Tasks() gains Create/Update; new Host.Comments() writer;
  CreateTaskInput/UpdateTaskInput/Comment DTOs (pkg/pluginsdk).
- Gating: api_write:tasks gates Create/UpdateTask, api_write:comments
  gates CreateComment; undeclared -> gRPC PermissionDenied, per-method
  and independent of the read caps on the same resource.
- Writes route through the first-party service layer (task service for
  tasks, office service for comments) via backendapp adapters, never a
  repository -- so task.*/comment-created events fire and WS updates.
  Server stamps source="plugin:<id>"; CreateTask resolves workspace/
  workflow defaults and best-effort honors start_agent; UpdateTask uses
  a conservative title/description/state/workflow_step_id mask.
- Fixture plugins exercise a CreateTask+CreateComment round-trip;
  in-process, real-broker wire, and subprocess tests cover the round-trip
  and per-capability denial.
- Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments moved
  writes deferred -> implemented; ADR 0043 amended with the concrete
  service-layer routing decision.

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

* feat(plugins): send a message to a task session via Host SendMessage (replaces CreateComment)

The office TaskComment write was the wrong primitive — it's an office/
dashboard construct, not a way to message the agent working a task, and
it only did anything under office reactivity. Replace the CreateComment
write RPC with SendMessage, which delivers a prompt to a task session
through the orchestrator's real delivery path (the same one message_task
uses).

- Proto: swap CreateComment/Comment for SendMessage(SendMessageRequest)
  returns SendMessageResponse{session_id, status}; regenerated stubs.
- SDK: Host.Comments() removed; Host.Messages().Send(taskID, sessionID,
  text) added (pairs with Messages().List), returning MessageDispatch.
- Gating: api_write:comments dropped; SendMessage gated api_write:messages
  (independent of api_read:messages on the same accessor, like Tasks()).
- Delivery: a new pluginsTaskMessengerAdapter (backendapp) resolves the
  target session (explicit id verified against the task, or the primary
  session), records a user message stamped source="plugin:<id>", then
  dispatches by state — running → queue; idle/completed → prompt (resume
  if the agent process is gone); never-started → launch; failed dispatch
  deletes the recorded message. Wired via SetWriteDeps (live-read).
- Tests: adapter state-machine unit tests (queue/start/prompt/resume/
  delete-on-failure/belongs-to-task/no-primary/terminal); SDK, in-process
  gating, wire, and subprocess round-trips updated to SendMessage.
- Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments, and the
  ADR 0043 amendment updated from comment-create to message-send.

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

* fix(plugins): address PR review — nil guards, detached contexts, state validation

Follow-up fixes from the #1970 review (Claude + CodeRabbit bots):

- grpcHostServer.CreateTask/UpdateTask/SendMessage: nil-guard the returned
  value before toProto() so a custom/test Host returning (nil, nil) yields a
  gRPC Internal error, not a server panic (matches GetTask's pattern).
- host.go: fix stale server doc comment (Comments()/CreateComment →
  Messages()/SendMessage).
- startTaskBestEffort and the post-resume wait+retry now run on
  context.WithoutCancel(ctx): the task/resume is already committed, so a
  plugin's incoming deadline must not silently abort the launch/resume.
- pluginsTaskWriterAdapter.UpdateTask validates the state string against the
  known task-state enum (excluding orchestrator-owned SCHEDULING) and returns
  InvalidArgument, so a plugin typo can't forward a bogus state to the service.
- Tests: add the resume-wait-timeout path (message deleted so a retry can't
  duplicate), and a dedicated pluginsTaskWriterAdapter test covering
  source→metadata mapping and state validation (both adapters now depend on
  narrow interfaces for testability).

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

* fix(plugins): launch start_agent asynchronously so CreateTask doesn't block

Review nit follow-up: startTaskBestEffort called StartTask synchronously, so
with the detached context the plugin's CreateTask RPC blocked on the
orchestrator's full inline launch path (office lookup, state update, workflow
step move, task fetch) with no bound — and the "asynchronous" comment was wrong.

Move the fire-and-forget into pluginsTaskStarterAdapter (backendapp, no goleak),
mirroring MCP's launchAutoStartTask: the launch runs in a goroutine on a
context.WithTimeout(context.WithoutCancel(ctx), AgentLaunchTimeout), and
StartTask returns immediately. The plugin host stays a simple synchronous call
(its taskStarter contract now documents "returns promptly, launches async").

Adds adapters_plugin_starter_test.go: launch invoked, returns before the launch
completes, detaches from a cancelled caller context, and swallows launch errors.

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

* docs(plugins): document write validation + SendMessage rollback; harden starter test cleanup

Addresses the final PR #1970 review nits:
- adapters_plugin_starter_test.go: register the block-channel release via
  t.Cleanup (sync.Once-guarded) before the goroutine can block, per AGENTS.md.
- GRPC-CONTRACT.md: document CreateTask/UpdateTask validation + error rules
  (title required, id required, NotFound on missing task, invalid-state reject).
- spec.md: document that a failed SendMessage dispatch deletes the recorded
  message, plus the required-field/NotFound/terminal-session rules.

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-27 10:54:12 +01:00

998 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
}
// 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)
}
// 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.
// 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) 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 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 }
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)
}
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) {
if !r.host.capabilities.CanRead(resourceTasks) {
return nil, permissionDenied(apiReadCapability(resourceTasks))
}
if r.host.taskData == nil {
return r.host.UnimplementedHostData.Tasks().Get(ctx, id)
}
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) {
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
}
// ── 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,
}
}