package backendapp import ( "context" "crypto/sha1" "database/sql" "errors" "fmt" "github.com/kandev/kandev/internal/authz" "path/filepath" "strings" "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" automationpkg "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/gitref" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/delivery" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/gitcredentials" githubpkg "github.com/kandev/kandev/internal/github" jirapkg "github.com/kandev/kandev/internal/jira" linearpkg "github.com/kandev/kandev/internal/linear" officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" "github.com/kandev/kandev/internal/orchestrator" executorpkg "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/messagequeue" promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/secrets" sentrypkg "github.com/kandev/kandev/internal/sentry" "github.com/kandev/kandev/internal/system/queuesettings" systemsettings "github.com/kandev/kandev/internal/system/settings" taskmodels "github.com/kandev/kandev/internal/task/models" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" utilitymodels "github.com/kandev/kandev/internal/utility/models" utilityservice "github.com/kandev/kandev/internal/utility/service" wfmodels "github.com/kandev/kandev/internal/workflow/models" workflowservice "github.com/kandev/kandev/internal/workflow/service" ) const ( defaultMainBranch = "main" defaultMasterBranch = "master" ) const defaultEventNamespace = "default" func provideOrchestrator( cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus.EventBus, taskRepo *sqliterepo.Repository, taskSvc *taskservice.Service, userSvc *userservice.Service, lifecycleMgr *lifecycle.Manager, agentRegistry *registry.Registry, workflowSvc *workflowservice.Service, secretStore secrets.SecretStore, repoCloner *repoclone.Cloner, promptSvc *promptservice.Service, githubSvc *githubpkg.Service, gitCredentialBroker *gitcredentials.Broker, ) (*orchestrator.Service, *messageCreatorAdapter, error) { if lifecycleMgr == nil { return nil, nil, errors.New("lifecycle manager is required: configure agent runtime (docker or standalone)") } taskRepoAdapter := &taskRepositoryAdapter{repo: taskRepo, svc: taskSvc} agentManagerClient := newLifecycleAdapter(lifecycleMgr, agentRegistry, log) serviceCfg := orchestrator.DefaultServiceConfig() serviceCfg.ClaudeBackgroundPromptHandoff = cfg != nil && cfg.Features.ClaudeBackgroundPromptHandoff serviceCfg.ClaudeMidTurnSteering = cfg != nil && cfg.Features.ClaudeMidTurnSteering serviceCfg.OfficeSessionIdentity = cfg != nil && cfg.Features.OfficeSessionIdentity namespace := resolveEventNamespace(cfg) serviceCfg.QueueGroup = "orchestrator." + namespace busMode := "memory" if cfg != nil && strings.TrimSpace(cfg.NATS.URL) != "" { busMode = "nats" } log.Debug("orchestrator queue group resolved", zap.String("event_bus", busMode), zap.String("event_namespace", namespace), zap.String("queue_group", serviceCfg.QueueGroup), zap.Int("agent_standalone_port", cfg.Agent.StandalonePort)) queueRepo, err := messagequeue.NewSQLiteRepository(pool.Writer(), pool.Reader()) if err != nil { return nil, nil, fmt.Errorf("init message queue repo: %w", err) } queueSettings := resolveQueueSettings(pool, log, queueConfiguration(cfg)).Effective maxPerSession := queueSettings.MaxPerSession mergeEnabled := queueSettings.MergeEnabled autoMergeEnabled := queueSettings.AutoMergeEnabled msgQueue := messagequeue.NewService(queueRepo, maxPerSession, log) msgQueue.SetMergeEnabled(mergeEnabled) msgQueue.SetAutoMergeEnabled(autoMergeEnabled) log.Info("Message queue initialized", zap.Int("max_per_session", maxPerSession), zap.Bool("merge_enabled", mergeEnabled), zap.Bool("auto_merge_enabled", autoMergeEnabled)) if taskSvc.AttachmentService() == nil && taskSvc.AttachmentRepository() != nil { attachmentSvc, attachmentErr := taskservice.NewAttachmentService( taskSvc.AttachmentRepository(), cfg.ResolvedHomeDir(), taskSvc.AuthorizeWorkspaceAccess, log, ) if attachmentErr != nil { return nil, nil, fmt.Errorf("initialize prompt attachment storage: %w", attachmentErr) } taskSvc.SetAttachmentService(attachmentSvc) } if attachmentSvc := taskSvc.AttachmentService(); attachmentSvc != nil { attachmentSvc.SetTaskAuthorizer(taskSvc.AuthorizeTaskAccess) } orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, userSvc, secretStore, msgQueue, log) orchestratorSvc.SetAgentProfileRecentUseRecorder(userSvc) if gitCredentialBroker != nil { orchestratorSvc.SetGitHubCredentialBroker(gitCredentialBroker, githubCredentialBrokerEndpoint(cfg)) } orchestratorSvc.SetAttachmentReader(taskSvc.AttachmentService()) orchestratorSvc.SetLaunchAttachmentClaimer(taskSvc) orchestratorSvc.SetTitleBranchRuntime(lifecycleMgr) if githubSvc != nil { orchestratorSvc.SetTaskGitCredentialPolicyResolver(githubExecutorCredentialPolicyAdapter{service: githubSvc}) orchestratorSvc.SetPRBaseResolver(githubPRBaseResolver{service: githubSvc}) } taskSvc.SetExecutionStopper(orchestratorSvc) // Runtime-aware liveness lets durable cleanup treat a not-found stop for a // confirmed-dead local runtime as already stopped instead of retrying forever. taskSvc.SetRowLivenessProber(agentManagerClient) taskSvc.SetContextWindowResetter(orchestratorSvc.ResetContextWindow) taskSvc.SetGitArchiveCapture(orchestratorSvc) // Automation runs keep their worktrees so they stay repliable, which makes // them the one task kind nothing else ever cleans up; the orchestrator needs // the manager to enforce the per-automation retention window. orchestratorSvc.SetWorktreeManager(lifecycleMgr.WorktreeManager()) orchestratorSvc.SetTaskLaunchRecoveryService(taskSvc) msgCreator := &messageCreatorAdapter{svc: taskSvc, logger: log} orchestratorSvc.SetMessageCreator(msgCreator) orchestratorSvc.SetTransientRetryMessageService(taskSvc) orchestratorSvc.SetSubagentContextRecorder(&subagentContextAdapter{svc: taskSvc}) orchestratorSvc.SetTurnService(newTurnServiceAdapter(taskSvc)) // Route orchestrator task.updated events through the task service, which // owns the canonical rich payload. Covers workflow transitions, workflow // step moves, and the primary-session-set callback below. orchestratorSvc.SetTaskEventPublisher(taskSvc) // Feeder promotion after an admitted manual move must wait for the // orchestrator's task lifecycle. The task service keeps ownership of the // candidate filter and promotion rules. orchestratorSvc.SetFeederPullReconciler(taskSvc) // Let the task service read the live per-session busy substate so it can // compute the task-level MOST-ACTIVE-WINS activity aggregate carried on the // boot payload and task.updated events. taskSvc.SetForegroundActivityProvider(orchestratorSvc) // Task dependencies gate every automated launch and drive chain advancement. // Wired unconditionally: dependencies are a core Kanban relationship, not an // Office feature. orchestratorSvc.SetTaskDependencyReader(taskSvc) // Let the task service stamp status_summary.queued_prompt_count on task // list/snapshot payloads (initial-load backstop for the sidebar badge; the // status-summary projector keeps the field live between loads). taskSvc.SetQueuedPromptCounter(orchestratorSvc.GetMessageQueue()) // Per-user scoping for the session-keyed WS actions. The orchestrator // resolves sessions through its own repo handle, so it does not inherit the // task service's authorize* checks. orchestratorSvc.SetSessionAccessChecker(taskSvc.AuthorizeSessionAccess) orchestratorSvc.SetSessionControlChecker(func(ctx context.Context, sessionID string) error { return taskSvc.AuthorizeSessionScope(ctx, sessionID, authz.ScopeSessionControl) }) // Starting, resuming, steering and dispatching a turn are writes, so they // need session.prompt rather than mere reach. orchestratorSvc.SetSessionPromptChecker(func(ctx context.Context, sessionID string) error { return taskSvc.AuthorizeSessionScope(ctx, sessionID, authz.ScopeSessionPrompt) }) orchestratorSvc.SetTaskPromptChecker(func(ctx context.Context, taskID string) error { return taskSvc.AuthorizeTaskScope(ctx, taskID, authz.ScopeSessionPrompt) }) orchestratorSvc.SetTaskAccessChecker(taskSvc.AuthorizeTaskAccess) // Publish task.updated when the first session is marked primary so the // frontend receives primary_session_id for newly created tasks. orchestratorSvc.SetOnPrimarySessionSet(func(ctx context.Context, taskID, _ string) { task, err := taskRepo.GetTask(ctx, taskID) if err != nil { log.Warn("failed to get task for primary session event", zap.String("task_id", taskID), zap.Error(err)) return } taskSvc.PublishTaskUpdated(ctx, task) }) // Wire workflow step getter for prompt building. The recorder is wired // first: SetStepHistoryRecorder's own initWorkflowEngine() call is a // no-op while workflowStepGetter is still nil, so the store/engine only // get built once, by SetWorkflowStepGetter below, instead of twice. if workflowSvc != nil { // Wire the ADR 0015 audit-trail writer for auto-advance step // transitions. workflowSvc.CreateStepTransition already matches // orchestrator.StepHistoryRecorder structurally, so no adapter is // needed. orchestratorSvc.SetStepHistoryRecorder(workflowSvc) orchestratorSvc.SetWorkflowStepGetter(&orchestratorWorkflowStepGetterAdapter{svc: workflowSvc}) } // Wire agent family resolution so configure_session rules can name an agent // the way a workflow author writes it ("Claude") and still match the // canonical ID a session stores ("claude-acp"). if agentRegistry != nil { orchestratorSvc.SetAgentFamilyResolver(agentRegistry) } // Wire "@name" saved-prompt reference expansion into workflow-step prompt // building. if promptSvc != nil { orchestratorSvc.SetPromptReferenceExpander(promptSvc) } // Wire review task creator for auto-creating tasks from review watch PRs orchestratorSvc.SetReviewTaskCreator(&reviewTaskCreatorAdapter{svc: taskSvc}) // Wire issue task creator for auto-creating tasks from issue watch events orchestratorSvc.SetIssueTaskCreator(&issueTaskCreatorAdapter{svc: taskSvc}) // Wire the per-watcher throttle gate. taskRepo exposes the JSON-scoped // count of open watcher-created tasks; the orchestrator combines it with // an in-process pending counter so a poll-tick burst can't overshoot. orchestratorSvc.SetWatcherTaskCounter(taskRepo) // Wire repository resolver for auto-cloning repos during review task creation if repoCloner != nil { orchestratorSvc.SetRepositoryResolver(&repositoryResolverAdapter{ cloner: repoCloner, taskSvc: taskSvc, logger: log, }) // Wire repo cloner into executor for provider-backed repos with no local path orchestratorSvc.SetRepoCloner(repoCloner, &repoLocalPathUpdater{svc: taskSvc}) } // Worktree fallback self-healing updates the exact task repository row // after a successful launch. Keep this seam on the task service so the // lifecycle and worktree packages remain task-agnostic. orchestratorSvc.SetTaskRepositoryBaseBranchUpdater(&repoLocalPathUpdater{svc: taskSvc}) return orchestratorSvc, msgCreator, nil } type githubCredentialPolicyService interface { DescribeTaskGitCredentialPolicy(context.Context, string) (githubpkg.TaskGitCredentialPolicy, error) } type githubExecutorCredentialPolicyAdapter struct { service githubCredentialPolicyService } type githubPRBaseResolver struct { service *githubpkg.Service } func (r githubPRBaseResolver) ResolvePRBaseBranch( ctx context.Context, workspaceID, owner, repo string, number int, ) (string, error) { pr, err := r.service.GetPRForAutomation(ctx, workspaceID, owner, repo, number) if err != nil || pr == nil { return "", err } return pr.BaseBranch, nil } func (a githubExecutorCredentialPolicyAdapter) ResolveTaskGitCredentialPolicy( ctx context.Context, workspaceID string, ) (executorpkg.TaskGitCredentialPolicy, error) { policy, err := a.service.DescribeTaskGitCredentialPolicy(ctx, workspaceID) if err != nil { return executorpkg.TaskGitCredentialPolicy{}, err } return executorpkg.TaskGitCredentialPolicy{ Mode: policy.Mode, WorkspaceMethod: policy.WorkspaceMethod, WorkspaceActor: policy.WorkspaceActor, }, nil } func githubCredentialBrokerEndpoint(cfg *config.Config) string { if cfg != nil { if publicBaseURL := strings.TrimRight(strings.TrimSpace(cfg.GitHubCredentialBroker.PublicBaseURL), "/"); publicBaseURL != "" { return publicBaseURL + "/api/v1/git/credentials/resolve" } if cfg.Server.Port != 0 { return fmt.Sprintf("http://localhost:%d/api/v1/git/credentials/resolve", cfg.Server.Port) } } return fmt.Sprintf("http://localhost:%d/api/v1/git/credentials/resolve", portsBackendDefault) } // resolveQueueMaxPerSession honors the KANDEV_QUEUE_MAX_PER_SESSION env var, // falling back to messagequeue.DefaultMaxPerSession (10) when unset or invalid. // Values <= 0 disable the cap entirely (callers can still flood queues — only // useful in tests / specialized deployments). func resolveQueueMaxPerSession(pool *db.Pool, log *logger.Logger, startup ...queuesettings.Configuration) int { return resolveQueueSettings(pool, log, startup...).Effective.MaxPerSession } // resolveQueueMergeEnabled honors the persisted message queue setting, // falling back to enabled (the shipped default) when unset or invalid. // Unlike max_per_session it has no environment override. func resolveQueueMergeEnabled(pool *db.Pool, log *logger.Logger) bool { return resolveQueueSettings(pool, log).Effective.MergeEnabled } // resolveQueueAutoMergeEnabled honors the persisted automatic merge setting, // defaulting to enabled when unset or invalid. func resolveQueueAutoMergeEnabled(pool *db.Pool, log *logger.Logger) bool { return resolveQueueSettings(pool, log).Effective.AutoMergeEnabled } // resolveQueueSettings loads the persisted message queue settings — falling // back to defaults when unset, invalid, or the store is unavailable — and // resolves them against the KANDEV_QUEUE_MAX_PER_SESSION environment // override. func resolveQueueSettings( pool *db.Pool, log *logger.Logger, startup ...queuesettings.Configuration, ) queuesettings.Resolution { var configured *queuesettings.Settings if pool != nil { rawStore, err := systemsettings.NewStore(pool) if err != nil { log.Warn("Failed to initialize message queue settings store", zap.Error(err)) } else { configured, err = queuesettings.NewStore(rawStore).Load(context.Background()) if err != nil { log.Warn("Ignoring invalid persisted message queue settings", zap.Error(err)) configured = nil } } } resolution, err := queuesettings.Resolve(configured, queuesettings.ReadEnvironment(), startup...) if err != nil { log.Warn("Failed to resolve message queue settings, using defaults", zap.Error(err)) return queuesettings.Resolution{Response: queuesettings.Response{ Settings: queuesettings.DefaultSettings(), Effective: queuesettings.Effective{ MaxPerSession: messagequeue.DefaultMaxPerSession, MergeEnabled: true, AutoMergeEnabled: true, }, }} } if resolution.InvalidEnvironment { log.Warn("Ignoring invalid message queue capacity environment value", zap.String("environment_variable", queuesettings.EnvironmentVariable)) } return resolution } func queueConfiguration(cfg *config.Config) queuesettings.Configuration { if cfg == nil || cfg.SourceFor("messageQueue.maxPerSession") != config.SourceConfiguration { return queuesettings.Configuration{} } return queuesettings.Configuration{Value: cfg.MessageQueue.MaxPerSession, Present: true} } func resolveEventNamespace(cfg *config.Config) string { if cfg == nil { return defaultEventNamespace } if explicit := strings.TrimSpace(cfg.Events.Namespace); explicit != "" { return sanitizeNamespace(explicit) } identity := resolveDatabaseIdentity(cfg) if identity == "" { return defaultEventNamespace } return hashNamespace(identity) } func resolveDatabaseIdentity(cfg *config.Config) string { if strings.EqualFold(cfg.Database.Driver, "sqlite") { path := cfg.Database.Path if path == "" { path = "./kandev.db" } absPath, err := filepath.Abs(path) if err == nil { return "sqlite:" + absPath } return "sqlite:" + path } return fmt.Sprintf("pg:%s:%d:%s:%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.DBName, cfg.Database.User) } func hashNamespace(identity string) string { sum := sha1.Sum([]byte(identity)) return fmt.Sprintf("%x", sum[:6]) } func sanitizeNamespace(namespace string) string { lower := strings.ToLower(namespace) var b strings.Builder for _, r := range lower { switch { case r >= 'a' && r <= 'z': b.WriteRune(r) case r >= '0' && r <= '9': b.WriteRune(r) case r == '-' || r == '_' || r == '.': b.WriteRune(r) default: b.WriteRune('-') } } out := strings.Trim(b.String(), "-._") if out == "" { return defaultEventNamespace } return out } // orchestratorWorkflowStepGetterAdapter adapts workflow service to orchestrator's WorkflowStepGetter interface. // Since orchestrator now uses wfmodels.WorkflowStep directly, the adapter simply delegates to the service. type orchestratorWorkflowStepGetterAdapter struct { svc *workflowservice.Service } // GetStep implements orchestrator.WorkflowStepGetter. func (a *orchestratorWorkflowStepGetterAdapter) GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error) { return a.svc.GetStep(ctx, stepID) } // GetNextStepByPosition implements orchestrator.WorkflowStepGetter. func (a *orchestratorWorkflowStepGetterAdapter) GetNextStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) { return a.svc.GetNextStepByPosition(ctx, workflowID, currentPosition) } // GetPreviousStepByPosition implements orchestrator.WorkflowStepGetter. func (a *orchestratorWorkflowStepGetterAdapter) GetPreviousStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) { return a.svc.GetPreviousStepByPosition(ctx, workflowID, currentPosition) } // GetWorkflowMeta implements orchestrator.WorkflowStepGetter. func (a *orchestratorWorkflowStepGetterAdapter) GetWorkflowMeta(ctx context.Context, workflowID string) (orchestrator.WorkflowMeta, error) { meta, err := a.svc.GetWorkflowMeta(ctx, workflowID) if err != nil { return orchestrator.WorkflowMeta{}, err } return orchestrator.WorkflowMeta{ AgentProfileID: meta.AgentProfileID, Prompt: meta.Prompt, }, nil } // reviewTaskCreatorAdapter adapts the task service to the orchestrator's ReviewTaskCreator interface. type reviewTaskCreatorAdapter struct { svc *taskservice.Service } // CreateReviewTask implements orchestrator.ReviewTaskCreator. func (a *reviewTaskCreatorAdapter) CreateReviewTask(ctx context.Context, req *orchestrator.ReviewTaskRequest) (*taskmodels.Task, error) { var repos []taskservice.TaskRepositoryInput for _, r := range req.Repositories { repos = append(repos, taskservice.TaskRepositoryInput{ RepositoryID: r.RepositoryID, BaseBranch: r.BaseBranch, CheckoutBranch: r.CheckoutBranch, PRNumber: r.PRNumber, }) } result, err := a.svc.CreateTask(ctx, &taskservice.CreateTaskRequest{ WorkspaceID: req.WorkspaceID, WorkflowID: req.WorkflowID, WorkflowStepID: req.WorkflowStepID, Title: req.Title, Description: req.Description, Metadata: req.Metadata, Repositories: repos, IsEphemeral: req.IsEphemeral, Origin: req.Origin, }) if err != nil { return nil, err } return result.Task, nil } // issueTaskCreatorAdapter adapts the task service to the orchestrator's IssueTaskCreator interface. type issueTaskCreatorAdapter struct { svc *taskservice.Service } // CreateIssueTask implements orchestrator.IssueTaskCreator. func (a *issueTaskCreatorAdapter) CreateIssueTask(ctx context.Context, req *orchestrator.IssueTaskRequest) (*taskmodels.Task, error) { var repos []taskservice.TaskRepositoryInput for _, r := range req.Repositories { repos = append(repos, taskservice.TaskRepositoryInput{ RepositoryID: r.RepositoryID, BaseBranch: r.BaseBranch, }) } result, err := a.svc.CreateTask(ctx, &taskservice.CreateTaskRequest{ WorkspaceID: req.WorkspaceID, WorkflowID: req.WorkflowID, WorkflowStepID: req.WorkflowStepID, Title: req.Title, Description: req.Description, Metadata: req.Metadata, Repositories: repos, }) if err != nil { return nil, err } return result.Task, nil } // jiraServiceAdapter exposes the JIRA service's issue-watch dedup methods to // the orchestrator without leaking the rest of the package surface area. type jiraServiceAdapter struct { svc *jirapkg.Service } func (a *jiraServiceAdapter) ReserveIssueWatchTask(ctx context.Context, watchID, issueKey, issueURL string) (bool, error) { return a.svc.Store().ReserveIssueWatchTask(ctx, watchID, issueKey, issueURL) } func (a *jiraServiceAdapter) AssignIssueWatchTaskID(ctx context.Context, watchID, issueKey, taskID string) error { return a.svc.Store().AssignIssueWatchTaskID(ctx, watchID, issueKey, taskID) } func (a *jiraServiceAdapter) ReleaseIssueWatchTask(ctx context.Context, watchID, issueKey string) error { return a.svc.Store().ReleaseIssueWatchTask(ctx, watchID, issueKey) } func (a *jiraServiceAdapter) DisableIssueWatchWithError(ctx context.Context, watchID, cause string) error { return a.svc.Store().DisableIssueWatchWithError(ctx, watchID, cause) } // linearServiceAdapter exposes the Linear service's issue-watch dedup methods // to the orchestrator without leaking the rest of the package surface area. type linearServiceAdapter struct { svc *linearpkg.Service } func (a *linearServiceAdapter) ReserveIssueWatchTask(ctx context.Context, watchID, identifier, issueURL string) (bool, error) { return a.svc.Store().ReserveIssueWatchTask(ctx, watchID, identifier, issueURL) } func (a *linearServiceAdapter) AssignIssueWatchTaskID(ctx context.Context, watchID, identifier, taskID string) error { return a.svc.Store().AssignIssueWatchTaskID(ctx, watchID, identifier, taskID) } func (a *linearServiceAdapter) ReleaseIssueWatchTask(ctx context.Context, watchID, identifier string) error { return a.svc.Store().ReleaseIssueWatchTask(ctx, watchID, identifier) } // sentryServiceAdapter exposes the Sentry service's issue-watch dedup methods // to the orchestrator without leaking the rest of the package surface area. type sentryServiceAdapter struct { svc *sentrypkg.Service } func (a *sentryServiceAdapter) ReserveIssueWatchTask(ctx context.Context, watchID, shortID, issueURL string) (bool, error) { return a.svc.Store().ReserveIssueWatchTask(ctx, watchID, shortID, issueURL) } func (a *sentryServiceAdapter) AssignIssueWatchTaskID(ctx context.Context, watchID, shortID, taskID string) error { return a.svc.Store().AssignIssueWatchTaskID(ctx, watchID, shortID, taskID) } func (a *sentryServiceAdapter) ReleaseIssueWatchTask(ctx context.Context, watchID, shortID string) error { return a.svc.Store().ReleaseIssueWatchTask(ctx, watchID, shortID) } func (a *sentryServiceAdapter) DisableIssueWatchWithError(ctx context.Context, watchID, cause string) error { return a.svc.Store().DisableIssueWatchWithError(ctx, watchID, cause) } func (a *linearServiceAdapter) DisableIssueWatchWithError(ctx context.Context, watchID, cause string) error { return a.svc.Store().DisableIssueWatchWithError(ctx, watchID, cause) } // profileLookupAdapter satisfies orchestrator.ProfileLookup by delegating to // the agent settings store. Returns (true, name, nil) when the row exists // but is soft-deleted, which the orchestrator treats as a signal to self-heal // the bound watcher (set enabled=0 + last_error). All other shapes — live // row, missing row, driver error — are returned verbatim so the dispatch // pipeline fails open on transient failures. type profileLookupAdapter struct { store settingsstore.Repository } func (a *profileLookupAdapter) LookupProfile(ctx context.Context, profileID string) (bool, string, error) { if _, err := a.store.GetAgentProfile(ctx, profileID); err == nil { return false, "", nil } else if !errors.Is(err, sql.ErrNoRows) { return false, "", err } profile, err := a.store.GetAgentProfileIncludingDeleted(ctx, profileID) if err != nil || profile == nil || profile.DeletedAt == nil { return false, "", err } return true, profile.Name, nil } // watcherDepsAdapter enumerates linear / jira / github_issue / github_review // watcher rows that reference an agent profile. The profile-delete confirm // dialog uses the list to render "this will also disable N watchers — are // you sure?". // // Each integration's package is optional in dev mode; nil-safe so the // adapter degrades gracefully when one isn't wired. // automationDepsAdapter lets the agent-settings controller name the enabled // automations bound to a profile without importing the automation package's // types into it. type automationDepsAdapter struct { store *automationpkg.Store } type utilityDepsAdapter struct { svc *utilityservice.Service userSvc *userservice.Service } func (a *utilityDepsAdapter) ListUtilityAgentsByAgentProfile(ctx context.Context, profileID string) ([]agentsettingscontroller.UtilityAgentReference, error) { if a == nil || a.svc == nil { return nil, nil } agents, err := a.svc.ListAgents(ctx) if err != nil { return nil, err } refs := make([]agentsettingscontroller.UtilityAgentReference, 0) defaultProfileID := "" if a.userSvc != nil { defaultProfileID, err = a.userSvc.GetDefaultUtilityAgentProfileID(ctx) if err != nil { return nil, err } } for _, agent := range agents { if agent != nil && (agent.AgentProfileID == profileID || (utilitymodels.UsesDefaultProfile(agent) && defaultProfileID == profileID)) { refs = append(refs, agentsettingscontroller.UtilityAgentReference{ID: agent.ID, Name: agent.Name}) } } return refs, nil } func (a *utilityDepsAdapter) ClearUtilityAgentProfileBindings(ctx context.Context, profileID string) error { if a == nil || a.svc == nil { return nil } if err := a.svc.ClearAgentProfileBindings(ctx, profileID); err != nil { return err } return nil } func (a *automationDepsAdapter) ListEnabledAutomationsByAgentProfile( ctx context.Context, profileID string, ) ([]agentsettingscontroller.AutomationReference, error) { if a == nil || a.store == nil { return nil, nil } bindings, err := a.store.ListEnabledByAgentProfile(ctx, profileID) if err != nil { return nil, err } refs := make([]agentsettingscontroller.AutomationReference, 0, len(bindings)) for _, b := range bindings { refs = append(refs, agentsettingscontroller.AutomationReference{ ID: b.ID, Name: b.Name, WorkspaceID: b.WorkspaceID, }) } return refs, nil } func (a *automationDepsAdapter) DisableAutomationsByAgentProfile( ctx context.Context, profileID string, ) ([]agentsettingscontroller.AutomationReference, error) { if a == nil || a.store == nil { return nil, nil } bindings, err := a.store.DisableByAgentProfile(ctx, profileID) if err != nil { return nil, err } refs := make([]agentsettingscontroller.AutomationReference, 0, len(bindings)) for _, b := range bindings { refs = append(refs, agentsettingscontroller.AutomationReference{ ID: b.ID, Name: b.Name, WorkspaceID: b.WorkspaceID, }) } return refs, nil } type watcherDepsAdapter struct { linear *linearpkg.Service jira *jirapkg.Service github githubWatcherLister log *logger.Logger } // githubWatcherLister is the slice of github.Service the adapter needs. // Local alias so the adapter file stays free of the wider GitHubService // orchestrator interface, which carries many unrelated methods. Uses the // enabled-only listers so already-disabled (self-healed) watchers do not // inflate the dependency count surfaced in ErrProfileInUseDetail. Disable // methods are the same store-level helpers the dispatch coordinator's // self-heal path uses. type githubWatcherLister interface { ListEnabledIssueWatches(ctx context.Context) ([]*githubpkg.IssueWatch, error) ListEnabledReviewWatches(ctx context.Context) ([]*githubpkg.ReviewWatch, error) DisableIssueWatchWithError(ctx context.Context, watchID, cause string) error DisableReviewWatchWithError(ctx context.Context, watchID, cause string) error } func (a *watcherDepsAdapter) ListWatchersByAgentProfile(ctx context.Context, profileID string) ([]agentsettingscontroller.WatcherReference, error) { if profileID == "" { return nil, nil } var refs []agentsettingscontroller.WatcherReference more, err := a.listLinearRefs(ctx, profileID) if err != nil { return nil, err } refs = append(refs, more...) more, err = a.listJiraRefs(ctx, profileID) if err != nil { return nil, err } refs = append(refs, more...) more, err = a.listGitHubRefs(ctx, profileID) if err != nil { return nil, err } refs = append(refs, more...) return refs, nil } // DisableWatchersByAgentProfile enumerates the enabled watcher rows that // reference profileID and flips each to enabled=0 with the supplied cause. // Returns the list it disabled so the caller can log. Best-effort across // integrations: a failure for one integration logs and continues to the // next so a single broken store doesn't strand the rest of the user's // orphaned watchers. func (a *watcherDepsAdapter) DisableWatchersByAgentProfile(ctx context.Context, profileID, cause string) ([]agentsettingscontroller.WatcherReference, error) { refs, err := a.ListWatchersByAgentProfile(ctx, profileID) if err != nil { return nil, err } disabled := make([]agentsettingscontroller.WatcherReference, 0, len(refs)) for _, ref := range refs { if err := a.disableByKind(ctx, ref.Kind, ref.ID, cause); err != nil { a.log.Warn("failed to disable referencing watcher", zap.String("profile_id", profileID), zap.String("watcher_kind", ref.Kind), zap.String("watcher_id", ref.ID), zap.Error(err)) continue } disabled = append(disabled, ref) } return disabled, nil } type routingTierDepsAdapter struct { repo *officesqlite.Repository } func (a *routingTierDepsAdapter) ListRoutingTierReferencesByAgentProfile( ctx context.Context, profileID string, ) ([]agentsettingscontroller.RoutingTierReference, error) { if a == nil || a.repo == nil || profileID == "" { return nil, nil } refs, err := a.repo.ListRoutingTierReferencesByAgentProfile(ctx, profileID) if err != nil { return nil, err } out := make([]agentsettingscontroller.RoutingTierReference, 0, len(refs)) for _, ref := range refs { out = append(out, agentsettingscontroller.RoutingTierReference{ WorkspaceID: ref.WorkspaceID, ProviderID: string(ref.ProviderID), Tier: string(ref.Tier), }) } return out, nil } func (a *watcherDepsAdapter) disableByKind(ctx context.Context, kind, watchID, cause string) error { switch kind { case "linear": if a.linear == nil { return nil } return a.linear.Store().DisableIssueWatchWithError(ctx, watchID, cause) case "jira": if a.jira == nil { return nil } return a.jira.Store().DisableIssueWatchWithError(ctx, watchID, cause) case "github_issue": if a.github == nil { return nil } return a.github.DisableIssueWatchWithError(ctx, watchID, cause) case "github_review": if a.github == nil { return nil } return a.github.DisableReviewWatchWithError(ctx, watchID, cause) default: return fmt.Errorf("unknown watcher kind: %s", kind) } } func (a *watcherDepsAdapter) listLinearRefs(ctx context.Context, profileID string) ([]agentsettingscontroller.WatcherReference, error) { if a.linear == nil { return nil, nil } watches, err := a.linear.Store().ListEnabledIssueWatches(ctx) if err != nil { return nil, fmt.Errorf("linear watchers: %w", err) } var out []agentsettingscontroller.WatcherReference for _, w := range watches { if w.AgentProfileID == profileID { out = append(out, agentsettingscontroller.WatcherReference{ ID: w.ID, Kind: "linear", Label: linearWatchLabel(w), }) } } return out, nil } func (a *watcherDepsAdapter) listJiraRefs(ctx context.Context, profileID string) ([]agentsettingscontroller.WatcherReference, error) { if a.jira == nil { return nil, nil } watches, err := a.jira.Store().ListEnabledIssueWatches(ctx) if err != nil { return nil, fmt.Errorf("jira watchers: %w", err) } var out []agentsettingscontroller.WatcherReference for _, w := range watches { if w.AgentProfileID == profileID { out = append(out, agentsettingscontroller.WatcherReference{ ID: w.ID, Kind: "jira", Label: truncateWatcherLabel(w.JQL), }) } } return out, nil } func (a *watcherDepsAdapter) listGitHubRefs(ctx context.Context, profileID string) ([]agentsettingscontroller.WatcherReference, error) { if a.github == nil { return nil, nil } issues, err := a.github.ListEnabledIssueWatches(ctx) if err != nil { return nil, fmt.Errorf("github issue watchers: %w", err) } var out []agentsettingscontroller.WatcherReference for _, w := range issues { if w.AgentProfileID == profileID { out = append(out, agentsettingscontroller.WatcherReference{ ID: w.ID, Kind: "github_issue", Label: githubIssueWatchLabel(w), }) } } reviews, err := a.github.ListEnabledReviewWatches(ctx) if err != nil { return nil, fmt.Errorf("github review watchers: %w", err) } for _, w := range reviews { if w.AgentProfileID == profileID { out = append(out, agentsettingscontroller.WatcherReference{ ID: w.ID, Kind: "github_review", Label: githubReviewWatchLabel(w), }) } } return out, nil } // linearWatchLabel renders a Linear filter into a short human-readable label // for the confirmation dialog. The team key is the most-recognisable scoping // fact; the query string is a fallback. Capped at watcherLabelMaxLen. func linearWatchLabel(w *linearpkg.IssueWatch) string { switch { case w.Filter.TeamKey != "": return truncateWatcherLabel("team " + w.Filter.TeamKey) case w.Filter.Query != "": return truncateWatcherLabel(w.Filter.Query) default: return "all teams" } } func githubIssueWatchLabel(w *githubpkg.IssueWatch) string { if w.CustomQuery != "" { return truncateWatcherLabel(w.CustomQuery) } return truncateWatcherLabel(githubReposJoin(w.Repos)) } func githubReviewWatchLabel(w *githubpkg.ReviewWatch) string { if w.CustomQuery != "" { return truncateWatcherLabel(w.CustomQuery) } return truncateWatcherLabel(githubReposJoin(w.Repos)) } func githubReposJoin(repos []githubpkg.RepoFilter) string { if len(repos) == 0 { return "all repos" } parts := make([]string, 0, len(repos)) for _, r := range repos { parts = append(parts, r.Owner+"/"+r.Name) } return strings.Join(parts, ", ") } const watcherLabelMaxLen = 80 // truncateWatcherLabel caps a watcher's human-readable label at // watcherLabelMaxLen runes, appending "…" when truncation happens. Counted // in runes (not bytes) so a JQL or filter ending in multi-byte UTF-8 // (Japanese, emoji) is not split mid-codepoint — slicing a byte string // would emit invalid UTF-8 and the UI would render the replacement // character. func truncateWatcherLabel(s string) string { s = strings.TrimSpace(s) runes := []rune(s) if len(runes) <= watcherLabelMaxLen { return s } return string(runes[:watcherLabelMaxLen-1]) + "…" } // repoLocalPathUpdater adapts the task service's UpdateRepository to the executor.RepoUpdater interface. type repoLocalPathUpdater struct { svc *taskservice.Service } func (u *repoLocalPathUpdater) UpdateRepositoryLocalPath(ctx context.Context, repositoryID, localPath string) error { if repositoryID == "" || localPath == "" { return fmt.Errorf("UpdateRepositoryLocalPath: repositoryID and localPath must be non-empty") } _, err := u.svc.UpdateRepository(ctx, repositoryID, &taskservice.UpdateRepositoryRequest{ LocalPath: &localPath, }) return err } func (u *repoLocalPathUpdater) UpdateRepositoryDefaultBranch(ctx context.Context, repositoryID, defaultBranch string) error { if repositoryID == "" || defaultBranch == "" { return fmt.Errorf("UpdateRepositoryDefaultBranch: repositoryID and defaultBranch must be non-empty") } _, err := u.svc.UpdateRepository(ctx, repositoryID, &taskservice.UpdateRepositoryRequest{ DefaultBranch: &defaultBranch, }) return err } func (u *repoLocalPathUpdater) UpdateTaskRepositoryBaseBranch(ctx context.Context, taskID, taskRepositoryID, baseBranch string) error { _, err := u.svc.UpdateRepositoryBaseBranch(ctx, taskservice.UpdateRepositoryBaseBranchRequest{ TaskID: taskID, TaskRepositoryID: taskRepositoryID, BaseBranch: baseBranch, }) return err } // repositoryResolverAdapter resolves GitHub repos by cloning + finding/creating DB records. type repositoryResolverAdapter struct { cloner reviewRepositoryCloner taskSvc *taskservice.Service logger *logger.Logger } type reviewRepositoryCloner interface { EnsureWorkspaceCloned(context.Context, string, string, string, string, string) (string, error) BuildCloneURLWithHost(context.Context, string, string, string, string) (string, error) } // ResolveForReview implements orchestrator.RepositoryResolver. // // If the workspace already has a Repository configured for the given provider // info with a non-empty LocalPath, that repo is reused and no clone is // performed. Otherwise the repo is cloned into the kandev-managed location and // a Repository entity is created. func (a *repositoryResolverAdapter) ResolveForReview( ctx context.Context, workspaceID, provider, owner, name, defaultBranch string, ) (string, string, error) { hostname, err := defaultProviderHostname(provider) if err != nil { return "", "", err } providerHost := "https://" + hostname existing, err := a.taskSvc.GetRepositoryByProviderInfo(ctx, workspaceID, provider, providerHost, owner, name) if err != nil { return "", "", fmt.Errorf("lookup repository by provider info: %w", err) } if existing != nil && existing.LocalPath != "" { baseBranch := a.resolveReviewBaseBranch(ctx, existing, existing.LocalPath, defaultBranch) return existing.ID, baseBranch, nil } cloneURL, err := a.cloner.BuildCloneURLWithHost(ctx, provider, providerHost, owner, name) if err != nil { return "", "", fmt.Errorf("unsupported provider: %w", err) } localPath, err := a.cloner.EnsureWorkspaceCloned(ctx, workspaceID, provider, cloneURL, owner, name) if err != nil { return "", "", fmt.Errorf("clone repository: %w", err) } repo, _, err := a.taskSvc.FindOrCreateRepository(ctx, &taskservice.FindOrCreateRepositoryRequest{ WorkspaceID: workspaceID, Provider: provider, ProviderHost: providerHost, ProviderOwner: owner, ProviderName: name, DefaultBranch: defaultBranch, LocalPath: localPath, }) if err != nil { return "", "", fmt.Errorf("find/create repository: %w", err) } baseBranch := a.resolveReviewBaseBranch(ctx, repo, localPath, defaultBranch) return repo.ID, baseBranch, nil } func defaultProviderHostname(provider string) (string, error) { switch strings.ToLower(provider) { case "gitlab": return "gitlab.com", nil case gitCredentialGitHubProviderID, "": return gitCredentialGitHubHost, nil default: return "", fmt.Errorf("unsupported review repository provider %q", provider) } } func (a *repositoryResolverAdapter) resolveReviewBaseBranch( ctx context.Context, repo *taskmodels.Repository, localPath string, requestedBranch string, ) string { if requestedBranch != "" { return requestedBranch } stored := strings.TrimSpace(repo.DefaultBranch) if stored == defaultMasterBranch && localPath != "" { if detected := detectGitDefaultBranch(localPath); detected == defaultMainBranch { return a.persistDetectedDefaultBranch(ctx, repo, detected) } } if stored != "" { return stored } return a.detectAndPersistDefaultBranch(ctx, repo, localPath) } // detectAndPersistDefaultBranch reads the default branch from the local clone // and persists it to the repository record for future lookups. func (a *repositoryResolverAdapter) detectAndPersistDefaultBranch( ctx context.Context, repo *taskmodels.Repository, localPath string, ) string { detected := detectGitDefaultBranch(localPath) if detected == "" { return "" } return a.persistDetectedDefaultBranch(ctx, repo, detected) } func (a *repositoryResolverAdapter) persistDetectedDefaultBranch( ctx context.Context, repo *taskmodels.Repository, detected string, ) string { if strings.TrimSpace(repo.DefaultBranch) == detected { return detected } // detected is still the correct base branch for the review in progress // even when the write below fails, so it is always returned. But a // failure here is not a transient blip to shrug off: it leaves // repositories.default_branch empty, which (per spec "Degraded // evaluation") permanently degrades every future delivery-ledger // evaluation of this repository to default_branch_unknown until some // later write succeeds — and this call site retries with the same // detected value on every future invocation, so a rejection driven by // validation (as opposed to a transient DB error) will repeat forever. // Review round 3, finding #4: this used to log at Warn and nothing // else, making that permanent degradation invisible. Error level plus // the dedicated counter make it observable the same way ancestry/write // failures already are in internal/delivery/metrics.go. if _, err := a.taskSvc.UpdateRepository(ctx, repo.ID, &taskservice.UpdateRepositoryRequest{ DefaultBranch: &detected, }); err != nil { delivery.RecordDefaultBranchPersistError() a.logger.Error("failed to persist detected default branch: repository row keeps its prior "+ "default_branch and will read as default_branch_unknown in the delivery ledger until a "+ "future write succeeds", zap.String("repository_id", repo.ID), zap.String(branchFieldKey, detected), zap.Error(err)) } return detected } // detectGitDefaultBranch reads the default branch of a git repository. // Returns empty string on any failure. func detectGitDefaultBranch(repoPath string) string { branch, err := gitref.DefaultBranchOrEmpty(repoPath) if err != nil { return "" } return branch }