Files
kandev/apps/backend/internal/backendapp/helpers.go
T
2026-08-21 16:36:54 +01:00

1783 lines
69 KiB
Go

package backendapp
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
agentcapabilities "github.com/kandev/kandev/internal/agent/capabilities/handlers"
"github.com/kandev/kandev/internal/agent/docker"
agenthandlers "github.com/kandev/kandev/internal/agent/handlers"
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/loginpty"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
client "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers"
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/agentctl/tracing"
analyticshandlers "github.com/kandev/kandev/internal/analytics/handlers"
analyticsrepository "github.com/kandev/kandev/internal/analytics/repository"
"github.com/kandev/kandev/internal/auth"
"github.com/kandev/kandev/internal/auth/authn"
authhttpapi "github.com/kandev/kandev/internal/auth/httpapi"
"github.com/kandev/kandev/internal/automation"
"github.com/kandev/kandev/internal/azuredevops"
"github.com/kandev/kandev/internal/clarification"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/ports"
"github.com/kandev/kandev/internal/db"
debughandlers "github.com/kandev/kandev/internal/debug"
editorcontroller "github.com/kandev/kandev/internal/editors/controller"
editorhandlers "github.com/kandev/kandev/internal/editors/handlers"
"github.com/kandev/kandev/internal/entityrefs"
"github.com/kandev/kandev/internal/events/bus"
gateways "github.com/kandev/kandev/internal/gateway/websocket"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/gitlab"
"github.com/kandev/kandev/internal/health"
"github.com/kandev/kandev/internal/health/oslimits"
"github.com/kandev/kandev/internal/i18n"
"github.com/kandev/kandev/internal/improvekandev"
"github.com/kandev/kandev/internal/integrations/workspacescope"
"github.com/kandev/kandev/internal/jira"
"github.com/kandev/kandev/internal/linear"
lspinstaller "github.com/kandev/kandev/internal/lsp/installer"
mcphandlers "github.com/kandev/kandev/internal/mcp/handlers"
mcpscope "github.com/kandev/kandev/internal/mcp/scope"
mcpserver "github.com/kandev/kandev/internal/mcp/server"
notificationcontroller "github.com/kandev/kandev/internal/notifications/controller"
notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers"
"github.com/kandev/kandev/internal/office"
officeagents "github.com/kandev/kandev/internal/office/agents"
officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite"
officetestharness "github.com/kandev/kandev/internal/office/testharness"
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/plugins"
pluginstore "github.com/kandev/kandev/internal/plugins/store"
"github.com/kandev/kandev/internal/profiles"
promptcontroller "github.com/kandev/kandev/internal/prompts/controller"
prompthandlers "github.com/kandev/kandev/internal/prompts/handlers"
"github.com/kandev/kandev/internal/quickterminal"
"github.com/kandev/kandev/internal/repoclone"
"github.com/kandev/kandev/internal/runtimeflags"
"github.com/kandev/kandev/internal/secrets"
"github.com/kandev/kandev/internal/sentry"
spriteshandlers "github.com/kandev/kandev/internal/sprites"
sshhandlers "github.com/kandev/kandev/internal/ssh"
systemsvc "github.com/kandev/kandev/internal/system"
"github.com/kandev/kandev/internal/system/storage/tempartifacts"
taskdto "github.com/kandev/kandev/internal/task/dto"
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
"github.com/kandev/kandev/internal/task/models"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
taskservice "github.com/kandev/kandev/internal/task/service"
usercontroller "github.com/kandev/kandev/internal/user/controller"
userhandlers "github.com/kandev/kandev/internal/user/handlers"
utilitycontroller "github.com/kandev/kandev/internal/utility/controller"
utilityhandlers "github.com/kandev/kandev/internal/utility/handlers"
"github.com/kandev/kandev/internal/webapp"
webembedded "github.com/kandev/kandev/internal/webapp/embedded"
workflowcontroller "github.com/kandev/kandev/internal/workflow/controller"
workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers"
"github.com/kandev/kandev/internal/workflowsync"
"github.com/kandev/kandev/internal/worktree"
ws "github.com/kandev/kandev/pkg/websocket"
)
const (
desktopHealthTokenEnv = "KANDEV_DESKTOP_HEALTH_TOKEN"
desktopHealthTokenHeader = "X-Kandev-Desktop-Health-Token"
agentShutdownTimeout = 20 * time.Second
httpShutdownTimeout = 10 * time.Second
tracingShutdownTimeout = 5 * time.Second
addedFieldKey = "added"
branchFieldKey = "branch"
branchAdditionsFieldKey = "branch_additions"
branchDeletionsFieldKey = "branch_deletions"
deletedFieldKey = "deleted"
versionFieldKey = "version"
kandevName = "kandev"
startingStatus = "starting"
)
// buildSessionDataProvider constructs the session data provider function used by the WebSocket hub
// to send initial data (git status, context window, available commands) when a client subscribes.
func buildSessionDataProvider(
taskRepo *sqliterepo.Repository,
lifecycleMgr *lifecycle.Manager,
cancellationProvider taskdto.CancellationPendingProvider,
log *logger.Logger,
) func(context.Context, string) ([]*ws.Message, error) {
return func(ctx context.Context, sessionID string) ([]*ws.Message, error) {
session, err := taskRepo.GetTaskSession(ctx, sessionID)
if err != nil {
return nil, nil // Session not found, no data to send
}
var result []*ws.Message
result = appendSessionStateMessageWithCancellation(sessionID, session, cancellationProvider, result)
result = appendAgentctlStatusMessage(ctx, lifecycleMgr, sessionID, result, log)
result = appendLiveGitStatusMessage(ctx, taskRepo, lifecycleMgr, sessionID, session, result, log)
result = appendContextWindowMessage(sessionID, session, result)
result = appendAvailableCommandsMessage(sessionID, session, lifecycleMgr, result)
result = appendSessionModeMessage(sessionID, session, lifecycleMgr, result)
result = appendSessionModelsMessage(sessionID, session, lifecycleMgr, result)
return result, nil
}
}
// buildSessionGitDataProvider constructs the narrow provider used by the diff
// panel's explicit refresh request. It intentionally does not hydrate session
// state, agent readiness, commands, mode, models, or context-window data.
func buildSessionGitDataProvider(taskRepo *sqliterepo.Repository, lifecycleMgr *lifecycle.Manager, log *logger.Logger) func(context.Context, string) ([]*ws.Message, error) {
return func(ctx context.Context, sessionID string) ([]*ws.Message, error) {
session, err := taskRepo.GetTaskSession(ctx, sessionID)
if err != nil {
return nil, nil
}
return appendLiveGitStatusMessage(ctx, taskRepo, lifecycleMgr, sessionID, session, nil, log), nil
}
}
const sessionIDPayloadKey = "session_id"
const taskIDPayloadKey = "task_id"
const newStatePayloadKey = "new_state"
const sessionUpdatedAtPayloadKey = "updated_at"
// appendAgentctlStatusMessage snapshots the current agentctl readiness for a
// session so late-subscribing clients (page reload, task switch, WS reconnect)
// don't sit forever on "Connecting terminal..." waiting for events that have
// already fired.
//
// Non-blocking by design — sendSessionData runs in the WS read loop, so a
// network probe here would delay every subscribe/focus ACK by its timeout.
// Instead, replay the readiness cached by waitForAgentctlReady's successful
// health check. Agent process and workspace stream state are deliberately not
// used here: prepared sessions have a healthy agentctl before either exists.
// The subsequent waitForAgentctlReady event (or its error) corrects the status
// if the snapshot runs while startup is still in progress.
//
// Emits no message when the session has no live execution — the lazy
// create-on-terminal-connect path publishes events normally in that case.
func appendAgentctlStatusMessage(
_ context.Context,
lifecycleMgr *lifecycle.Manager,
sessionID string,
result []*ws.Message,
_ *logger.Logger,
) []*ws.Message {
if lifecycleMgr == nil {
return result
}
execution, ok := lifecycleMgr.GetExecutionBySessionID(sessionID)
if !ok {
return result
}
payload := map[string]interface{}{
sessionIDPayloadKey: sessionID,
"agent_execution_id": execution.ID,
}
if execution.TaskEnvironmentID != "" {
payload["task_environment_id"] = execution.TaskEnvironmentID
}
if execution.WorkspacePath != "" {
payload["worktree_path"] = execution.WorkspacePath
}
action := ws.ActionSessionAgentctlStarting
if execution.IsAgentctlReady() {
action = ws.ActionSessionAgentctlReady
}
notification, err := ws.NewNotification(action, payload)
if err != nil {
return result
}
return append(result, notification)
}
// appendSessionStateMessage always sends the current session state so clients
// that subscribe after a state change still receive the authoritative state.
//
// Includes task_environment_id when present so late-subscribing clients
// (page reload, task switch, WS reconnect) can seed environmentIdBySessionId
// — without it, env-routed shell terminals stall on "Connecting terminal...".
func appendSessionStateMessage(sessionID string, session *models.TaskSession, result []*ws.Message) []*ws.Message {
return appendSessionStateMessageWithCancellation(sessionID, session, nil, result)
}
func cancellationPendingSnapshot(
provider taskdto.CancellationPendingProvider,
sessionID string,
) (bool, uint64) {
if snapshotProvider, ok := provider.(taskdto.CancellationPendingSnapshotProvider); ok {
return snapshotProvider.CancellationPendingSnapshot(sessionID)
}
return provider.CancellationPending(sessionID), 0
}
func appendSessionStateMessageWithCancellation(
sessionID string,
session *models.TaskSession,
cancellationProvider taskdto.CancellationPendingProvider,
result []*ws.Message,
) []*ws.Message {
payload := map[string]interface{}{
sessionIDPayloadKey: sessionID,
taskIDPayloadKey: session.TaskID,
newStatePayloadKey: string(session.State),
sessionUpdatedAtPayloadKey: session.UpdatedAt.UTC().Format(time.RFC3339Nano),
"name": session.Name,
"cancellation_pending": false,
"cancellation_revision": uint64(0),
}
if cancellationProvider != nil {
pending, revision := cancellationPendingSnapshot(cancellationProvider, sessionID)
payload["cancellation_pending"] = pending
payload["cancellation_revision"] = revision
}
if session.ReviewStatus != models.ReviewStatusNone {
payload["review_status"] = string(session.ReviewStatus)
}
if session.Metadata != nil {
payload["session_metadata"] = session.Metadata
}
if session.TaskEnvironmentID != "" {
payload["task_environment_id"] = session.TaskEnvironmentID
}
notification, err := ws.NewNotification(ws.ActionSessionStateChanged, payload)
if err == nil {
result = append(result, notification)
}
return result
}
// appendLiveGitStatusMessage adds git status notification(s) by querying
// agentctl for live status. Multi-repo workspaces emit one notification per
// repo (stamped with repository_name); single-repo emits a single untagged
// notification. Falls back to DB snapshot if no execution exists (archived
// sessions only — the snapshot is workspace-wide, not per-repo).
func appendLiveGitStatusMessage(ctx context.Context, taskRepo *sqliterepo.Repository, lifecycleMgr *lifecycle.Manager, sessionID string, session *models.TaskSession, result []*ws.Message, log *logger.Logger) []*ws.Message {
if msgs := tryGetLiveGitStatus(ctx, lifecycleMgr, sessionID, log); len(msgs) > 0 {
return append(result, msgs...)
}
return appendDBSnapshotGitStatus(ctx, taskRepo, sessionID, result, log)
}
// tryGetLiveGitStatus attempts to get live git status from agentctl.
// Returns one notification per repo (one entry for single-repo workspaces).
// Returns nil when the session has no live execution or agentctl is stuck.
func tryGetLiveGitStatus(ctx context.Context, lifecycleMgr *lifecycle.Manager, sessionID string, log *logger.Logger) []*ws.Message {
if lifecycleMgr == nil {
return nil
}
execution, ok := lifecycleMgr.GetExecutionBySessionID(sessionID)
if !ok {
log.Debug("no execution found for session, will fall back to DB snapshot",
zap.String("session_id", sessionID))
return nil
}
agentClient := execution.GetAgentCtlClient()
if agentClient == nil {
log.Debug("no agentctl client available for session, will fall back to DB snapshot",
zap.String("session_id", sessionID))
return nil
}
// Use bounded timeout to prevent blocking session hydration if agentctl is stuck.
rpcCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
// Force fresh git query: cache can wedge when the poll loop misses a HEAD change.
multi, err := agentClient.GetGitStatusMultiFresh(rpcCtx)
if err != nil {
log.Debug("failed to get live git status, will fall back to DB snapshot",
zap.String("session_id", sessionID),
zap.Error(err))
return nil
}
if multi == nil || !multi.Success || len(multi.Repos) == 0 {
return nil
}
out := make([]*ws.Message, 0, len(multi.Repos))
for _, repo := range multi.Repos {
if !repo.Status.Success {
continue
}
notification := buildGitStatusNotification(sessionID, repo.RepositoryName, repo.Status)
if notification != nil {
out = append(out, notification)
}
}
if len(out) == 0 {
return nil
}
log.Debug("got live git status from agentctl",
zap.String("session_id", sessionID),
zap.Int("repos", len(out)))
return out
}
// buildGitStatusNotification packages a single repo's status as a WS event
// the frontend can route through its existing git-status handler. The
// repository_name is stamped on the inner status payload so the frontend
// stores it under byEnvironmentRepo[envKey][repository_name].
func buildGitStatusNotification(sessionID, repositoryName string, status client.GitStatusResult) *ws.Message {
statusPayload := map[string]interface{}{
branchFieldKey: status.Branch,
"remote_branch": status.RemoteBranch,
"head_commit": status.HeadCommit,
"base_commit": status.BaseCommit,
"ahead": status.Ahead,
"behind": status.Behind,
"remote_ahead": status.RemoteAhead,
"remote_behind": status.RemoteBehind,
"remote_head_commit": status.RemoteHeadCommit,
"files": status.Files,
"modified": status.Modified,
addedFieldKey: status.Added,
deletedFieldKey: status.Deleted,
"untracked": status.Untracked,
"renamed": status.Renamed,
branchAdditionsFieldKey: status.BranchAdditions,
branchDeletionsFieldKey: status.BranchDeletions,
"is_submodule": status.IsSubmodule,
}
if repositoryName != "" {
statusPayload["repository_name"] = repositoryName
}
gitEventData := map[string]interface{}{
"type": "status_update",
"session_id": sessionID,
"timestamp": status.Timestamp,
"status": statusPayload,
}
notification, err := ws.NewNotification(ws.ActionSessionGitEvent, gitEventData)
if err != nil {
return nil
}
return notification
}
// appendDBSnapshotGitStatus appends a git status notification from DB snapshot.
func appendDBSnapshotGitStatus(ctx context.Context, taskRepo *sqliterepo.Repository, sessionID string, result []*ws.Message, log *logger.Logger) []*ws.Message {
log.Debug("falling back to DB snapshot for git status",
zap.String("session_id", sessionID))
latestSnapshot, err := taskRepo.GetLatestGitSnapshot(ctx, sessionID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Expected for sessions that have not produced a snapshot yet.
return result
}
log.Warn("failed to load DB snapshot for session",
zap.String("session_id", sessionID),
zap.Error(err))
return result
}
if latestSnapshot == nil {
log.Debug("no DB snapshot found for session",
zap.String("session_id", sessionID))
return result
}
metadata := latestSnapshot.Metadata
gitEventData := map[string]interface{}{
"type": "status_update",
"session_id": sessionID,
"timestamp": metadata["timestamp"],
"status": map[string]interface{}{
branchFieldKey: latestSnapshot.Branch,
"remote_branch": latestSnapshot.RemoteBranch,
"head_commit": latestSnapshot.HeadCommit,
"base_commit": latestSnapshot.BaseCommit,
"ahead": latestSnapshot.Ahead,
"behind": latestSnapshot.Behind,
"remote_ahead": metadata["remote_ahead"],
"remote_behind": metadata["remote_behind"],
"remote_head_commit": metadata["remote_head_commit"],
"files": latestSnapshot.Files,
"modified": metadata["modified"],
addedFieldKey: metadata[addedFieldKey],
deletedFieldKey: metadata[deletedFieldKey],
"untracked": metadata["untracked"],
"renamed": metadata["renamed"],
branchAdditionsFieldKey: metadata[branchAdditionsFieldKey],
branchDeletionsFieldKey: metadata[branchDeletionsFieldKey],
},
}
notification, err := ws.NewNotification(ws.ActionSessionGitEvent, gitEventData)
if err == nil {
result = append(result, notification)
}
return result
}
// appendContextWindowMessage adds a context window notification to result if available.
func appendContextWindowMessage(sessionID string, session *models.TaskSession, result []*ws.Message) []*ws.Message {
if session.Metadata == nil {
return result
}
contextWindow, ok := session.Metadata[models.SessionMetaKeyContextWindow]
if !ok {
return result
}
metadata := map[string]interface{}{
models.SessionMetaKeyContextWindow: contextWindow,
}
if count, present := session.Metadata[models.SessionMetaKeyContextCompactionCount]; present {
metadata[models.SessionMetaKeyContextCompactionCount] = count
}
notification, err := ws.NewNotification(ws.ActionSessionStateChanged, map[string]interface{}{
"session_id": sessionID,
"task_id": session.TaskID,
"metadata": metadata,
})
if err == nil {
result = append(result, notification)
}
return result
}
// appendAvailableCommandsMessage adds available slash commands notification to result if any.
func appendAvailableCommandsMessage(sessionID string, session *models.TaskSession, lifecycleMgr *lifecycle.Manager, result []*ws.Message) []*ws.Message {
if lifecycleMgr == nil {
return result
}
commands := lifecycleMgr.GetAvailableCommandsForSession(sessionID)
if len(commands) == 0 {
return result
}
notification, err := ws.NewNotification(ws.ActionSessionAvailableCommands, map[string]interface{}{
"session_id": sessionID,
"task_id": session.TaskID,
"available_commands": commands,
})
if err == nil {
result = append(result, notification)
}
return result
}
// appendSessionModeMessage adds session mode state notification to result if cached.
func appendSessionModeMessage(sessionID string, session *models.TaskSession, lifecycleMgr *lifecycle.Manager, result []*ws.Message) []*ws.Message {
if lifecycleMgr == nil {
return result
}
modeState := lifecycleMgr.GetModeStateForSession(sessionID)
if modeState == nil || (modeState.CurrentModeID == "" && len(modeState.AvailableModes) == 0) {
return result
}
notification, err := ws.NewNotification(ws.ActionSessionModeChanged, lifecycle.SessionModeEventPayload{
TaskID: session.TaskID,
SessionID: sessionID,
CurrentModeID: modeState.CurrentModeID,
AvailableModes: modeState.AvailableModes,
})
if err == nil {
result = append(result, notification)
}
return result
}
// appendSessionModelsMessage adds session models state notification to result if cached.
func appendSessionModelsMessage(sessionID string, session *models.TaskSession, lifecycleMgr *lifecycle.Manager, result []*ws.Message) []*ws.Message {
if lifecycleMgr == nil {
return result
}
modelState := lifecycleMgr.GetModelStateForSession(sessionID)
if modelState == nil || (modelState.CurrentModelID == "" && len(modelState.Models) == 0) {
return result
}
snapshot, _ := lifecycle.LoadSessionModelsSnapshot(session.Metadata[models.SessionMetaKeyACPModelState])
notification, err := ws.NewNotification(ws.ActionSessionModelsUpdated, lifecycle.SessionModelsEventPayload{
TaskID: session.TaskID,
SessionID: sessionID,
CurrentModelID: modelState.CurrentModelID,
Models: modelState.Models,
ConfigOptions: modelState.ConfigOptions,
ConfigOptionsSettled: modelState.ConfigOptionsSettled || snapshot.ConfigOptionsSettled,
ConfigBaseline: sessionACPConfigBaseline(session),
})
if err == nil {
result = append(result, notification)
}
return result
}
func sessionACPConfigBaseline(session *models.TaskSession) map[string]string {
if session == nil {
return nil
}
baseline, _ := models.LoadSessionACPConfigBaseline(session.Metadata)
return baseline
}
// routeParams holds all dependencies needed for HTTP and WebSocket route registration.
type routeParams struct {
router *gin.Engine
gateway *gateways.Gateway
taskSvc *taskservice.Service
taskRepo *sqliterepo.Repository
officeRepo *officesqlite.Repository
analyticsRepo analyticsrepository.Repository
orchestratorSvc *orchestrator.Service
lifecycleMgr *lifecycle.Manager
loginMgr *loginpty.Manager
quickTerminalSvc *quickterminal.Service
hostUtilityMgr *hostutility.Manager
eventBus bus.EventBus
services *Services
systemSvc *systemsvc.Service
workspaceRestorer taskhandlers.WorkspaceQuarantineRestorer
temporaryArtifacts *tempartifacts.Registry
runtimeFlagsSvc *runtimeflags.Service
dbPool *db.Pool
agentSettingsController *agentsettingscontroller.Controller
agentSettingsRepo settingsstore.Repository
agentList taskhandlers.AgentLister
agentRegistry *registry.Registry
userCtrl *usercontroller.Controller
notificationCtrl *notificationcontroller.Controller
editorCtrl *editorcontroller.Controller
promptCtrl *promptcontroller.Controller
utilityCtrl *utilitycontroller.Controller
msgCreator *messageCreatorAdapter
secretsSvc *secrets.Service
secretStore secrets.SecretStore
mcpConfigSvc *mcpconfig.Service
authSvc *auth.Service
agentRuntimeAvailability *client.Availability
addCleanup func(func() error)
repoCloner *repoclone.Cloner
version string
webInternalURL string
webTitlePrefix string
devMode bool
httpPort int
features config.FeaturesConfig
homeDir string
interimSettingsInterlockToken string
log *logger.Logger
}
// registerRoutes sets up all HTTP and WebSocket routes on the given router.
func registerRoutes(p routeParams) {
workflowCtrl := workflowcontroller.NewController(p.services.Workflow)
planService := taskservice.NewPlanService(p.taskRepo, p.eventBus, p.log)
// Per-user task scoping for plan reads/writes (opt-in auth).
planService.SetTaskAuthorizer(p.taskSvc.AuthorizeTaskAccess)
clarificationStore := clarification.NewStore(2 * time.Hour)
clarificationCanceller := clarification.NewCanceller(clarificationStore, p.taskRepo, p.taskSvc, p.log)
p.orchestratorSvc.SetClarificationCanceller(clarificationCanceller)
p.taskSvc.SetClarificationCanceller(clarificationCanceller)
// Wire pending clarification requests into the office inbox.
if p.services.OfficeSvcs != nil && p.services.OfficeSvcs.Dashboard != nil {
p.services.OfficeSvcs.Dashboard.SetPermissionLister(clarificationStore)
}
// Office task-handoffs phase 4 + 5 — single HandoffService instance
// shared by the MCP path (office agents) and the HTTP path (Kanban
// subtask UI). Constructed here so registerTaskRoutes can wire it
// into TaskHandlers.SetHandoffService and registerMCPAndDebugRoutes
// reuses the same instance via SetHandoffService on mcpHandlers.
handoffDocSvc := taskservice.NewDocumentService(p.taskRepo, p.log)
handoffSvc := taskservice.NewHandoffService(p.taskRepo, p.taskRepo, handoffDocSvc,
p.officeRepo, p.officeRepo, p.log)
// Phase 6 wirings — materializer hook + disk cleaner. The
// SessionWorktreeReader and WorkspaceCleaner interfaces are both
// satisfied by adapters that delegate to existing services.
handoffSvc.SetSessionReader(p.taskRepo)
if p.lifecycleMgr != nil {
if wtMgr := p.lifecycleMgr.WorktreeManager(); wtMgr != nil {
handoffSvc.SetWorkspaceCleaner(worktree.NewHandoffCleaner(wtMgr, p.log))
}
}
handoffSvc.SetRunCanceller(p.orchestratorSvc)
// Cascade archive/delete must re-publish task.updated / task.deleted
// events; HandoffService walks the repo directly and bypasses the
// Service wrappers that normally publish these. Without this wiring
// the kanban board doesn't react to subtree archive/delete until a
// full reload.
handoffSvc.SetTaskEventPublisher(p.taskSvc)
// Per-user scoping for the cascade is installed by
// TaskHandlers.SetHandoffService, which is the call that makes the archive /
// delete routes prefer the cascade over the guarded Service methods.
if p.services.Office != nil {
p.services.Office.SetWorkspaceGroupCleaner(handoffSvc)
}
// Cascade archive/delete must tear down runtime resources
// (container, sandbox, worktree, executor_running rows) for every
// task in the tree. Without this wiring the agent gets stopped via
// runCanceller but its container leaks because the cascade bypasses
// Service.ArchiveTask's runAsyncTaskCleanup branch.
handoffSvc.SetTaskResourceCleaner(p.taskSvc)
// Watch reset (Reset button on integration settings) cascade-deletes
// every task a watch previously created. The integrations re-use the
// shared HandoffService so the reset path goes through the same
// cleanup machinery as the regular delete-task surface.
if p.services.GitHub != nil {
p.services.GitHub.SetCascadeTaskDeleter(handoffSvc)
}
if p.services.GitLab != nil {
p.services.GitLab.SetCascadeTaskDeleter(handoffSvc)
}
if p.services.AzureDevOps != nil {
p.services.AzureDevOps.SetCascadeTaskDeleter(handoffSvc)
}
// repoLookup validates a watcher's optional repository binding (workspace
// ownership + default-branch fill) on create/update. Shared across the three
// repo-less watchers; one concrete adapter satisfies each package's
// structurally-identical RepositoryLookup interface.
repoLookup := &repositoryLookupAdapter{svc: p.taskSvc}
if p.services.GitLab != nil {
p.services.GitLab.SetRepositoryLookup(repoLookup)
p.services.GitLab.SetWatchDependencyValidator(&gitLabWatchDependencyValidator{
tasks: p.taskSvc, workflows: p.services.Workflow, agents: p.agentSettingsRepo,
})
}
if p.services.AzureDevOps != nil {
p.services.AzureDevOps.SetWatchRepositoryLookup(repoLookup)
p.services.AzureDevOps.SetWatchDependencyValidator(&gitLabWatchDependencyValidator{
tasks: p.taskSvc, workflows: p.services.Workflow, agents: p.agentSettingsRepo,
})
}
if p.services.Jira != nil {
p.services.Jira.SetTaskDeleter(handoffSvc)
p.services.Jira.SetRepositoryLookup(repoLookup)
p.services.Jira.SetWorkspaceAuthorizer(p.taskSvc.AuthorizeWorkspaceAccess)
}
if p.services.Linear != nil {
p.services.Linear.SetTaskDeleter(handoffSvc)
p.services.Linear.SetRepositoryLookup(repoLookup)
p.services.Linear.SetWorkspaceAuthorizer(p.taskSvc.AuthorizeWorkspaceAccess)
}
if p.services.Sentry != nil {
p.services.Sentry.SetTaskDeleter(handoffSvc)
p.services.Sentry.SetRepositoryLookup(repoLookup)
}
if p.services.Automation != nil {
p.services.Automation.Service.SetRepositoryLookup(repoLookup)
}
p.orchestratorSvc.SetWorkspaceMaterializer(handoffSvc)
// Phase 8 prompt enrichment — wire the office scheduler's
// TaskContextProvider so every run prompt rendered by the active
// office/service/BuildPrompt sees Related-tasks / Documents
// available / Workspace sections.
if p.services.OrchScheduler != nil {
p.services.OrchScheduler.SetTaskContextProvider(handoffSvc)
}
p.gateway.SetupRoutes(p.router)
registerTaskRoutes(p, planService, handoffSvc)
registerSecondaryRoutes(p, workflowCtrl, clarificationStore, clarificationCanceller, planService, handoffSvc)
if p.authSvc != nil {
authhttpapi.RegisterRoutes(p.router, p.authSvc, p.log)
}
// /health is a readiness probe, not a liveness probe. It only
// returns 200 after main has flipped the package-level `ready`
// flag — which happens after route registration, agent-registry
// seeding, the HTTP listener accepting connections, and (when
// KANDEV_E2E_MOCK is set) the testharness routes being mounted.
// Before that, return 503 so callers (including the e2e fixture's
// waitForHealth) keep polling instead of racing ahead and hitting
// 404s on routes that aren't wired yet.
p.router.GET("/health", healthHandler(p))
// /api/v1/features is a public, unauthenticated read of the runtime
// feature-flag map. The frontend SSR-fetches it once per page render to
// decide whether to mount Office (and any future flagged feature). The
// `json:` tags on FeaturesConfig drive serialization, so adding a new
// field to the struct is enough — no edit here required.
// See docs/decisions/0007-runtime-feature-flags.md.
p.router.GET("/api/v1/features", func(c *gin.Context) {
c.JSON(http.StatusOK, p.features)
})
p.router.GET("/api/v1/app-state", func(c *gin.Context) {
routePath := c.Query("path")
if routePath == "" {
routePath = c.Request.URL.Path
}
route := webapp.ClassifyRoute(routePath)
payload := bootPayload(c.Request.Context(), c.Request, p, route)
c.JSON(http.StatusOK, payload)
})
if p.webInternalURL == "" {
if handler, distDir, ok := newWebAppHandler(p); ok {
p.router.NoRoute(func(c *gin.Context) {
handler.ServeHTTP(c.Writer, c.Request)
})
p.log.Info("Web SPA static serving enabled", zap.String("dist_dir", distDir))
return
}
}
if p.webInternalURL != "" {
handler, err := newWebDevHandler(p)
if err != nil {
p.log.Error("Invalid web internal URL, skipping web dev handler", zap.String("url", p.webInternalURL), zap.Error(err))
} else {
p.router.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/") || path == "/ws" || path == "/health" {
c.AbortWithStatus(http.StatusNotFound)
return
}
// httputil.ReverseProxy panics with http.ErrAbortHandler as an
// intentional stdlib signal when a streaming response is aborted
// (e.g., the client disconnects mid-body, or the upstream dies
// after response headers were already written). net/http.Server
// understands this sentinel panic and closes the connection
// quietly, but Gin's recovery middleware catches it first and
// logs a noisy stack trace. Swallow that specific panic here
// while letting any other panic bubble up to Gin's recovery.
defer func() {
if r := recover(); r != nil && r != http.ErrAbortHandler {
panic(r)
}
}()
handler.ServeHTTP(c.Writer, c.Request)
})
p.log.Info("Web dev handler enabled", zap.String("target", p.webInternalURL))
}
}
}
// healthHandler serves GET /health. The response always carries the
// process's running version — in both the ready and not-ready bodies — so an
// operator can identify the build of a backend that is stuck starting, and so
// a monitor never needs a credential to read it (see docs/specs/
// health-endpoint-version/spec.md).
func healthHandler(p routeParams) gin.HandlerFunc {
// p.version is normally the package-level Version wired in by run()'s
// registerRoutes call. Falling back here keeps the never-empty guarantee
// (AC-11) true regardless of that call-site wiring, since standing up
// run()'s full DI graph in a test just to exercise that one field
// assignment is out of proportion to this change.
version := p.version
if version == "" {
version = Version
}
return func(c *gin.Context) {
if !ready.Load() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": startingStatus,
"service": kandevName,
versionFieldKey: version,
})
return
}
if token := desktopHealthToken(); token != "" {
c.Header(desktopHealthTokenHeader, token)
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": kandevName,
"mode": "websocket+http",
versionFieldKey: version,
})
}
}
func desktopHealthToken() string {
return strings.TrimSpace(os.Getenv(desktopHealthTokenEnv))
}
func newWebAppHandler(p routeParams) (*webapp.Handler, string, bool) {
assets, source, ok := webAssetsFS()
if !ok {
return nil, "", false
}
handler := webapp.NewHandler(assets, webAppHandlerOptions(p)...)
return handler, source, true
}
func newWebDevHandler(p routeParams) (*webapp.DevHandler, error) {
return webapp.NewDevHandler(p.webInternalURL, webAppHandlerOptions(p)...)
}
func webAppHandlerOptions(p routeParams) []webapp.HandlerOption {
return []webapp.HandlerOption{
webapp.WithPayloadBuilder(func(req *http.Request, route webapp.RouteClassification) webapp.BootPayload {
return bootPayload(req.Context(), req, p, route)
}),
}
}
// webRuntimeConfig builds the SPA's runtime block. `req` supplies the active
// locale (from the kandev_locale cookie) so the shell can set <html lang> and the
// client can activate the right catalog before first paint.
func webRuntimeConfig(debug bool, titlePrefix string, req *http.Request) webapp.RuntimeConfig {
return webapp.RuntimeConfig{
APIPrefix: "/api/v1",
WebSocketPath: "/ws",
LSPAutoInstallPreferenceLanguages: lspinstaller.AutoInstallPreferenceLanguages(),
Debug: debug,
// Gates QA-only UI (the pseudo-locale option). Separate from Debug: the
// e2e harness serves a PRODUCTION bundle, so the frontend cannot infer
// this from its own build mode.
NonProduction: profiles.DetectEnvironment() != profiles.EnvProd,
Locale: i18n.FromRequest(req),
TitlePrefix: strings.TrimSpace(titlePrefix),
}
}
func bootPayload(ctx context.Context, req *http.Request, p routeParams, route webapp.RouteClassification) webapp.BootPayload {
initialState := bootInitialState(ctx, req, p, route)
routeData := bootRouteData(ctx, req, p, route)
if route.Route == webapp.RouteTaskDetail && routeData == nil && canLoadTaskDetailFallback(req, p.authSvc) {
bootStateBuilder{p: p}.addHomeKanbanRouteState(ctx, req, initialState)
}
payload := webapp.NewBootPayload(
route,
webRuntimeConfig(p.devMode, p.webTitlePrefix, req),
initialState,
)
payload.RouteData = routeData
payload.Plugins = bootActivePlugins(p)
payload.InterimSettingsInterlockToken = p.interimSettingsInterlockToken
return payload
}
func canLoadTaskDetailFallback(req *http.Request, authSvc *auth.Service) bool {
if authSvc == nil || authSvc.Mode() == auth.ModeDisabled {
return true
}
if req == nil {
return false
}
_, ok := authn.IdentityFromContext(req.Context())
return ok
}
// bootActivePlugins populates the boot payload's Plugins list from every
// active, UI-bundle-declaring plugin, per
// docs/plans/plugins/PLUGIN-API.md ("Loading model").
func bootActivePlugins(p routeParams) []webapp.ActivePluginPayload {
if p.services == nil || p.services.Plugins == nil {
return nil
}
records := p.services.Plugins.ActiveUIPlugins()
out := make([]webapp.ActivePluginPayload, 0, len(records))
for _, rec := range records {
entry := webapp.ActivePluginPayload{
ID: rec.ID,
Name: rec.DisplayName,
BundleURL: pluginBundleURL(rec),
StyleURLs: pluginStyleURLs(rec),
}
if rec.RepositoryProviders != nil {
providerIDs := make([]string, len(rec.RepositoryProviders))
copy(providerIDs, rec.RepositoryProviders)
entry.RepositoryProviderIDs = &providerIDs
}
out = append(out, entry)
}
return out
}
// pluginBundleURL builds the browser-facing bundle URL, mirrored on the
// frontend by lib/plugins/active-plugin.ts's toActivePlugin. The `?v=`
// query param keys the URL on the installed version so an updated plugin
// resolves to a *different* module specifier: without it,
// unloadPlugin(id, {evictCache: true}) (apps/web/lib/plugins/host.ts) drops
// the cached bundle registration on update, but a same-tab re-import() of
// the identical URL returns the browser's already-evaluated ES module
// without re-running its top-level registerKandevPlugin() call — leaving
// the plugin active but unregistered. An unchanged version keeps the same
// URL across boots, so normal (non-update) loads stay cache-friendly.
func pluginBundleURL(rec pluginstore.Record) string {
return "/api/plugins/" + rec.ID + "/bundle?v=" + url.QueryEscape(rec.Version)
}
// pluginStyleURLs maps a plugin's root-relative ui.styles paths to
// browser-facing URLs served through the /api/plugins/:id/ui/* proxy (the
// plugin's own base_url is never exposed to the browser directly).
func pluginStyleURLs(rec pluginstore.Record) []string {
if len(rec.UI.Styles) == 0 {
return nil
}
urls := make([]string, 0, len(rec.UI.Styles))
for _, style := range rec.UI.Styles {
urls = append(urls, "/api/plugins/"+rec.ID+"/ui"+style)
}
return urls
}
func webAssetsFS() (fs.FS, string, bool) {
distDir := os.Getenv("KANDEV_WEB_DIST_DIR")
if distDir == "" {
distDir = firstExistingDir("apps/web/dist", "../web/dist", "../../apps/web/dist")
}
if distDir != "" {
return os.DirFS(distDir), distDir, true
}
assets, err := webembedded.FS()
if err != nil {
return nil, "", false
}
return assets, "embedded", true
}
func firstExistingDir(candidates ...string) string {
for _, candidate := range candidates {
info, err := os.Stat(candidate)
if err == nil && info.IsDir() {
return candidate
}
}
return ""
}
// resolvePrimaryTaskRepositoryID returns the primary (lowest-position)
// task_repositories.repository_id for a task, or "" if none / on error.
// Used by PR-import callbacks where the PR is associated with the task's
// primary repo (the one the task was created against).
func resolvePrimaryTaskRepositoryID(ctx context.Context, taskRepo *sqliterepo.Repository, taskID string, log *logger.Logger) string {
repo, err := taskRepo.GetPrimaryTaskRepository(ctx, taskID)
if err != nil {
log.Warn("primary task repository lookup failed",
zap.String("task_id", taskID), zap.Error(err))
return ""
}
if repo == nil {
return ""
}
return repo.RepositoryID
}
// resolveRepositoryIDForSubpath maps a multi-repo subpath name (e.g.
// "kandev") to its task_repositories.repository_id by joining with the
// repositories table on Name. Empty subpath falls back to the primary
// repository so single-repo tasks Just Work. Returns "" if no match — the
// caller will then write a legacy single-repo PR row.
func resolveRepositoryIDForSubpath(ctx context.Context, taskRepo *sqliterepo.Repository, taskID, subpath string, log *logger.Logger) string {
if subpath == "" {
return resolvePrimaryTaskRepositoryID(ctx, taskRepo, taskID, log)
}
repos, err := taskRepo.ListTaskRepositories(ctx, taskID)
if err != nil {
log.Warn("task repositories lookup failed",
zap.String("task_id", taskID), zap.Error(err))
return ""
}
for _, link := range repos {
repo, err := taskRepo.GetRepository(ctx, link.RepositoryID)
if err != nil || repo == nil {
continue
}
if repo.Name == subpath || worktree.SanitizeRepoDirName(repo.Name) == subpath {
return link.RepositoryID
}
}
log.Warn("no task repository matches subpath",
zap.String("task_id", taskID), zap.String("subpath", subpath))
return ""
}
func resolveRepositoryIDForSessionSubpath(ctx context.Context, taskRepo *sqliterepo.Repository, sessionID, subpath string, log *logger.Logger) string {
worktrees, err := taskRepo.ListTaskSessionWorktrees(ctx, sessionID)
if err != nil {
log.Warn("session worktrees lookup failed",
zap.String("session_id", sessionID), zap.Error(err))
return ""
}
if subpath == "" {
if len(worktrees) == 1 {
return worktrees[0].RepositoryID
}
log.Warn("branch rename did not specify repo for multi-repo session",
zap.String("session_id", sessionID), zap.Int("worktree_count", len(worktrees)))
return ""
}
for _, wt := range worktrees {
// Multi-repo sessions are small today and repository lookup is only on
// branch rename, so keep this direct until the repository interface grows
// a batch lookup.
repo, err := taskRepo.GetRepository(ctx, wt.RepositoryID)
if err != nil || repo == nil {
continue
}
if repo.Name == subpath || worktree.SanitizeRepoDirName(repo.Name) == subpath {
return wt.RepositoryID
}
}
log.Warn("no session worktree repository matches subpath",
zap.String("session_id", sessionID), zap.String("subpath", subpath))
return ""
}
// registerTaskRoutes registers all task-related HTTP and WebSocket routes.
func registerTaskRoutes(p routeParams, planService *taskservice.PlanService, handoffSvc *taskservice.HandoffService) {
if attachmentSvc := p.taskSvc.AttachmentService(); attachmentSvc != nil {
taskhandlers.RegisterAttachmentRoutes(p.router, attachmentSvc, p.log)
} else {
p.log.Warn("prompt attachment routes disabled: attachment service is unavailable")
}
taskhandlers.RegisterWorkspaceRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log)
if p.services != nil {
registerMentionRoutes(p.router, p.services.Mentions)
}
workflowH := taskhandlers.RegisterWorkflowRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.services.Workflow, p.log)
workflowH.SetForegroundActivityProvider(p.orchestratorSvc)
taskH := taskhandlers.RegisterTaskRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.orchestratorSvc, p.taskRepo, planService, p.log)
if p.services != nil && p.services.User != nil {
taskH.SetTaskCreateLastUsedRecorder(p.services.User)
}
if handoffSvc != nil {
taskH.SetHandoffService(handoffSvc)
}
if p.workspaceRestorer != nil {
taskH.SetWorkspaceQuarantineRestorer(p.workspaceRestorer)
}
if p.services.GitHub != nil {
ghSvc := p.services.GitHub
taskH.SetOnTaskCreatedWithPR(func(ctx context.Context, taskID, sessionID, prURL, branch string) {
// Task-create-from-PR runs once per task and the PR maps to the
// primary repository (first task_repository row). Resolve to that
// repository_id so the resulting TaskPR/PRWatch are scoped per-repo.
repositoryID := resolvePrimaryTaskRepositoryID(ctx, p.taskRepo, taskID, p.log)
task, taskErr := p.taskRepo.GetTask(ctx, taskID)
if taskErr != nil || task == nil || task.WorkspaceID == "" {
p.log.Warn("cannot associate GitHub PR without task workspace", zap.String("task_id", taskID), zap.Error(taskErr))
return
}
if err := ghSvc.AssociatePRByURLForWorkspace(
ctx, task.WorkspaceID, github.DefaultUserID,
sessionID, taskID, repositoryID, prURL, branch,
); err != nil {
p.log.Warn("failed to associate task GitHub PR", zap.String("task_id", taskID), zap.Error(err))
}
})
}
taskhandlers.RegisterRepositoryRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log)
taskhandlers.RegisterRepositorySetRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log)
taskhandlers.RegisterExecutorRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log)
taskhandlers.RegisterExecutorProfileRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.agentList, p.log)
taskhandlers.RegisterEnvironmentRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log)
var referenceValidators []entityrefs.SubmissionValidator
if p.services != nil && p.services.Mentions != nil && p.services.Mentions.Submission != nil {
referenceValidators = append(referenceValidators, p.services.Mentions.Submission)
}
taskhandlers.RegisterMessageRoutes(
p.router, p.gateway.Dispatcher, p.taskSvc,
&orchestratorWrapper{svc: p.orchestratorSvc}, p.log, referenceValidators...,
)
taskhandlers.RegisterProcessRoutes(p.router, p.taskSvc, p.lifecycleMgr, p.log)
analyticshandlers.RegisterStatsRoutes(p.router, p.analyticsRepo, p.log)
agenthandlers.RegisterShellRoutes(p.router, p.lifecycleMgr, p.log)
if p.services.Share != nil {
p.services.Share.RegisterRoutes(p.router)
p.log.Debug("Registered Public Share Links handlers (HTTP)")
}
p.log.Debug("Registered Task Service handlers (HTTP + WebSocket)")
}
// registerSecondaryRoutes registers workflow, agent settings, user, notification, editor,
// prompt, clarification, MCP, and debug routes.
func registerSecondaryRoutes(
p routeParams,
workflowCtrl *workflowcontroller.Controller,
clarificationStore *clarification.Store,
clarificationCanceller *clarification.Canceller,
planService *taskservice.PlanService,
handoffSvc *taskservice.HandoffService,
) {
workflowhandlers.RegisterRoutes(p.router, p.gateway.Dispatcher, workflowCtrl, p.eventBus, p.log)
p.log.Info("Registered Workflow handlers (HTTP + WebSocket)")
agentsettingshandlers.RegisterRoutes(p.router, p.agentSettingsController, p.gateway.Hub, p.log, p.interimSettingsInterlockToken)
p.log.Debug("Registered Agent Settings handlers (HTTP)")
// Login PTY: spawns agent login commands under a PTY on the kandev host
// (claude auth login, auggie login, ...). The manager is shared with Quick
// Terminal so descriptor lifecycle callbacks observe the same sessions.
loginHandlers := loginpty.NewHandlers(p.loginMgr, p.agentRegistry, p.log.Zap(), nil)
if p.quickTerminalSvc != nil {
// Guard the assignment: passing a nil *quickterminal.Service straight
// into the interface would create a typed-nil binder that is non-nil to
// the handler's nil check but panics when invoked.
loginHandlers.SetHostShellSessionBinder(p.quickTerminalSvc)
}
loginHandlers.RegisterRoutes(p.router)
if p.quickTerminalSvc != nil {
p.quickTerminalSvc.RegisterRoutes(p.router)
}
p.log.Debug("Registered Login PTY handlers (HTTP + WebSocket)")
userhandlers.RegisterRoutes(p.router, p.gateway.Dispatcher, p.userCtrl, p.log)
p.log.Debug("Registered User handlers (HTTP + WebSocket)")
notificationhandlers.RegisterRoutes(p.router, p.notificationCtrl, p.log)
p.log.Debug("Registered Notification handlers (HTTP)")
editorhandlers.RegisterRoutes(p.router, p.editorCtrl, p.log)
p.log.Debug("Registered Editors handlers (HTTP)")
prompthandlers.RegisterRoutes(p.router, p.promptCtrl, p.log)
p.log.Debug("Registered Prompts handlers (HTTP)")
utilityhandlers.RegisterRoutes(p.router, p.utilityCtrl, p.lifecycleMgr, p.hostUtilityMgr, p.services.User, p.log)
p.log.Debug("Registered Utility Agents handlers (HTTP)")
agentcapabilities.RegisterRoutes(p.router, p.hostUtilityMgr, p.log)
p.log.Debug("Registered Agent Capabilities handlers (HTTP)")
clarification.RegisterRoutes(
p.router,
clarificationStore,
p.gateway.Hub,
p.msgCreator,
p.taskRepo,
p.eventBus,
p.orchestratorSvc,
p.log,
)
p.log.Debug("Registered Clarification handlers (HTTP)")
if p.secretsSvc != nil {
secrets.RegisterRoutes(p.router, p.gateway.Dispatcher, p.secretsSvc, p.log)
p.log.Debug("Registered Secrets handlers (HTTP + WebSocket)")
}
if p.secretStore != nil {
spriteshandlers.RegisterRoutes(p.router, p.gateway.Dispatcher, p.secretStore, p.log)
p.log.Debug("Registered Sprites handlers (HTTP + WebSocket)")
}
if p.taskRepo != nil {
sshhandlers.RegisterRoutes(
p.router,
p.gateway.Dispatcher,
p.taskRepo,
p.services.Task,
p.agentRegistry,
lifecycle.NewAgentctlResolver(p.log),
p.log,
)
p.log.Debug("Registered SSH handlers (HTTP + WebSocket)")
}
if p.services.GitHub != nil {
github.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.GitHub, p.log)
github.RegisterMockRoutes(p.router, p.services.GitHub, p.log)
p.log.Debug("Registered GitHub handlers (HTTP + WebSocket)")
}
if p.services.GitLab != nil {
gitlab.RegisterRoutesWithDispatcher(p.router, p.gateway.Dispatcher, p.services.GitLab, p.log)
gitlab.RegisterMockRoutes(p.router, p.services.GitLab, p.log)
p.log.Debug("Registered GitLab handlers (HTTP + WebSocket)")
}
if p.services.AzureDevOps != nil {
azuredevops.RegisterRoutes(p.router, p.services.AzureDevOps, p.log)
azuredevops.RegisterMockRoutes(p.router, p.services.AzureDevOps, p.log)
p.log.Debug("Registered Azure DevOps handlers (HTTP)")
}
if p.services.Jira != nil {
jira.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Jira, p.log)
jira.RegisterMockRoutes(p.router, p.services.Jira, p.log)
p.log.Debug("Registered JIRA handlers (HTTP + WebSocket)")
}
if p.services.Linear != nil {
linear.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Linear, p.log)
linear.RegisterMockRoutes(p.router, p.services.Linear, p.log)
p.log.Debug("Registered Linear handlers (HTTP + WebSocket)")
}
if p.services.Sentry != nil {
sentry.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Sentry, p.log)
sentry.RegisterMockRoutes(p.router, p.services.Sentry, p.log)
p.log.Debug("Registered Sentry handlers (HTTP)")
}
if p.services.WorkflowSync != nil {
workflowsync.RegisterRoutes(p.router, p.services.WorkflowSync, p.log)
p.log.Debug("Registered workflow sync handlers (HTTP)")
}
if p.services.Automation != nil {
automation.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Automation.Service, p.log)
p.log.Debug("Registered Automation handlers (HTTP + WebSocket)")
}
if p.services.Plugins != nil {
if p.authSvc != nil {
// Lets an auth-capable plugin complete OIDC/SAML SSO: it asserts a
// validated external identity on its webhook response and the host
// mints + sets the session cookie (the plugin never sees the token).
p.services.Plugins.SetAuthLoginBridge(pluginSSOBridge{auth: p.authSvc})
}
plugins.RegisterRoutes(p.router, p.services.Plugins, p.services.Plugins.Deliverer(), p.log)
p.log.Debug("Registered Plugins handlers (HTTP)")
}
docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), dockerTaskTitleProvider(p.taskRepo, p.log), p.log)
p.log.Debug("Registered Docker management handlers (HTTP)")
registerHealthRoutes(p)
registerSystemRoutes(p)
if p.runtimeFlagsSvc != nil {
runtimeflags.RegisterRoutes(p.router, p.runtimeFlagsSvc)
}
if p.repoCloner != nil {
var ghCopier improvekandev.GitHubWorkspaceCopier
var resolveDefaultWorkspace improvekandev.DefaultWorkspaceResolver
if p.services.GitHub != nil && p.dbPool != nil {
ghCopier = p.services.GitHub
resolveDefaultWorkspace = func(context.Context) (string, error) {
return workspacescope.ResolveMigrationTarget(p.dbPool.Reader())
}
}
ikHandler := improvekandev.NewHandler(p.taskSvc, p.repoCloner, ghCopier, resolveDefaultWorkspace, p.version, p.log)
if p.services.GitHub != nil {
ikHandler.SetManagedGitHubForkProber(p.services.GitHub)
}
ikHandler.SetTemporaryArtifactRegistry(p.temporaryArtifacts)
if p.systemSvc != nil {
ikHandler.SetLogBundles(p.systemSvc.LogBundles)
}
improvekandev.RegisterRoutes(p.router, ikHandler)
p.log.Debug("Registered Improve Kandev handlers (HTTP)")
}
registerMCPAndDebugRoutes(p, workflowCtrl, clarificationStore, clarificationCanceller, planService, handoffSvc)
var automationSvc *automation.Service
if p.services.Automation != nil {
automationSvc = p.services.Automation.Service
}
registerE2EResetRoutes(
p.router, p.taskRepo, p.taskSvc, automationSvc, p.services.GitHub, p.services.GitLab, p.eventBus, p.log,
)
if officetestharness.Enabled() {
var officeAgentSvc *officeagents.AgentService
if p.services.OfficeSvcs != nil {
officeAgentSvc = p.services.OfficeSvcs.Agents
}
officetestharness.RegisterRoutes(
p.router,
p.taskRepo,
p.officeRepo,
p.agentSettingsRepo,
officeAgentSvc,
p.eventBus,
p.log,
)
p.log.Info("E2E mock routes enabled at /api/v1/_test/* — DO NOT enable in production")
}
// Register office routes
if p.services.OfficeSvcs != nil {
api := p.router.Group("/api/v1/office")
api.Use(officeagents.AgentAuthMiddleware(p.services.OfficeSvcs.Agents))
api.Use(officeWorkspaceScopeMiddleware(p.authSvc, p.taskSvc))
office.RegisterAllRoutes(api, p.services.OfficeSvcs, p.log)
p.log.Debug("Registered Office handlers (HTTP)")
}
}
// officeWorkspaceScopeMiddleware enforces per-user workspace ownership on
// office routes that carry a `:wsId` param (opt-in auth). Office endpoints are
// dual-consumed: sandbox agents authenticate with a workspace-scoped JWT
// (validated + workspace-claim-checked by AgentAuthMiddleware, which sets an
// agent caller in context — those requests skip this check), while browser
// users authenticate with a session cookie and must own the target workspace.
// Routes without a `:wsId` param (agent runtime callbacks, approval/routine by
// ID) are not gated here; they remain governed by AgentAuthMiddleware.
func officeWorkspaceScopeMiddleware(authSvc *auth.Service, taskSvc *taskservice.Service) gin.HandlerFunc {
return func(c *gin.Context) {
if authSvc == nil || authSvc.Mode() == auth.ModeDisabled {
c.Next()
return
}
// Agent JWT callers are already constrained to their workspace claim.
if officeagents.CallerFromContext(c) != nil {
c.Next()
return
}
wsID := c.Param("wsId")
if wsID == "" {
c.Next()
return
}
if err := taskSvc.AuthorizeWorkspaceAccess(c.Request.Context(), wsID); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
c.Next()
}
}
// integrationWorkspacePrefixes are the workspace-scoped third-party
// integration route groups. Their config/watch/data routes are keyed by a
// caller-supplied workspace_id (query, or a /workspaces/:id/ path segment on
// gitlab) with no per-user gate of their own, so this global middleware
// authorizes ownership for them when auth is enabled.
var integrationWorkspacePrefixes = []string{
"/api/v1/jira/", "/api/v1/linear/", "/api/v1/sentry/",
"/api/v1/azure-devops/", "/api/v1/gitlab/", "/api/v1/github/", "/api/v1/workflow-sync/",
}
// integrationWorkspaceScopeMiddleware enforces workspace ownership on the
// third-party integration route groups (opt-in auth). Their handlers read the
// workspace from the request; internal pollers call the services directly (no
// identity) and are unaffected. Mock subroutes (/api/v1/<x>/mock/...) are only
// mounted in e2e/dev and carry no real credentials, but they still route
// through here — the ownership check is harmless there because e2e runs with
// auth disabled.
func integrationWorkspaceScopeMiddleware(authSvc *auth.Service, taskSvc *taskservice.Service) gin.HandlerFunc {
return func(c *gin.Context) {
if authSvc == nil || authSvc.Mode() == auth.ModeDisabled {
c.Next()
return
}
path := c.Request.URL.Path
if !hasIntegrationPrefix(path) {
c.Next()
return
}
wsID := c.Query("workspace_id")
if wsID == "" {
wsID = workspaceIDFromPath(path)
}
if wsID == "" {
c.Next()
return
}
if err := taskSvc.AuthorizeWorkspaceAccess(c.Request.Context(), wsID); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
c.Next()
}
}
func hasIntegrationPrefix(path string) bool {
for _, prefix := range integrationWorkspacePrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
// workspaceIDFromPath extracts the ID from a `/workspaces/<id>/...` path
// segment (gitlab's GET /api/v1/gitlab/workspaces/:workspaceID/task-mrs).
func workspaceIDFromPath(path string) string {
const marker = "/workspaces/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := path[idx+len(marker):]
if slash := strings.IndexByte(rest, '/'); slash >= 0 {
return rest[:slash]
}
return rest
}
func dockerTaskTitleProvider(taskRepo *sqliterepo.Repository, log *logger.Logger) docker.TaskTitleProvider {
return func(ctx context.Context, taskID string) (string, bool) {
if taskRepo == nil || taskID == "" {
return "", false
}
task, err := taskRepo.GetTask(ctx, taskID)
if err != nil {
log.Debug("docker container task title lookup failed",
zap.String("task_id", taskID), zap.Error(err))
return "", false
}
return task.Title, task.Title != ""
}
}
// registerSystemRoutes mounts the System pages backend onto /api/v1/system/*.
// The system service is constructed upstream (startGatewayAndServe) so the
// updates-poller goroutine can be started with the main run context; here we
// only register HTTP handlers. The systemSvc field is nil during partial
// builds (tests, CLI subcommands) — registration is then a no-op.
func registerSystemRoutes(p routeParams) {
if p.systemSvc == nil {
return
}
p.systemSvc.RegisterRoutes(p.router, p.log)
}
// registerHealthRoutes sets up the system health endpoint with all health checkers.
func registerHealthRoutes(p routeParams) {
var githubProvider health.GitHubStatusProvider
var githubRateProvider health.GitHubRateLimitProvider
if p.services.GitHub != nil {
githubProvider = githubWorkspaceHealthAdapter{svc: p.services.GitHub}
githubRateProvider = githubRateLimitAdapter{svc: p.services.GitHub}
}
githubChecker := health.NewGitHubChecker(githubProvider)
if githubRateProvider != nil {
githubChecker.WithRateLimitProvider(githubRateProvider)
}
osLimitsChecker := health.NewCachedChecker(
oslimits.NewOSLimitsChecker(oslimits.NewInotifyProbe()),
5*time.Minute,
)
checkers := []health.Checker{
health.NewGitExecutableChecker(),
githubChecker,
health.NewAgentChecker(p.agentSettingsController),
osLimitsChecker,
}
if p.systemSvc != nil && p.systemSvc.StorageRuntime != nil {
checkers = append(checkers, p.systemSvc.StorageRuntime)
}
healthSvc := health.NewService(p.log, checkers...)
health.RegisterRoutes(p.router, healthSvc, p.log)
}
type githubWorkspaceHealthAdapter struct {
svc *github.Service
}
func (a githubWorkspaceHealthAdapter) GitHubConnectionHealth(
ctx context.Context,
) (health.GitHubConnectionHealth, error) {
if a.svc == nil {
return health.GitHubConnectionHealth{}, github.ErrGitHubNotConfigured
}
summary, err := a.svc.GetWorkspaceConnectionHealth(ctx)
if err != nil {
return health.GitHubConnectionHealth{}, err
}
return health.GitHubConnectionHealth{
Active: summary.Active,
Invalid: summary.Invalid,
Suspended: summary.Suspended,
Revoked: summary.Revoked,
}, nil
}
// githubRateLimitAdapter bridges the github.Service's per-resource exhaustion
// snapshot to the structural shape consumed by the health package without
// importing health into github (cycle).
type githubRateLimitAdapter struct {
svc *github.Service
}
func (a githubRateLimitAdapter) ExhaustedRateLimits() []health.GitHubRateLimitStatus {
if a.svc == nil {
return nil
}
src := a.svc.ExhaustedRateLimits()
if len(src) == 0 {
return nil
}
out := make([]health.GitHubRateLimitStatus, len(src))
for i, s := range src {
out[i] = health.GitHubRateLimitStatus{Resource: s.Resource, ResetAt: s.ResetAt}
}
return out
}
// mcpTaskPRListerAdapter adapts *github.Service to the MCP handlers'
// TaskPRLister interface so list_tasks responses can carry per-task PR
// summaries. Returns an empty map when the github service is nil.
type mcpTaskPRListerAdapter struct {
gh *github.Service
}
func (a mcpTaskPRListerAdapter) ListTaskPRsByTaskIDs(
ctx context.Context, taskIDs []string,
) (map[string][]mcphandlers.TaskPRInfo, error) {
out := make(map[string][]mcphandlers.TaskPRInfo)
if a.gh == nil || len(taskIDs) == 0 {
return out, nil
}
prs, err := a.gh.ListTaskPRsByTaskIDs(ctx, taskIDs)
if err != nil {
return nil, err
}
for taskID, list := range prs {
infos := make([]mcphandlers.TaskPRInfo, 0, len(list))
for _, pr := range list {
if pr == nil {
continue
}
infos = append(infos, mcphandlers.TaskPRInfo{
Number: pr.PRNumber,
URL: pr.PRURL,
Title: pr.PRTitle,
State: pr.State,
MergedAt: pr.MergedAt,
})
}
if len(infos) > 0 {
out[taskID] = infos
}
}
return out, nil
}
// registerMCPAndDebugRoutes registers MCP and debug routes and wires the MCP handler.
func registerMCPAndDebugRoutes(
p routeParams,
wfCtrl *workflowcontroller.Controller,
clarificationStore *clarification.Store,
clarificationCanceller *clarification.Canceller,
planService *taskservice.PlanService,
handoffSvc *taskservice.HandoffService,
) {
walkthroughService := taskservice.NewWalkthroughService(p.taskRepo, p.eventBus, p.log)
walkthroughService.SetTaskAuthorizer(p.taskSvc.AuthorizeTaskAccess)
mcpHandlers := mcphandlers.NewHandlers(
p.taskSvc, wfCtrl,
clarificationStore, clarificationCanceller, p.msgCreator, p.taskRepo, p.taskRepo, p.eventBus, planService, walkthroughService, p.orchestratorSvc, p.orchestratorSvc.GetMessageQueue(), p.log,
)
mcpHandlers.SetPluginService(p.services.Plugins)
mcpHandlers.SetRemoteContributionService(newRemoteContributionCoordinator(p.services.GitHub, p.services.GitLab))
// Wire config-mode dependencies for agent-native configuration
mcpHandlers.SetConfigDeps(p.services.Workflow, p.agentSettingsController, p.mcpConfigSvc)
mcpHandlers.SetClarificationInputPauser(p.orchestratorSvc)
mcpHandlers.SetPromptReferenceResolver(p.services.Prompts)
mcpHandlers.SetTaskStopper(p.orchestratorSvc)
mcpHandlers.SetTaskTitleBranchRenamer(p.orchestratorSvc)
mcpHandlers.SetUserSettingsProvider(p.services.User)
if p.systemSvc != nil && p.systemSvc.LogBundles != nil {
mcpHandlers.SetDiagnosticBundleServices(p.systemSvc.LogBundles, p.lifecycleMgr)
}
// Enrich list_tasks responses with associated GitHub PRs (link, title,
// number, state) when the github service is available.
if p.services.GitHub != nil {
mcpHandlers.SetTaskPRLister(mcpTaskPRListerAdapter{gh: p.services.GitHub})
mcpHandlers.SetTaskPRAutomationService(p.services.GitHub)
}
if p.services.GitLab != nil {
mcpHandlers.SetTaskMRAutomationService(p.services.GitLab)
}
if p.services.OfficeSvcs != nil && p.services.OfficeSvcs.Dashboard != nil {
mcpHandlers.SetDashboardService(p.services.OfficeSvcs.Dashboard)
}
// Reuse the cross-task handoff service constructed in registerRoutes —
// the same instance backs the MCP path and the HTTP Kanban path so
// workspace-group state stays consistent across both surfaces.
if handoffSvc != nil {
mcpHandlers.SetHandoffService(handoffSvc)
}
// Native code review. The runner owns background review passes, so it is
// started here and drained on shutdown; the orchestrator gets it too, which
// is what enables the run_code_review workflow step action.
reviewParts := buildReviewComponents(p)
mcpHandlers.SetReviewService(reviewParts.service)
mcpHandlers.SetReviewRunner(reviewParts.runner)
p.orchestratorSvc.SetReviewRunner(reviewParts.runner)
reviewParts.runner.Start(context.Background())
if p.addCleanup != nil {
p.addCleanup(func() error {
reviewParts.runner.Stop()
return nil
})
}
// Any pass still marked running belongs to a previous process. Close them so
// the UI never shows a review that will never finish.
if cancelled, err := p.taskRepo.CancelInFlightTaskReviewRuns(context.Background()); err != nil {
p.log.Warn("failed to cancel interrupted review runs", zap.Error(err))
} else if cancelled > 0 {
p.log.Info("cancelled review runs interrupted by restart", zap.Int("count", cancelled))
}
p.log.Debug("Registered native code review (WebSocket + MCP)")
mcpHandlers.RegisterHandlers(p.gateway.Dispatcher)
p.log.Debug("Registered MCP handlers (WebSocket)")
p.lifecycleMgr.SetMCPHandler(p.gateway.Dispatcher)
p.log.Debug("MCP handler configured for agent lifecycle manager")
// In-session MCP calls reach this same dispatcher over the agent's own WS
// stream, which carries no credential — so scope them to the user who owns
// the stream's task. Without this the handlers run with no identity, which
// the task service treats as an internal caller and serves unscoped.
if p.authSvc != nil {
p.lifecycleMgr.SetMCPIdentityScoper(mcpscope.NewResolver(
p.taskRepo,
p.authSvc,
func() bool { return p.authSvc.Mode() != auth.ModeDisabled },
p.log,
).Scope)
p.log.Debug("In-session MCP dispatch scoped to task owner")
}
// External MCP endpoint — exposes config tools + create_task to external coding
// agents (Claude Code, Cursor, etc.) at /mcp on the backend HTTP server.
registerExternalMCP(p)
debughandlers.RegisterRoutes(p.router, p.log)
p.log.Debug("Registered Debug handlers (HTTP)")
if p.devMode {
debughandlers.RegisterPprofRoutes(p.router, p.log)
debughandlers.RegisterMemoryRoute(p.router, p.log)
}
}
// registerExternalMCP mounts an MCP server on the backend HTTP router so external
// coding agents can connect to Kandev at /mcp, /mcp/sse, /mcp/message.
func registerExternalMCP(p routeParams) {
port := p.httpPort
if port == 0 {
port = ports.Backend
}
baseURL := fmt.Sprintf("http://localhost:%d", port)
backendClient := mcpserver.NewDispatcherBackendClient(p.gateway.Dispatcher, p.log)
srv := mcpserver.NewExternal(backendClient, p.log, "")
mcpGroup := p.router.Group("", externalMCPAuthMiddleware(p.authSvc))
srv.RegisterBackendRoutes(mcpGroup)
if p.addCleanup != nil {
p.addCleanup(func() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Close(ctx)
})
}
p.log.Info("Registered external MCP endpoint",
zap.String("base_url", baseURL),
zap.String("streamable_http", baseURL+"/mcp"),
zap.String("sse", baseURL+"/mcp/sse"),
zap.String("sse_message", baseURL+"/mcp/message"))
}
// externalMCPAuthMiddleware guards the external MCP endpoint (/mcp*). While
// authentication is disabled the endpoint stays open (today's behavior). Once
// auth is enabled, external coding agents must present a personal access
// token as an Authorization bearer — they have no browser session cookie.
func externalMCPAuthMiddleware(authSvc *auth.Service) gin.HandlerFunc {
return func(c *gin.Context) {
if authSvc == nil || authSvc.Mode() == auth.ModeDisabled {
c.Next()
return
}
// The global auth middleware may already have resolved a PAT (or a
// browser session — useful for same-origin tooling).
if _, ok := authn.FromGin(c); ok {
c.Next()
return
}
c.Header("WWW-Authenticate", "Bearer")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "external MCP requires a personal access token (Settings > Account > API tokens)",
})
}
}
// runGracefulShutdown gracefully stops all services and runs cleanups.
func runGracefulShutdown(
server *http.Server,
listeners *serverListeners,
scheduling *schedulingRuntime,
orchestratorSvc *orchestrator.Service,
lifecycleMgr *lifecycle.Manager,
runCleanups func(),
log *logger.Logger,
) []error {
start := time.Now()
var shutdownErrs []error
log.Info("Graceful shutdown started",
zap.Int("http_timeout_seconds", int(httpShutdownTimeout/time.Second)),
zap.Int("agent_timeout_seconds", int(agentShutdownTimeout/time.Second)),
zap.Int("tracing_timeout_seconds", int(tracingShutdownTimeout/time.Second)))
// Stop the background bind-retry loop before Shutdown so no new listener
// can be created after the server begins closing its listeners.
if listeners != nil {
listeners.Stop()
}
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), httpShutdownTimeout)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
shutdownErrs = append(shutdownErrs, err)
log.Error("HTTP server shutdown error", zap.Error(err))
}
if err := scheduling.Stop(); err != nil {
shutdownErrs = append(shutdownErrs, err)
log.Error("Scheduler stop error", zap.Error(err))
}
if orchestratorSvc != nil {
if err := orchestratorSvc.Stop(); err != nil {
shutdownErrs = append(shutdownErrs, err)
log.Error("Orchestrator stop error", zap.Error(err))
}
}
if err := stopLifecycleManager(lifecycleMgr, log); err != nil {
shutdownErrs = append(shutdownErrs, err)
}
runCleanups()
// Flush pending OTel spans before exit
traceCtx, traceCancel := context.WithTimeout(context.Background(), tracingShutdownTimeout)
if err := tracing.Shutdown(traceCtx); err != nil {
shutdownErrs = append(shutdownErrs, err)
log.Error("Tracer shutdown error", zap.Error(err))
}
traceCancel()
log.Info("Graceful shutdown complete",
zap.Duration("duration", time.Since(start)),
zap.Int("error_count", len(shutdownErrs)))
_ = log.Sync()
return shutdownErrs
}
// stopLifecycleManager gracefully stops all agents and the lifecycle manager.
func stopLifecycleManager(lifecycleMgr *lifecycle.Manager, log *logger.Logger) error {
if lifecycleMgr == nil {
return nil
}
var shutdownErrs []error
log.Info("Stopping agents gracefully",
zap.Int("timeout_seconds", int(agentShutdownTimeout/time.Second)))
stopCtx, stopCancel := context.WithTimeout(context.Background(), agentShutdownTimeout)
if err := lifecycleMgr.StopAllAgents(stopCtx); err != nil {
shutdownErrs = append(shutdownErrs, err)
log.Error("Graceful agent stop error", zap.Error(err))
}
stopCancel()
if err := lifecycleMgr.Stop(); err != nil {
shutdownErrs = append(shutdownErrs, err)
log.Error("Lifecycle manager stop error", zap.Error(err))
}
if len(shutdownErrs) == 0 {
log.Info("Agents stopped gracefully")
}
return errors.Join(shutdownErrs...)
}