481359ddb2
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1154 lines
42 KiB
Go
1154 lines
42 KiB
Go
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
|
|
}
|