78cae96bb1
* 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>
993 lines
36 KiB
Go
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,
|
|
}
|
|
}
|