343 lines
13 KiB
Go
343 lines
13 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
agentcontroller "github.com/kandev/kandev/internal/agent/controller"
|
|
agenthandlers "github.com/kandev/kandev/internal/agent/handlers"
|
|
"github.com/kandev/kandev/internal/agent/registry"
|
|
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/common/scripts"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
gateways "github.com/kandev/kandev/internal/gateway/websocket"
|
|
"github.com/kandev/kandev/internal/github"
|
|
lspinstaller "github.com/kandev/kandev/internal/lsp/installer"
|
|
notificationcontroller "github.com/kandev/kandev/internal/notifications/controller"
|
|
notificationservice "github.com/kandev/kandev/internal/notifications/service"
|
|
notificationstore "github.com/kandev/kandev/internal/notifications/store"
|
|
"github.com/kandev/kandev/internal/orchestrator"
|
|
orchestratorhandlers "github.com/kandev/kandev/internal/orchestrator/handlers"
|
|
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
|
|
taskservice "github.com/kandev/kandev/internal/task/service"
|
|
terminalrepo "github.com/kandev/kandev/internal/terminal/repository"
|
|
terminalservice "github.com/kandev/kandev/internal/terminal/service"
|
|
userservice "github.com/kandev/kandev/internal/user/service"
|
|
ws "github.com/kandev/kandev/pkg/websocket"
|
|
)
|
|
|
|
// scriptServiceAdapter adapts the task service to scripts.ScriptService.
|
|
type scriptServiceAdapter struct {
|
|
taskSvc *taskservice.Service
|
|
}
|
|
|
|
func (a *scriptServiceAdapter) GetRepositoryScript(ctx context.Context, id string) (*scripts.RepositoryScript, error) {
|
|
script, err := a.taskSvc.GetRepositoryScript(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &scripts.RepositoryScript{
|
|
ID: script.ID,
|
|
Name: script.Name,
|
|
Command: script.Command,
|
|
}, nil
|
|
}
|
|
|
|
// hubModeQuerierAdapter converts the hub's SessionMode enum to lifecycle's
|
|
// WorkspacePollMode (same string values) so lifecycle doesn't import websocket.
|
|
type hubModeQuerierAdapter struct {
|
|
hub *gateways.Hub
|
|
}
|
|
|
|
func (a *hubModeQuerierAdapter) GetSessionMode(sessionID string) lifecycle.WorkspacePollMode {
|
|
return lifecycle.WorkspacePollMode(a.hub.GetSessionMode(sessionID))
|
|
}
|
|
|
|
// sessionReaderAdapter implements agenthandlers.SessionReader using the task repository.
|
|
// This allows git handlers to look up session metadata (like base commit SHA) from the database.
|
|
type sessionReaderAdapter struct {
|
|
repo *sqliterepo.Repository
|
|
logger *logger.Logger
|
|
}
|
|
|
|
func (a *sessionReaderAdapter) GetSessionBaseCommit(ctx context.Context, sessionID string) string {
|
|
session, err := a.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
if a.logger != nil {
|
|
a.logger.Warn("failed to load session for base commit lookup",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
}
|
|
return ""
|
|
}
|
|
if session == nil {
|
|
return ""
|
|
}
|
|
return session.BaseCommitSHA
|
|
}
|
|
|
|
func (a *sessionReaderAdapter) GetSessionBaseBranch(ctx context.Context, sessionID string) string {
|
|
session, err := a.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil {
|
|
if a.logger != nil {
|
|
a.logger.Warn("failed to load session for base branch lookup",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
}
|
|
return ""
|
|
}
|
|
if session == nil {
|
|
return ""
|
|
}
|
|
return session.BaseBranch
|
|
}
|
|
|
|
func provideGateway(
|
|
ctx context.Context,
|
|
log *logger.Logger,
|
|
eventBus bus.EventBus,
|
|
taskSvc *taskservice.Service,
|
|
userSvc *userservice.Service,
|
|
orchestratorSvc *orchestrator.Service,
|
|
lifecycleMgr *lifecycle.Manager,
|
|
agentRegistry *registry.Registry,
|
|
notificationRepo notificationstore.Repository,
|
|
taskRepo *sqliterepo.Repository,
|
|
terminalRepo *terminalrepo.Repository,
|
|
githubSvc *github.Service,
|
|
dataDir string,
|
|
) (*gateways.Gateway, *notificationservice.Service, *notificationcontroller.Controller, *terminalservice.Service, error) {
|
|
gateway, err := gateways.Provide(log)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, err
|
|
}
|
|
|
|
// Terminal service — DB-backed first-class user terminals. Backend is
|
|
// nil-safe when no interactive runner is wired (remote-executor mode).
|
|
var terminalSvc *terminalservice.Service
|
|
if terminalRepo != nil {
|
|
var ptyBackend terminalservice.PTYBackend
|
|
if lifecycleMgr != nil {
|
|
ptyBackend = terminalservice.NewInteractiveRunnerBackend(lifecycleMgr.GetInteractiveRunner())
|
|
}
|
|
terminalSvc = terminalservice.New(terminalRepo, ptyBackend, log)
|
|
if taskRepo != nil {
|
|
terminalSvc.SetTaskEnvironmentReader(taskRepo)
|
|
}
|
|
}
|
|
|
|
// Enable dedicated terminal WebSocket for passthrough mode
|
|
scriptSvc := &scriptServiceAdapter{taskSvc: taskSvc}
|
|
if lifecycleMgr != nil {
|
|
gateway.SetLifecycleManager(lifecycleMgr, userSvc, scriptSvc)
|
|
gateway.SetLSPHandler(lifecycleMgr, userSvc, lspinstaller.NewRegistry(dataDir, log))
|
|
gateway.SetVscodeProxy(lifecycleMgr)
|
|
gateway.SetPortProxy(lifecycleMgr)
|
|
gateway.SetPortTunnel(lifecycleMgr)
|
|
}
|
|
|
|
orchestratorHandlers := orchestratorhandlers.NewHandlers(orchestratorSvc, log)
|
|
orchestratorHandlers.RegisterHandlers(gateway.Dispatcher)
|
|
|
|
// Register message queue handlers
|
|
queueHandlers := orchestratorhandlers.NewQueueHandlers(orchestratorSvc.GetMessageQueue(), orchestratorSvc.GetEventBus(), log, orchestratorSvc)
|
|
queueHandlers.RegisterHandlers(gateway.Dispatcher)
|
|
|
|
if lifecycleMgr != nil && agentRegistry != nil {
|
|
agentCtrl := agentcontroller.NewController(lifecycleMgr, agentRegistry)
|
|
agentHandlers := agenthandlers.NewHandlers(agentCtrl, log)
|
|
agentHandlers.RegisterHandlers(gateway.Dispatcher)
|
|
|
|
workspaceFileHandlers := agenthandlers.NewWorkspaceFileHandlers(lifecycleMgr, log)
|
|
workspaceFileHandlers.RegisterHandlers(gateway.Dispatcher)
|
|
|
|
shellHandlers := agenthandlers.NewShellHandlers(lifecycleMgr, scriptSvc, log)
|
|
if terminalSvc != nil {
|
|
shellHandlers.SetTerminalService(terminalSvc)
|
|
}
|
|
shellHandlers.RegisterHandlers(gateway.Dispatcher)
|
|
|
|
gitHandlers := agenthandlers.NewGitHandlers(lifecycleMgr, &sessionReaderAdapter{repo: taskRepo, logger: log}, log)
|
|
if githubSvc != nil {
|
|
gitHandlers.SetOnPRCreated(func(ctx context.Context, sessionID, taskID, prURL, branch, repo string) {
|
|
// Resolve the multi-repo subpath to a per-task repository_id so the
|
|
// resulting TaskPR / PRWatch rows are scoped per-repo. Empty `repo`
|
|
// (single-repo task) yields empty repositoryID and preserves legacy
|
|
// 1:1 task→PR semantics.
|
|
repositoryID := resolveRepositoryIDForSubpath(ctx, taskRepo, taskID, repo, log)
|
|
githubSvc.AssociatePRByURL(ctx, sessionID, taskID, repositoryID, prURL, branch)
|
|
})
|
|
}
|
|
gitHandlers.SetOnGitOperationFailed(func(ctx context.Context, sessionID, taskID, operation, errorOutput string) {
|
|
fixPrompt := fmt.Sprintf("The git %s command failed with the following error:\n\n```\n%s\n```\n\nPlease fix the issues reported above.", operation, errorOutput)
|
|
if _, err := taskSvc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
|
|
TaskSessionID: sessionID,
|
|
TaskID: taskID,
|
|
Content: fmt.Sprintf("Git %s failed", operation),
|
|
AuthorType: "agent",
|
|
Type: "error",
|
|
Metadata: map[string]interface{}{
|
|
"git_operation_error": true,
|
|
"operation": operation,
|
|
"error_output": errorOutput,
|
|
"session_id": sessionID,
|
|
"task_id": taskID,
|
|
"variant": "error",
|
|
"actions": []map[string]interface{}{{
|
|
"type": "ws_request", "label": "Fix", "icon": "sparkles",
|
|
"tooltip": "Ask the agent to fix the git error",
|
|
"test_id": "git-fix-button",
|
|
"params": map[string]interface{}{
|
|
"method": "message.add",
|
|
"payload": map[string]interface{}{"task_id": taskID, "session_id": sessionID, "content": fixPrompt},
|
|
},
|
|
}},
|
|
},
|
|
}); err != nil {
|
|
log.Error("failed to create git operation error message",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("operation", operation),
|
|
zap.Error(err))
|
|
}
|
|
})
|
|
gitHandlers.RegisterHandlers(gateway.Dispatcher)
|
|
|
|
passthroughHandlers := agenthandlers.NewPassthroughHandlers(lifecycleMgr, log)
|
|
passthroughHandlers.RegisterHandlers(gateway.Dispatcher)
|
|
|
|
vscodeHandlers := agenthandlers.NewVscodeHandlers(lifecycleMgr, gateway.VscodeProxyHandler, log)
|
|
vscodeHandlers.RegisterHandlers(gateway.Dispatcher)
|
|
|
|
portHandlers := agenthandlers.NewPortHandlers(lifecycleMgr, gateway.TunnelManager, log)
|
|
portHandlers.RegisterHandlers(gateway.Dispatcher)
|
|
}
|
|
|
|
go gateway.Hub.Run(ctx)
|
|
gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log)
|
|
gateways.RegisterUserNotifications(ctx, eventBus, gateway.Hub, log)
|
|
gateways.RegisterOfficeNotifications(ctx, eventBus, gateway.Hub, log)
|
|
gateways.RegisterRunNotifications(ctx, eventBus, gateway.Hub, log)
|
|
|
|
// Route session focus/subscription transitions from the hub into the
|
|
// lifecycle manager so it can push poll-mode changes to agentctl.
|
|
if lifecycleMgr != nil {
|
|
gateway.Hub.AddSessionModeListener(func(sessionID string, mode gateways.SessionMode) {
|
|
lifecycleMgr.HandleSessionMode(sessionID, lifecycle.WorkspacePollMode(mode))
|
|
})
|
|
// Expose hub's live mode state to lifecycle so it can query on
|
|
// execution-ready and proactively push the right mode even when
|
|
// gateway events fired before the execution was registered.
|
|
lifecycleMgr.SetSessionModeQuerier(&hubModeQuerierAdapter{hub: gateway.Hub})
|
|
}
|
|
|
|
// Broadcast session poll mode transitions to all WS clients. Uses Broadcast
|
|
// (not BroadcastToSession) because focus events fire before subscribe events
|
|
// on page load, so BroadcastToSession misses the focused-but-not-yet-
|
|
// subscribed client. Volume is low (debounced down-transitions) and clients
|
|
// filter by session_id in the payload.
|
|
gateway.Hub.AddSessionModeListener(func(sessionID string, mode gateways.SessionMode) {
|
|
msg, err := ws.NewNotification(ws.ActionSessionPollModeChanged, map[string]interface{}{
|
|
"session_id": sessionID,
|
|
"poll_mode": string(mode),
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
gateway.Hub.Broadcast(msg)
|
|
})
|
|
|
|
notificationSvc := notificationservice.NewService(notificationRepo, taskRepo, gateway.Hub, log)
|
|
notificationCtrl := notificationcontroller.NewController(notificationSvc)
|
|
if eventBus != nil {
|
|
_, err = eventBus.Subscribe(events.TaskSessionStateChanged, func(ctx context.Context, event *bus.Event) error {
|
|
data, ok := event.Data.(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
taskID, _ := data["task_id"].(string)
|
|
sessionID, _ := data["session_id"].(string)
|
|
newState, _ := data["new_state"].(string)
|
|
notificationSvc.HandleTaskSessionStateChanged(ctx, taskID, sessionID, newState)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
log.Error("Failed to subscribe to task session notifications", zap.Error(err))
|
|
}
|
|
|
|
_, err = eventBus.Subscribe(events.OfficeInboxItem, func(ctx context.Context, event *bus.Event) error {
|
|
data, ok := event.Data.(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
itemType, _ := data["type"].(string)
|
|
title, _ := data["title"].(string)
|
|
notificationSvc.HandleInboxItem(ctx, itemType, title)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
log.Error("Failed to subscribe to office inbox notifications", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// Wire the task-lifecycle cascade: terminals are PTYs and DB rows;
|
|
// destroying the task should destroy them too. Mirrors the GitHub PR
|
|
// watch / handoff cascade pattern (see CLAUDE.md "Task lifecycle
|
|
// events").
|
|
if terminalSvc != nil && eventBus != nil {
|
|
subscribeTerminalCleanup(ctx, eventBus, terminalSvc, log)
|
|
}
|
|
|
|
return gateway, notificationSvc, notificationCtrl, terminalSvc, nil
|
|
}
|
|
|
|
// subscribeTerminalCleanup wires the terminal service to task.deleted /
|
|
// task.archived (where archived_at is set). It cleans up PTYs and DB rows
|
|
// for the task.
|
|
func subscribeTerminalCleanup(ctx context.Context, eventBus bus.EventBus, svc *terminalservice.Service, log *logger.Logger) {
|
|
handler := func(eventCtx context.Context, event *bus.Event) error {
|
|
if event == nil {
|
|
return nil
|
|
}
|
|
data, ok := event.Data.(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
taskID, _ := data["task_id"].(string)
|
|
if taskID == "" {
|
|
return nil
|
|
}
|
|
archivedAt, _ := data["archived_at"].(string)
|
|
if event.Type == events.TaskUpdated && archivedAt == "" {
|
|
// Plain update — only react on archive transitions.
|
|
return nil
|
|
}
|
|
log.Info("terminal cleanup on task event started",
|
|
zap.String("task_id", taskID),
|
|
zap.String("event", event.Type),
|
|
zap.Bool("archived", archivedAt != ""))
|
|
deleted, err := svc.CleanupTask(eventCtx, taskID)
|
|
if err != nil {
|
|
log.Warn("terminal cleanup on task event",
|
|
zap.String("task_id", taskID),
|
|
zap.String("event", event.Type),
|
|
zap.Error(err))
|
|
return nil
|
|
}
|
|
log.Info("terminal cleanup on task event completed",
|
|
zap.String("task_id", taskID),
|
|
zap.String("event", event.Type),
|
|
zap.Int("deleted_terminal_rows", deleted))
|
|
return nil
|
|
}
|
|
if _, err := eventBus.Subscribe(events.TaskDeleted, handler); err != nil {
|
|
log.Error("subscribe terminal cleanup (deleted)", zap.Error(err))
|
|
}
|
|
if _, err := eventBus.Subscribe(events.TaskUpdated, handler); err != nil {
|
|
log.Error("subscribe terminal cleanup (updated/archived)", zap.Error(err))
|
|
}
|
|
}
|