Files
kandev/apps/backend/internal/backendapp/services.go
T
João Salavisa 0df2bbac0a feat(plugins): typed capability-gated Host data API (ADR 0043) (#1753)
* docs(plugins): draft Host data API proto (read + write DTOs)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plugins): ADR + spec + plan for the Host data API

Plugins access kandev data via capability-gated Host gRPC RPCs returning
hand-mapped DTOs, never the DB directly. ADR 0042.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugins): add Host data API RPCs and DTOs to plugin proto

Extends the existing service Host (per ADR 0042) with read RPCs
(ListTasks/GetTask, ListWorkspaces, ListWorkflows, ListWorkflowSteps,
ListAgentProfiles, ListRepositories, ListSessions, ListSessionCodeStats)
and declares the deferred phase-2 write RPCs (CreateTask, UpdateTask,
CreateComment) so the contract is stable for later tasks. Regenerates
plugin.pb.go/plugin_grpc.pb.go via `make -C apps/backend proto`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugins): Host data API SDK accessors + session LOC aggregation

Wave 1-2 of ADR 0042: pkg/pluginsdk gains typed reader accessors (host.Tasks(),
host.Sessions().CodeStats(), host.Workflows(), ...) over the extended Host gRPC
service, with proto<->native DTO conversions and an UnimplementedHostData seam
for the kandev-side impl. internal/analytics gains the per-session LOC
aggregation those readers surface. Backend handlers + capability gating land in
the next wave.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugins): capability-gated Host data read handlers (ADR 0042)

Implement pluginHost Tasks/Sessions/Workspaces/Workflows/AgentProfiles/
Repositories readers: each gated on api_read:<resource> (PermissionDenied
otherwise), backed by the service layer via narrow injected interfaces, and
hand-mapped to the pluginsdk DTOs. Sessions().CodeStats routes through the
analytics aggregation; acp_session_id preserves the metadata->resume_token
fallback. Wired through plugins.Service.SetDataSources + backendapp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(plugins): wire test for Host data reads + docs (ADR 0042 accepted)

End-to-end test over the real go-plugin transport: session data round-trips,
per-resource api_read gating returns PermissionDenied over the wire, cursor
pagination. Document the Host data API + SDK accessors in GRPC-CONTRACT.md,
reconcile the spec to shipped code, mark ADR 0042 accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugins): full session history + snapshot profile name in data API

Sessions().List now fetches tasks with includeArchived=true — an archived
task's sessions are still real sessions, and CodeStats already made the
same call at the SQL layer, so the two session reads were inconsistent
(26 of 359 sessions visible on a real instance). Tasks().List keeps
archived tasks excluded.

Session gains agent_profile_name (proto field 10) sourced from the
profile snapshot, so sessions of since-uninstalled agents keep a human
label — the AgentProfiles registry only lists live agents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plugins): renumber Host data API ADR 0042 -> 0043

main merged its own ADR 0042 (project shell output); renumber this one to the
next free slot and update all references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address Host data API review (perf, pagination, filters, proto)

- paginate before DTO conversion so per-session ACP lookups run <=limit times
- fetch all source pages (no silent 1000/workspace truncation); accurate HasMore
- stable id tiebreaker on paginated sorts
- AND SessionFilter.TaskIDs with WorkspaceIDs; exclude config-mode sessions from
  CodeStats (shared dialect.ExcludeConfigModePredicate) so List/CodeStats match
- Get returns codes.NotFound consistently in-process and over the wire
- wrap bare-DTO RPC responses (GetTaskResponse etc.) for Buf response uniqueness

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plugins): mark Host data API plan tasks done

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:43:58 +01:00

619 lines
23 KiB
Go

package backendapp
import (
"context"
"fmt"
"path/filepath"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/discovery"
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/registry"
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
agentusage "github.com/kandev/kandev/internal/agent/usage"
agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility"
analyticsservice "github.com/kandev/kandev/internal/analytics/service"
"github.com/kandev/kandev/internal/automation"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
editorservice "github.com/kandev/kandev/internal/editors/service"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/gitlab"
"github.com/kandev/kandev/internal/integrations/secretadapter"
"github.com/kandev/kandev/internal/jira"
"github.com/kandev/kandev/internal/linear"
"github.com/kandev/kandev/internal/plugins"
promptservice "github.com/kandev/kandev/internal/prompts/service"
"github.com/kandev/kandev/internal/secrets"
"github.com/kandev/kandev/internal/sentry"
"github.com/kandev/kandev/internal/slack"
systemsettings "github.com/kandev/kandev/internal/system/settings"
taskmodels "github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/task/share"
userservice "github.com/kandev/kandev/internal/user/service"
utilityservice "github.com/kandev/kandev/internal/utility/service"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
workflowservice "github.com/kandev/kandev/internal/workflow/service"
"github.com/kandev/kandev/internal/workflowsync"
)
func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, dbPool *db.Pool, eventBus bus.EventBus, agentRegistry *registry.Registry, version string) (*Services, *agentsettingscontroller.Controller, error) {
// Load custom TUI agents from DB into registry before discovery
loadCustomTUIAgents(context.Background(), repos, agentRegistry, log)
discoveryRegistry, err := discovery.LoadRegistry(context.Background(), agentRegistry, log)
if err != nil {
return nil, nil, err
}
agentSettingsController := agentsettingscontroller.NewController(repos.AgentSettings, discoveryRegistry, agentRegistry, repos.Task, log)
agentSettingsController.SetHostUsageLister(agentusage.NewHostService(log))
userSvc := userservice.NewService(repos.User, eventBus, log)
editorSvc := editorservice.NewService(repos.Editor, repos.Task, userSvc)
promptSvc := promptservice.NewService(repos.Prompts)
utilitySvc := utilityservice.NewService(repos.Utility)
workflowSvc := workflowservice.NewService(repos.Workflow, log)
taskSvc := taskservice.NewService(
taskservice.Repos{
Workspaces: repos.Task,
Tasks: repos.Task,
TaskRepos: repos.Task,
Workflows: repos.Task,
Messages: repos.Task,
Turns: repos.Task,
Sessions: repos.Task,
GitSnapshots: repos.Task,
RepoEntities: repos.Task,
Executors: repos.Task,
Environments: repos.Task,
TaskEnvironments: repos.Task,
Reviews: repos.Task,
},
eventBus,
log,
taskservice.RepositoryDiscoveryConfig{
Roots: cfg.RepositoryDiscovery.Roots,
MaxDepth: cfg.RepositoryDiscovery.MaxDepth,
TaskWorktreeRoots: []string{filepath.Join(cfg.ResolvedHomeDir(), "tasks")},
},
)
// Wire workflow step creator to task service for board creation
taskSvc.SetWorkflowStepCreator(workflowSvc)
// Wire workflow step getter to task service for MoveTask
taskSvc.SetWorkflowStepGetter(&workflowStepGetterAdapter{svc: workflowSvc})
// Wire start step resolver to task service for CreateTask
taskSvc.SetStartStepResolver(&startStepResolverAdapter{svc: workflowSvc})
// Wire workflow provider to workflow service for export/import
workflowSvc.SetWorkflowProvider(&workflowProviderAdapter{svc: taskSvc})
// Wire agent profile resolver/matcher for workflow export/import
workflowSvc.SetAgentProfileFuncs(
buildAgentProfileResolver(repos),
buildAgentProfileMatcher(repos),
)
githubSvc := initGitHubService(dbPool, eventBus, repos.Secrets, log)
if githubSvc != nil {
githubSvc.SetPromptResolver(promptSvc)
}
gitlabSvc := initGitLabService(dbPool, eventBus, repos.Secrets, log)
jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log)
linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log)
sentrySvc := initSentryService(dbPool, eventBus, repos.Secrets, log)
slackSvc := initSlackService(dbPool, repos.Secrets, log)
workflowSyncSvc := initWorkflowSyncService(dbPool, githubSvc, workflowSvc, taskSvc, log)
pluginsSvc := initPluginsService(cfg, dbPool, eventBus, repos.Secrets, log)
if pluginsSvc != nil {
pluginsSvc.SetDataSources(taskSvc, taskSvc, workflowSvc, agentSettingsController, analyticsservice.New(repos.Analytics))
}
shareHTTP := initShareHandlers(dbPool, repos.Task, githubSvc, log, version)
// Plumb GitHub branch listing into the task service so provider-backed
// ("Remote") repos serve branches from the GitHub API rather than relying
// on a local clone that may not exist yet (or ever - some executors clone
// inside their own container).
if githubSvc != nil {
taskSvc.SetRemoteBranchLister(githubBranchListerAdapter{svc: githubSvc})
taskSvc.SetPRTaskResolver(githubSvc)
}
// Initialize Automation service
automationComponents, automationErr := automation.Provide(dbPool.Writer(), dbPool.Reader(), eventBus, githubSvc, log)
if automationErr != nil {
log.Warn("Automation service initialization failed (non-fatal)", zap.Error(automationErr))
}
if automationComponents != nil {
automationComponents.Service.SetTaskDeleter(&automationTaskDeleterAdapter{svc: taskSvc})
}
return &Services{
Task: taskSvc,
User: userSvc,
Editor: editorSvc,
Prompts: promptSvc,
Utility: utilitySvc,
Workflow: workflowSvc,
GitHub: githubSvc,
GitLab: gitlabSvc,
Jira: jiraSvc,
Linear: linearSvc,
Sentry: sentrySvc,
Slack: slackSvc,
WorkflowSync: workflowSyncSvc,
Share: shareHTTP,
Automation: automationComponents,
Plugins: pluginsSvc,
// Office is constructed later in initOfficeServices once all
// of its dependencies (config loader, task integrations, etc.) are available.
Office: nil,
// Notification service is initialized after gateway is available.
Notification: nil,
}, agentSettingsController, nil
}
// loadCustomTUIAgents loads user-defined TUI agents from the database into the registry.
// Non-fatal: logs warnings but continues if any individual agent fails.
func loadCustomTUIAgents(ctx context.Context, repos *Repositories, agentRegistry *registry.Registry, log *logger.Logger) {
tuiAgents, err := repos.AgentSettings.ListTUIAgents(ctx)
if err != nil {
log.Warn("failed to load custom TUI agents from database", zap.Error(err))
return
}
for _, agent := range tuiAgents {
if agent.TUIConfig == nil {
continue
}
cfg := agent.TUIConfig
if regErr := agentRegistry.RegisterCustomTUIAgent(
agent.Name, cfg.DisplayName, cfg.Command, cfg.Description, cfg.Model, cfg.CommandArgs,
); regErr != nil {
log.Warn("failed to register custom TUI agent",
zap.String("name", agent.Name), zap.Error(regErr))
}
}
}
// workflowStepGetterAdapter adapts workflow service to task service's WorkflowStepGetter interface.
// Since task service now uses wfmodels.WorkflowStep directly, the adapter simply delegates to the service.
type workflowStepGetterAdapter struct {
svc *workflowservice.Service
}
// GetStep implements taskservice.WorkflowStepGetter.
func (a *workflowStepGetterAdapter) GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error) {
return a.svc.GetStep(ctx, stepID)
}
// GetNextStepByPosition implements taskservice.WorkflowStepGetter.
func (a *workflowStepGetterAdapter) GetNextStepByPosition(ctx context.Context, boardID string, currentPosition int) (*wfmodels.WorkflowStep, error) {
return a.svc.GetNextStepByPosition(ctx, boardID, currentPosition)
}
// startStepResolverAdapter adapts workflow service to task service's StartStepResolver interface.
type startStepResolverAdapter struct {
svc *workflowservice.Service
}
// ResolveStartStep implements taskservice.StartStepResolver.
func (a *startStepResolverAdapter) ResolveStartStep(ctx context.Context, workflowID string) (string, error) {
step, err := a.svc.ResolveStartStep(ctx, workflowID)
if err != nil {
return "", err
}
return step.ID, nil
}
// ResolveFirstStep implements taskservice.StartStepResolver.
func (a *startStepResolverAdapter) ResolveFirstStep(ctx context.Context, workflowID string) (string, error) {
step, err := a.svc.ResolveFirstStep(ctx, workflowID)
if err != nil {
return "", err
}
return step.ID, nil
}
// githubSecretAdapter adapts secrets.SecretStore to github.SecretProvider and github.SecretManager.
type githubSecretAdapter struct {
store secrets.SecretStore
}
func (a *githubSecretAdapter) List(ctx context.Context) ([]*github.SecretListItem, error) {
items, err := a.store.List(ctx)
if err != nil {
return nil, err
}
result := make([]*github.SecretListItem, len(items))
for i, item := range items {
result[i] = &github.SecretListItem{
ID: item.ID,
Name: item.Name,
HasValue: item.HasValue,
}
}
return result, nil
}
func (a *githubSecretAdapter) Reveal(ctx context.Context, id string) (string, error) {
return a.store.Reveal(ctx, id)
}
// Create creates a new secret with the given name and value.
func (a *githubSecretAdapter) Create(ctx context.Context, name, value string) (string, error) {
secret := &secrets.SecretWithValue{
Secret: secrets.Secret{Name: name},
Value: value,
}
if err := a.store.Create(ctx, secret); err != nil {
return "", err
}
return secret.ID, nil
}
// Update updates an existing secret's value.
func (a *githubSecretAdapter) Update(ctx context.Context, id, value string) error {
return a.store.Update(ctx, id, &secrets.UpdateSecretRequest{Value: &value})
}
// Delete removes a secret by ID.
func (a *githubSecretAdapter) Delete(ctx context.Context, id string) error {
return a.store.Delete(ctx, id)
}
// initGitHubService wires up the GitHub integration. Failures are non-fatal:
// the rest of the backend still boots without GitHub configured.
func initGitHubService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *github.Service {
adapter := &githubSecretAdapter{store: secretsStore}
svc, _, err := github.Provide(dbPool.Writer(), dbPool.Reader(), adapter, eventBus, log)
if err != nil {
log.Warn("GitHub service initialization failed (non-fatal)", zap.Error(err))
}
if svc != nil {
// GitHub takes both a SecretProvider (read-only) and a SecretManager
// (mutating) — same adapter satisfies both interfaces, but the
// service needs the mutating one wired explicitly.
svc.SetSecretManager(adapter)
}
return svc
}
// gitlabSecretAdapter adapts secrets.SecretStore to the GitLab integration's
// SecretProvider and SecretManager interfaces. Mirrors githubSecretAdapter
// — kept separate so the two packages can evolve independently.
type gitlabSecretAdapter struct {
store secrets.SecretStore
}
func (a *gitlabSecretAdapter) List(ctx context.Context) ([]*gitlab.SecretListItem, error) {
items, err := a.store.List(ctx)
if err != nil {
return nil, err
}
result := make([]*gitlab.SecretListItem, len(items))
for i, item := range items {
result[i] = &gitlab.SecretListItem{
ID: item.ID,
Name: item.Name,
HasValue: item.HasValue,
}
}
return result, nil
}
func (a *gitlabSecretAdapter) Reveal(ctx context.Context, id string) (string, error) {
return a.store.Reveal(ctx, id)
}
func (a *gitlabSecretAdapter) Create(ctx context.Context, name, value string) (string, error) {
secret := &secrets.SecretWithValue{
Secret: secrets.Secret{Name: name},
Value: value,
}
if err := a.store.Create(ctx, secret); err != nil {
return "", err
}
return secret.ID, nil
}
func (a *gitlabSecretAdapter) Update(ctx context.Context, id, value string) error {
return a.store.Update(ctx, id, &secrets.UpdateSecretRequest{Value: &value})
}
func (a *gitlabSecretAdapter) Delete(ctx context.Context, id string) error {
return a.store.Delete(ctx, id)
}
const gitlabHostSettingKey = "gitlab_host"
type gitlabHostStore struct {
settings *systemsettings.Store
}
func newGitLabHostStore(dbPool *db.Pool) (gitlab.HostStore, error) {
settingsStore, err := systemsettings.NewStore(dbPool)
if err != nil {
return nil, err
}
return &gitlabHostStore{settings: settingsStore}, nil
}
func (s *gitlabHostStore) GetHost(ctx context.Context) (string, error) {
raw, found, err := s.settings.Get(ctx, gitlabHostSettingKey)
if err != nil || !found {
return "", err
}
return string(raw), nil
}
func (s *gitlabHostStore) SetHost(ctx context.Context, host string) error {
return s.settings.Save(ctx, gitlabHostSettingKey, []byte(host))
}
// initGitLabService wires up the GitLab integration. Failures are non-fatal:
// the rest of the backend still boots without GitLab configured.
func initGitLabService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *gitlab.Service {
adapter := &gitlabSecretAdapter{store: secretsStore}
hostStore, hostStoreErr := newGitLabHostStore(dbPool)
if hostStoreErr != nil {
log.Warn("GitLab host store unavailable (non-fatal)", zap.Error(hostStoreErr))
return nil
}
svc, _, err := gitlab.Provide(context.Background(), adapter, hostStore, log)
if err != nil {
log.Warn("GitLab service initialization failed (non-fatal)", zap.Error(err))
}
if svc != nil {
svc.SetSecretManager(adapter)
svc.SetEventBus(eventBus)
if store, storeErr := gitlab.NewStore(dbPool.Writer(), dbPool.Reader()); storeErr == nil {
svc.SetStore(store)
} else {
log.Warn("GitLab task-mr store unavailable (non-fatal)", zap.Error(storeErr))
}
}
return svc
}
// initJiraService wires up the Jira integration. Failures are non-fatal.
func initJiraService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *jira.Service {
svc, _, err := jira.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log)
if err != nil {
log.Warn("JIRA service initialization failed (non-fatal)", zap.Error(err))
}
return svc
}
// initWorkflowSyncService wires the GitHub workflow-sync service. Failures
// are non-fatal; the service is nil when GitHub is unavailable.
func initWorkflowSyncService(dbPool *db.Pool, githubSvc *github.Service, workflowSvc *workflowservice.Service, taskSvc *taskservice.Service, log *logger.Logger) *workflowsync.Service {
if githubSvc == nil {
log.Warn("workflow sync disabled: GitHub service unavailable")
return nil
}
workflowSvc.SetSyncWorkflowOps(taskSvc)
svc, _, err := workflowsync.Provide(dbPool.Writer(), dbPool.Reader(), githubSvc, workflowSvc, log)
if err != nil {
log.Warn("workflow sync service initialization failed (non-fatal)", zap.Error(err))
return nil
}
return svc
}
// initLinearService wires up the Linear integration. Failures are non-fatal.
func initLinearService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *linear.Service {
svc, _, err := linear.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log)
if err != nil {
log.Warn("Linear service initialization failed (non-fatal)", zap.Error(err))
}
return svc
}
// initSentryService wires up the Sentry integration. Failures are non-fatal.
func initSentryService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *sentry.Service {
svc, _, err := sentry.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log)
if err != nil {
log.Warn("Sentry service initialization failed (non-fatal)", zap.Error(err))
}
return svc
}
// initShareHandlers wires up the public-share-links HTTP surface. Failures
// are non-fatal: the rest of the backend boots without the share endpoints.
// The github.Client may be nil; CreateShare will fail at the IsAuthenticated
// probe with a 412 in that case.
func initShareHandlers(
dbPool *db.Pool,
taskRepo share.TaskReader,
githubSvc *github.Service,
log *logger.Logger,
version string,
) *share.HTTPHandlers {
var ghClient github.Client
if githubSvc != nil {
ghClient = githubSvc.Client()
}
if ghClient == nil {
ghClient = &github.NoopClient{}
}
h, _, err := share.Provide(dbPool.Writer(), dbPool.Reader(), taskRepo, ghClient, log, share.Config{KandevVersion: version})
if err != nil {
log.Warn("Share handlers initialization failed (non-fatal)", zap.Error(err))
return nil
}
return h
}
// initSlackService wires up the Slack integration. Failures are non-fatal.
// The agent runner is wired post-construction by main.go once hostutility +
// utility services exist.
func initSlackService(dbPool *db.Pool, secretsStore secrets.SecretStore, log *logger.Logger) *slack.Service {
svc, _, err := slack.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), log)
if err != nil {
log.Warn("Slack service initialization failed (non-fatal)", zap.Error(err))
}
return svc
}
// initPluginsService wires up the plugin system's core Service
// (registration registry, config, plugin_state store). Failures are
// non-fatal: the rest of the backend still boots without plugins.
//
// This only constructs the Service — event delivery (delivery.Deliverer)
// and health monitoring (plugins.HealthMonitor) are wired separately by
// startPluginsSubsystems (plugins.go), once addCleanup and ctx are
// available, mirroring how the Jira/Linear/Sentry pollers are started in
// startAgentInfrastructure rather than inside their init*Service functions.
func initPluginsService(cfg *config.Config, dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *plugins.Service {
svc, _, err := plugins.Provide(cfg, dbPool, secretadapter.New(secretsStore), eventBus, log)
if err != nil {
log.Warn("Plugins service initialization failed (non-fatal)", zap.Error(err))
return nil
}
return svc
}
// buildKandevMCPURL is the URL passed to the Slack triage agent for the
// Kandev MCP server. The MCP server is mounted on the same port as the rest
// of the backend's HTTP API; this just centralises the path so it stays in
// sync with internal/mcp/server's mount point ("/mcp").
func buildKandevMCPURL(port int) string {
if port == 0 {
port = portsBackendDefault
}
return fmt.Sprintf("http://localhost:%d/mcp", port)
}
// portsBackendDefault is the default backend HTTP port. We don't import
// internal/common/ports here to avoid pulling its transitive deps into
// services.go's import graph; the value is duplicated only as a fallback for
// when cfg.Server.Port is left at zero (which shouldn't happen in practice).
const portsBackendDefault = 38429
// slackHostUtilityAdapter adapts *hostutility.Manager to slack.HostUtilityRunner.
// The slack package can't import hostutility without a transitive cycle (it
// would need to import agentctl + lifecycle), so we shim through the agentctl
// utility DTO here in the cmd package where both are already imported.
type slackHostUtilityAdapter struct {
mgr *hostutility.Manager
}
func (a slackHostUtilityAdapter) ExecutePromptWithMCP(
ctx context.Context,
agentType, model, mode, prompt string,
mcpServers []agentctlutil.MCPServerDTO,
) (slack.HostPromptResult, error) {
res, err := a.mgr.ExecutePromptWithMCP(ctx, agentType, model, mode, prompt, mcpServers)
if err != nil {
return slack.HostPromptResult{}, err
}
return slack.HostPromptResult{
Response: res.Response,
Model: res.Model,
PromptTokens: res.PromptTokens,
ResponseTokens: res.ResponseTokens,
DurationMs: res.DurationMs,
}, nil
}
// workflowProviderAdapter adapts task service to workflow service's WorkflowProvider interface.
type workflowProviderAdapter struct {
svc *taskservice.Service
}
// ListWorkflows implements workflowservice.WorkflowProvider.
func (a *workflowProviderAdapter) ListWorkflows(ctx context.Context, workspaceID string, includeHidden bool) ([]*taskmodels.Workflow, error) {
return a.svc.ListWorkflows(ctx, workspaceID, includeHidden)
}
// GetWorkflow implements workflowservice.WorkflowProvider.
func (a *workflowProviderAdapter) GetWorkflow(ctx context.Context, id string) (*taskmodels.Workflow, error) {
return a.svc.GetWorkflow(ctx, id)
}
// CreateWorkflow implements workflowservice.WorkflowProvider.
func (a *workflowProviderAdapter) CreateWorkflow(ctx context.Context, workspaceID, name, description string) (*taskmodels.Workflow, error) {
return a.svc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{
WorkspaceID: workspaceID,
Name: name,
Description: description,
})
}
// UpdateWorkflow implements workflowservice.WorkflowProvider.
func (a *workflowProviderAdapter) UpdateWorkflow(ctx context.Context, workflow *taskmodels.Workflow) error {
_, err := a.svc.UpdateWorkflow(ctx, workflow.ID, &taskservice.UpdateWorkflowRequest{
Name: &workflow.Name,
Description: &workflow.Description,
AgentProfileID: &workflow.AgentProfileID,
})
return err
}
// buildAgentProfileResolver creates a resolver that converts profile IDs to portable form for export.
func buildAgentProfileResolver(repos *Repositories) wfmodels.AgentProfileResolver {
return func(profileID string) *wfmodels.AgentProfilePortable {
if profileID == "" {
return nil
}
profile, err := repos.AgentSettings.GetAgentProfile(context.Background(), profileID)
if err != nil || profile == nil {
return nil
}
return &wfmodels.AgentProfilePortable{
AgentName: profile.AgentDisplayName,
Model: profile.Model,
Mode: profile.Mode,
}
}
}
// buildAgentProfileMatcher creates a matcher that finds profiles by agent name, model, and mode for import.
func buildAgentProfileMatcher(repos *Repositories) wfmodels.AgentProfileMatcher {
return func(agentName, model, mode string) string {
agents, err := repos.AgentSettings.ListAgents(context.Background())
if err != nil {
return ""
}
for _, agent := range agents {
profiles, pErr := repos.AgentSettings.ListAgentProfiles(context.Background(), agent.ID)
if pErr != nil {
continue
}
for _, p := range profiles {
if p.AgentDisplayName == agentName && p.Model == model && p.Mode == mode {
return p.ID
}
}
}
return ""
}
}
// githubBranchListerAdapter bridges github.Service to the task service's
// RemoteBranchLister interface. It maps github.RepoBranch into the task
// service's Branch shape with Type="remote" so the dialog renders branches
// the same way URL-mode does - bare names without an "origin/" prefix, since
// there is no checked-out clone whose tracking config could disambiguate.
type githubBranchListerAdapter struct {
svc *github.Service
}
func (a githubBranchListerAdapter) ListRepoBranches(ctx context.Context, owner, repo string) ([]taskservice.Branch, error) {
remote, err := a.svc.ListRepoBranches(ctx, owner, repo)
if err != nil {
return nil, err
}
out := make([]taskservice.Branch, 0, len(remote))
for _, b := range remote {
out = append(out, taskservice.Branch{Name: b.Name, Type: "remote"})
}
return out, nil
}