481359ddb2
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2056 lines
81 KiB
Go
2056 lines
81 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"sort"
|
|
"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/authz"
|
|
"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"
|
|
kuberneteshandlers "github.com/kandev/kandev/internal/kubernetes"
|
|
"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"
|
|
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/org"
|
|
"github.com/kandev/kandev/internal/orgunit"
|
|
"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"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
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"
|
|
serviceFieldKey = "service"
|
|
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) from the canonical
|
|
// task-environment workspace. Multi-repo workspaces emit one notification per
|
|
// repo (stamped with repository_name); single-repo emits a single untagged
|
|
// notification. A session without an environment has no authoritative status
|
|
// scope and therefore emits nothing.
|
|
func appendLiveGitStatusMessage(ctx context.Context, taskRepo *sqliterepo.Repository, lifecycleMgr *lifecycle.Manager, sessionID string, session *models.TaskSession, result []*ws.Message, log *logger.Logger) []*ws.Message {
|
|
sources, ok := resolveGitStatusSources(ctx, taskRepo, session, log)
|
|
if !ok {
|
|
return result
|
|
}
|
|
msgs, live := tryGetLiveGitStatusWithState(ctx, lifecycleMgr, sessionID, sources, log)
|
|
if live {
|
|
return append(result, msgs...)
|
|
}
|
|
return appendDBSnapshotGitStatus(ctx, taskRepo, sessionID, sources, 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 no eligible source has a live execution or agentctl is
|
|
// stuck. Callers that need to distinguish a failed live query from an absent
|
|
// live execution should use tryGetLiveGitStatusWithState.
|
|
func tryGetLiveGitStatus(ctx context.Context, lifecycleMgr *lifecycle.Manager, requestedSessionID string, sources *gitStatusSources, log *logger.Logger) []*ws.Message {
|
|
msgs, _ := tryGetLiveGitStatusWithState(ctx, lifecycleMgr, requestedSessionID, sources, log)
|
|
return msgs
|
|
}
|
|
|
|
// tryGetLiveGitStatusWithState probes every eligible execution in the
|
|
// environment under one deadline. The boolean is true as soon as an eligible
|
|
// non-terminal execution is found, even when agentctl cannot answer. This
|
|
// prevents a stale persisted snapshot from replacing an authoritative but
|
|
// currently unavailable live source.
|
|
func tryGetLiveGitStatusWithState(ctx context.Context, lifecycleMgr *lifecycle.Manager, requestedSessionID string, sources *gitStatusSources, log *logger.Logger) ([]*ws.Message, bool) {
|
|
if lifecycleMgr == nil {
|
|
return nil, false
|
|
}
|
|
if sources == nil || sources.environmentID == "" {
|
|
return nil, false
|
|
}
|
|
|
|
// Bound the complete source probe, not each source independently. Shared
|
|
// environments can have several eligible executions, and a stuck agentctl
|
|
// must not add another two seconds for every sibling.
|
|
rpcCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
|
defer cancel()
|
|
|
|
live := false
|
|
for _, sourceSessionID := range sources.sessionIDs {
|
|
if err := rpcCtx.Err(); err != nil {
|
|
return nil, live
|
|
}
|
|
execution, ok := lifecycleMgr.GetExecutionBySessionID(sourceSessionID)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !executionMatchesGitStatusSource(sources, execution, sourceSessionID, log) {
|
|
continue
|
|
}
|
|
if !isLiveGitStatusExecution(execution) {
|
|
continue
|
|
}
|
|
live = true
|
|
if msgs := tryGetLiveGitStatusFromExecution(rpcCtx, execution, requestedSessionID, sourceSessionID, sources.environmentID, log); len(msgs) > 0 {
|
|
return msgs, true
|
|
}
|
|
}
|
|
return nil, live
|
|
}
|
|
|
|
func isLiveGitStatusExecution(execution *lifecycle.AgentExecution) bool {
|
|
if execution == nil {
|
|
return false
|
|
}
|
|
switch execution.Status {
|
|
case v1.AgentStatusCompleted, v1.AgentStatusFailed, v1.AgentStatusStopped:
|
|
return false
|
|
default:
|
|
// An empty status is retained as live for recovered/legacy in-memory
|
|
// executions that have been registered but not promoted yet.
|
|
return true
|
|
}
|
|
}
|
|
|
|
func executionMatchesGitStatusSource(sources *gitStatusSources, execution *lifecycle.AgentExecution, sessionID string, log *logger.Logger) bool {
|
|
if execution == nil || sources == nil || sources.environmentID == "" {
|
|
return false
|
|
}
|
|
if execution.TaskEnvironmentID != "" && execution.TaskEnvironmentID != sources.environmentID {
|
|
log.Debug("rejecting live git status source",
|
|
zap.String("source_session_id", sessionID),
|
|
zap.String("task_environment_id", sources.environmentID),
|
|
zap.String("reason", "environment_mismatch"))
|
|
return false
|
|
}
|
|
if execution.WorkspacePath != sources.workspacePath {
|
|
log.Debug("rejecting live git status source",
|
|
zap.String("source_session_id", sessionID),
|
|
zap.String("task_environment_id", sources.environmentID),
|
|
zap.String("reason", "workspace_mismatch"))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func tryGetLiveGitStatusFromExecution(ctx context.Context, execution *lifecycle.AgentExecution, requestedSessionID, sourceSessionID, taskEnvironmentID string, log *logger.Logger) []*ws.Message {
|
|
agentClient, releaseClient := execution.AcquireAgentCtlClient()
|
|
defer releaseClient()
|
|
if agentClient == nil {
|
|
log.Debug("no agentctl client available for live git status",
|
|
zap.String("source_session_id", sourceSessionID))
|
|
return nil
|
|
}
|
|
|
|
// Force fresh git query: cache can wedge when the poll loop misses a HEAD change.
|
|
multi, err := agentClient.GetGitStatusMultiFresh(ctx)
|
|
if err != nil {
|
|
log.Debug("failed to get live git status",
|
|
zap.String("source_session_id", sourceSessionID),
|
|
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(requestedSessionID, taskEnvironmentID, 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("requested_session_id", requestedSessionID),
|
|
zap.String("source_session_id", sourceSessionID),
|
|
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, taskEnvironmentID, repositoryName string, status client.GitStatusResult) *ws.Message {
|
|
if taskEnvironmentID == "" {
|
|
return nil
|
|
}
|
|
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,
|
|
"comparison_target": status.ComparisonTarget,
|
|
"comparison_status": status.ComparisonStatus,
|
|
"comparison_error_code": status.ComparisonErrorCode,
|
|
"is_submodule": status.IsSubmodule,
|
|
}
|
|
if repositoryName != "" {
|
|
statusPayload["repository_name"] = repositoryName
|
|
}
|
|
gitEventData := map[string]interface{}{
|
|
"type": "status_update",
|
|
"session_id": sessionID,
|
|
"task_environment_id": taskEnvironmentID,
|
|
"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 the newest
|
|
// eligible DB snapshot. The selected source is always routed as the requested
|
|
// subscription session.
|
|
func appendDBSnapshotGitStatus(ctx context.Context, taskRepo *sqliterepo.Repository, sessionID string, sources *gitStatusSources, result []*ws.Message, log *logger.Logger) []*ws.Message {
|
|
if taskRepo == nil || sources == nil || sources.environmentID == "" {
|
|
return result
|
|
}
|
|
logFields := []zap.Field{zap.String("requested_session_id", sessionID)}
|
|
logFields = append(logFields, zap.String("task_environment_id", sources.environmentID))
|
|
log.Debug("falling back to DB snapshot for git status", logFields...)
|
|
|
|
snapshots, err := taskRepo.GetLatestGitStatusSnapshotsByTaskEnvironmentIDs(ctx, []string{sources.environmentID})
|
|
if err != nil {
|
|
log.Warn("failed to load DB snapshots for git status",
|
|
zap.String("requested_session_id", sessionID),
|
|
zap.Error(err))
|
|
return result
|
|
}
|
|
latestByRepository := newestGitStatusSnapshotsByRepository(snapshots)
|
|
if len(latestByRepository) == 0 {
|
|
log.Debug("no eligible DB snapshot found for git status",
|
|
zap.String("requested_session_id", sessionID))
|
|
return result
|
|
}
|
|
repositoryNames := make([]string, 0, len(latestByRepository))
|
|
for repositoryName := range latestByRepository {
|
|
repositoryNames = append(repositoryNames, repositoryName)
|
|
}
|
|
sort.Strings(repositoryNames)
|
|
for _, repositoryName := range repositoryNames {
|
|
snapshot := latestByRepository[repositoryName]
|
|
logFields := []zap.Field{
|
|
zap.String("requested_session_id", sessionID),
|
|
zap.String("source_session_id", snapshot.SessionID),
|
|
}
|
|
logFields = append(logFields, zap.String("task_environment_id", sources.environmentID))
|
|
if repositoryName != "" {
|
|
logFields = append(logFields, zap.String("repository_name", repositoryName))
|
|
}
|
|
log.Debug("selected DB snapshot for git status", logFields...)
|
|
if notification := buildGitSnapshotNotification(sessionID, repositoryName, snapshot); notification != nil {
|
|
result = append(result, notification)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func buildGitSnapshotNotification(sessionID, repositoryName string, snapshot *models.GitSnapshot) *ws.Message {
|
|
if snapshot == nil || snapshot.TaskEnvironmentID == "" {
|
|
return nil
|
|
}
|
|
metadata := snapshot.Metadata
|
|
statusPayload := map[string]interface{}{
|
|
branchFieldKey: snapshot.Branch,
|
|
"remote_branch": snapshot.RemoteBranch,
|
|
"head_commit": snapshot.HeadCommit,
|
|
"base_commit": snapshot.BaseCommit,
|
|
"ahead": snapshot.Ahead,
|
|
"behind": snapshot.Behind,
|
|
"remote_ahead": metadata["remote_ahead"],
|
|
"remote_behind": metadata["remote_behind"],
|
|
"remote_head_commit": metadata["remote_head_commit"],
|
|
"files": snapshot.Files,
|
|
"modified": metadata["modified"],
|
|
addedFieldKey: metadata[addedFieldKey],
|
|
deletedFieldKey: metadata[deletedFieldKey],
|
|
"untracked": metadata["untracked"],
|
|
"renamed": metadata["renamed"],
|
|
branchAdditionsFieldKey: metadata[branchAdditionsFieldKey],
|
|
branchDeletionsFieldKey: metadata[branchDeletionsFieldKey],
|
|
"comparison_target": metadata["comparison_target"],
|
|
"comparison_status": metadata["comparison_status"],
|
|
"comparison_error_code": metadata["comparison_error_code"],
|
|
}
|
|
if repositoryName != "" {
|
|
statusPayload["repository_name"] = repositoryName
|
|
}
|
|
gitEventData := map[string]interface{}{
|
|
"type": "status_update",
|
|
"session_id": sessionID,
|
|
"task_environment_id": snapshot.TaskEnvironmentID,
|
|
"timestamp": metadata["timestamp"],
|
|
"status": statusPayload,
|
|
}
|
|
notification, err := ws.NewNotification(ws.ActionSessionGitEvent, gitEventData)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return notification
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return appendSessionModelsMessageFromState(
|
|
sessionID,
|
|
session,
|
|
lifecycleMgr.GetModelStateForSession(sessionID),
|
|
result,
|
|
)
|
|
}
|
|
|
|
func appendSessionModelsMessageFromState(sessionID string, session *models.TaskSession, modelState *lifecycle.CachedModelState, result []*ws.Message) []*ws.Message {
|
|
if modelState == nil {
|
|
return result
|
|
}
|
|
snapshot, _ := lifecycle.LoadSessionModelsSnapshot(session.Metadata[models.SessionMetaKeyACPModelState])
|
|
replayState := *modelState
|
|
if len(replayState.Models) == 0 &&
|
|
len(replayState.ConfigOptions) == 0 &&
|
|
!replayState.ConfigOptionsSettled &&
|
|
len(snapshot.Models) > 0 {
|
|
replayState.Models = snapshot.Models
|
|
}
|
|
if replayState.CurrentModelID == "" && len(replayState.Models) == 0 {
|
|
return result
|
|
}
|
|
notification, err := ws.NewNotification(ws.ActionSessionModelsUpdated, lifecycle.SessionModelsEventPayload{
|
|
TaskID: session.TaskID,
|
|
SessionID: sessionID,
|
|
CurrentModelID: replayState.CurrentModelID,
|
|
Models: replayState.Models,
|
|
ConfigOptions: replayState.ConfigOptions,
|
|
ConfigOptionsSettled: replayState.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
|
|
planCoalesceWindow time.Duration
|
|
planCoalesceWindowConfigured bool
|
|
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)
|
|
var planService *taskservice.PlanService
|
|
if p.planCoalesceWindowConfigured {
|
|
planService = taskservice.NewPlanService(p.taskRepo, p.eventBus, p.log, p.planCoalesceWindow)
|
|
} else {
|
|
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)
|
|
// Stamps each plan revision with the task's workflow step at write time.
|
|
planService.SetWorkflowStepGetter(&workflowStepGetterAdapter{svc: p.services.Workflow})
|
|
clarificationStore := clarification.NewStore(2 * time.Hour)
|
|
clarificationCanceller := clarification.NewCanceller(clarificationStore, p.taskRepo, p.taskSvc, p.log)
|
|
p.orchestratorSvc.SetClarificationCanceller(clarificationCanceller)
|
|
p.taskSvc.SetClarificationCanceller(clarificationCanceller)
|
|
// Single resolver instance shared by the REST clarification routes and the
|
|
// external answer_question_kandev/list_pending_questions_kandev MCP tools
|
|
// (R3: both entry points must race through the same claim).
|
|
clarificationResolver := clarification.NewResolver(
|
|
clarificationStore,
|
|
p.taskRepo,
|
|
p.msgCreator,
|
|
p.taskSvc,
|
|
p.orchestratorSvc,
|
|
p.eventBus,
|
|
p.orchestratorSvc.HandleClarificationPrimaryAnswered,
|
|
p.log,
|
|
)
|
|
|
|
// 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)
|
|
p.taskSvc.SetWorkspacePolicyAttacher(handoffSvc)
|
|
handoffSvc.SetCommentReader(&officeCommentReaderAdapter{reader: p.officeRepo})
|
|
// 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)
|
|
handoffSvc.SetVacatedStepReconciler(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)
|
|
}
|
|
// Config sync's own tables (office_config_sync_configs,
|
|
// office_config_sync_manifest) have no FK/cascade onto the workspace
|
|
// row, so DeleteWorkspace must release them explicitly or the poller
|
|
// keeps running against — and resurrecting entities into — a deleted
|
|
// workspace (see the "Workspace deletion side tables" convention).
|
|
if p.services.Office != nil && p.services.OfficeSvcs != nil && p.services.OfficeSvcs.ConfigSync != nil {
|
|
p.services.Office.SetConfigSyncCleaner(p.services.OfficeSvcs.ConfigSync)
|
|
}
|
|
// 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, clarificationResolver, planService, handoffSvc)
|
|
if p.authSvc != nil {
|
|
authhttpapi.RegisterRoutes(p.router, p.authSvc, p.services.SessionHostnameResolver, p.log)
|
|
}
|
|
|
|
// /health is a liveness probe: it answers 200 unconditionally, matching
|
|
// the bootstrap handler's /health contract (internal/backendapp/
|
|
// httpserver.go) so the launcher's healthcheck never depends on startup
|
|
// progress. By the time this handler is reachable at all, ready is
|
|
// already true (see the comment on startGatewayAndServe's Store calls),
|
|
// but it does not gate on that flag — liveness must not depend on
|
|
// readiness, or the crash loop docs/specs/startup-listener-before-
|
|
// recovery/spec.md exists to fix comes back.
|
|
p.router.GET("/health", healthHandler(p))
|
|
|
|
// /ready is a readiness probe. It returns 200 only 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 readiness wait and Kubernetes' readinessProbe) keep polling
|
|
// instead of racing ahead and hitting 404s on routes that aren't wired
|
|
// yet. See docs/specs/health-endpoint-version/spec.md for the exact
|
|
// contract this endpoint now owns.
|
|
p.router.GET("/ready", readyHandler(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" || path == "/ready" {
|
|
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 liveness probe. It always answers
|
|
// 200 with the process's running version — regardless of the `ready` flag —
|
|
// mirroring the bootstrap handler's /health contract (httpserver.go) so the
|
|
// launcher's healthcheck never depends on startup progress. See docs/specs/
|
|
// health-endpoint-version/spec.md for the exact response contract.
|
|
func healthHandler(p routeParams) gin.HandlerFunc {
|
|
version := resolveVersion(p)
|
|
return func(c *gin.Context) {
|
|
if token := desktopHealthToken(); token != "" {
|
|
c.Header(desktopHealthTokenHeader, token)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
statusKey: "ok",
|
|
serviceFieldKey: kandevName,
|
|
"mode": "websocket+http",
|
|
versionFieldKey: version,
|
|
})
|
|
}
|
|
}
|
|
|
|
// readyHandler serves GET /ready, the readiness probe. 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 readyHandler(p routeParams) gin.HandlerFunc {
|
|
version := resolveVersion(p)
|
|
return func(c *gin.Context) {
|
|
if !ready.Load() {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
statusKey: startingStatus,
|
|
serviceFieldKey: kandevName,
|
|
versionFieldKey: version,
|
|
})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
statusKey: "ok",
|
|
serviceFieldKey: kandevName,
|
|
versionFieldKey: version,
|
|
})
|
|
}
|
|
}
|
|
|
|
// resolveVersion returns p.version, falling back to the package-level
|
|
// Version. p.version is normally wired in by run()'s registerRoutes call;
|
|
// the fallback 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.
|
|
func resolveVersion(p routeParams) string {
|
|
if p.version != "" {
|
|
return p.version
|
|
}
|
|
return 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)
|
|
}
|
|
runtime := webRuntimeConfig(p.devMode, p.webTitlePrefix, req)
|
|
if p.systemSvc != nil && p.systemSvc.Info != nil {
|
|
runtime.BootID = p.systemSvc.Info.Info().BootID
|
|
}
|
|
payload := webapp.NewBootPayload(
|
|
route,
|
|
runtime,
|
|
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)
|
|
taskhandlers.RegisterMemberRoutes(p.router, p.taskSvc, p.log)
|
|
if p.services != nil && p.services.Org != nil {
|
|
org.NewController(p.services.Org, p.log).RegisterRoutes(p.router)
|
|
}
|
|
if p.services.OrgUnits != nil {
|
|
orgunit.NewController(p.services.OrgUnits, p.log).RegisterRoutes(p.router)
|
|
}
|
|
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)
|
|
taskH.SetAgentProfileRecentUseRecorder(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.RegisterRepositoryBranchPolicyRoutes(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...,
|
|
)
|
|
processHandlers := taskhandlers.RegisterProcessRoutes(p.router, p.taskSvc, p.lifecycleMgr, p.log)
|
|
taskhandlers.RegisterWorkspaceFileRoutes(p.router, processHandlers)
|
|
analyticshandlers.RegisterStatsRoutes(p.router, p.analyticsRepo, p.taskSvc, 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,
|
|
clarificationResolver *clarification.Resolver,
|
|
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,
|
|
clarificationResolver,
|
|
p.log,
|
|
)
|
|
p.log.Debug("Registered Clarification handlers (HTTP)")
|
|
|
|
// Wire the plugin Host interaction write path (ADR 0052) onto the same
|
|
// orchestrator permission resolution and the same clarification resolver
|
|
// instance the REST route and the MCP handlers use, so a plugin's response
|
|
// is indistinguishable from a user's and takes the same durable claim.
|
|
// Deliberately here rather than in initOfficeServices: that returns early
|
|
// when features.office=false (the production default), while plugins run
|
|
// whenever services.Plugins is non-nil.
|
|
if p.services.Plugins != nil {
|
|
p.services.Plugins.SetInteractionResponder(pluginsInteractionResponderAdapter{
|
|
permissions: p.orchestratorSvc,
|
|
clarifications: clarificationResolver,
|
|
})
|
|
}
|
|
|
|
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 {
|
|
kuberneteshandlers.RegisterRoutes(
|
|
p.router,
|
|
p.gateway.Dispatcher,
|
|
p.taskRepo,
|
|
p.services.Task,
|
|
p.log,
|
|
)
|
|
p.log.Debug("Registered Kubernetes handlers (HTTP + WebSocket)")
|
|
|
|
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), dockerSessionAuthorizer(p.taskSvc), 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, clarificationResolver, 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 {
|
|
mountOfficeRoutes(p.router, p.services.OfficeSvcs, p.authSvc, p.taskSvc, p.officeRepo, handoffSvc, p.log)
|
|
p.log.Debug("Registered Office handlers (HTTP)")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// dockerSessionAuthorizer scopes the Docker management endpoints to the
|
|
// caller's own task sessions. Returning an untyped nil when the task service
|
|
// is absent (partial builds) keeps the handlers failing closed instead of
|
|
// calling through a typed-nil interface.
|
|
func dockerSessionAuthorizer(taskSvc *taskservice.Service) docker.SessionAuthorizer {
|
|
if taskSvc == nil {
|
|
return nil
|
|
}
|
|
return taskSvc
|
|
}
|
|
|
|
// lifecycleAccessAuthorizer is the task-service surface the lifecycle manager's
|
|
// per-user visibility checks are wired to. Narrowed to an interface so the
|
|
// wiring itself is testable: the three single-ID checkers take the same
|
|
// signature, so a crossed wire (session visibility installed in the task slot,
|
|
// say) compiles and silently authorizes the wrong resource.
|
|
type lifecycleAccessAuthorizer interface {
|
|
AuthorizeSessionAccess(ctx context.Context, sessionID string) error
|
|
AuthorizeEnvironmentAccess(ctx context.Context, taskEnvironmentID string) error
|
|
AuthorizeTaskAccess(ctx context.Context, taskID string) error
|
|
AuthorizeTaskEnvironmentAccess(ctx context.Context, taskID, taskEnvironmentID string) error
|
|
AuthorizeSessionScope(ctx context.Context, sessionID string, scope authz.Scope) error
|
|
AuthorizeEnvironmentScope(ctx context.Context, taskEnvironmentID string, scope authz.Scope) error
|
|
}
|
|
|
|
// wireLifecycleAccessCheckers installs every per-user visibility check on the
|
|
// lifecycle manager. Kept together so a surface that needs a new kind of check
|
|
// has one place to add it, rather than another setter call somewhere else in
|
|
// startup that nothing asserts on.
|
|
func wireLifecycleAccessCheckers(lifecycleMgr *lifecycle.Manager, access lifecycleAccessAuthorizer) {
|
|
lifecycleMgr.SetSessionAccessChecker(access.AuthorizeSessionAccess)
|
|
lifecycleMgr.SetEnvironmentAccessChecker(access.AuthorizeEnvironmentAccess)
|
|
lifecycleMgr.SetTaskAccessChecker(access.AuthorizeTaskAccess)
|
|
lifecycleMgr.SetTaskEnvironmentAccessChecker(access.AuthorizeTaskEnvironmentAccess)
|
|
// Execution surfaces (terminal, shell, file writes, VS Code, port
|
|
// previews) require session.exec, which a viewer does not hold. They are
|
|
// keyed either by session or by environment, so both slots get one.
|
|
lifecycleMgr.SetSessionExecAccessChecker(func(ctx context.Context, sessionID string) error {
|
|
return access.AuthorizeSessionScope(ctx, sessionID, authz.ScopeSessionExec)
|
|
})
|
|
lifecycleMgr.SetEnvironmentExecAccessChecker(func(ctx context.Context, environmentID string) error {
|
|
return access.AuthorizeEnvironmentScope(ctx, environmentID, authz.ScopeSessionExec)
|
|
})
|
|
}
|
|
|
|
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,
|
|
clarificationResolver *clarification.Resolver,
|
|
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.SetPromptReader(p.services.Prompts)
|
|
mcpHandlers.SetTaskStopper(p.orchestratorSvc)
|
|
mcpHandlers.SetAgentPermissionService(p.orchestratorSvc)
|
|
mcpHandlers.SetTaskTitleBranchRenamer(p.orchestratorSvc)
|
|
mcpHandlers.SetUserSettingsProvider(p.services.User)
|
|
// list_pending_questions_kandev / answer_question_kandev (external MCP
|
|
// surface only). p.taskRepo already implements ClarificationBundleLister
|
|
// (ListUnresolvedClarificationBundles, FindMessagesByPendingID).
|
|
mcpHandlers.SetClarificationResolver(clarificationResolver, p.taskRepo)
|
|
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. Always attach the server-derived
|
|
// task/session principal so automation self/workspace boundaries remain in
|
|
// force even when authentication is disabled or unavailable. Owner identity
|
|
// scoping is conditional because single-user installs intentionally retain
|
|
// their existing unscoped behavior.
|
|
mcpScopeResolver := mcpscope.NewResolver(
|
|
p.taskRepo,
|
|
p.authSvc,
|
|
func() bool { return p.authSvc != nil && p.authSvc.Mode() != auth.ModeDisabled },
|
|
p.log,
|
|
)
|
|
p.lifecycleMgr.SetMCPPrincipalScoper(mcpScopeResolver.ScopePrincipal)
|
|
if p.authSvc != nil {
|
|
p.lifecycleMgr.SetMCPIdentityScoper(mcpScopeResolver.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)
|
|
if p.dbPool != nil {
|
|
db.RegisterWriterPoolStats(p.dbPool)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.NewExternalDispatcherBackendClient(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...)
|
|
}
|