From 33863b10c24b8c59798349970989840c529d2b15 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 10 Mar 2026 23:20:14 +0300 Subject: [PATCH] refactor(server): move agent, session, permission, and event logic to backend package --- internal/backend/agent.go | 116 ++++++ internal/backend/backend.go | 206 ++++++++++ internal/backend/events.go | 67 ++++ internal/backend/permission.go | 59 +++ internal/backend/session.go | 85 +++++ internal/backend/util.go | 22 ++ internal/server/proto.go | 664 ++++++++++----------------------- internal/server/server.go | 42 +-- 8 files changed, 760 insertions(+), 501 deletions(-) create mode 100644 internal/backend/agent.go create mode 100644 internal/backend/backend.go create mode 100644 internal/backend/events.go create mode 100644 internal/backend/permission.go create mode 100644 internal/backend/session.go create mode 100644 internal/backend/util.go diff --git a/internal/backend/agent.go b/internal/backend/agent.go new file mode 100644 index 0000000000000000000000000000000000000000..1859b3f67aa915c2115ea2f524e73a1e824e840a --- /dev/null +++ b/internal/backend/agent.go @@ -0,0 +1,116 @@ +package backend + +import ( + "context" + + "github.com/charmbracelet/crush/internal/proto" +) + +// SendMessage sends a prompt to the agent coordinator for the given +// workspace and session. +func (b *Backend) SendMessage(ctx context.Context, workspaceID string, msg proto.AgentMessage) error { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return err + } + + if ws.AgentCoordinator == nil { + return ErrAgentNotInitialized + } + + _, err = ws.AgentCoordinator.Run(ctx, msg.SessionID, msg.Prompt) + return err +} + +// GetAgentInfo returns the agent's model and busy status. +func (b *Backend) GetAgentInfo(workspaceID string) (proto.AgentInfo, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return proto.AgentInfo{}, err + } + + var agentInfo proto.AgentInfo + if ws.AgentCoordinator != nil { + m := ws.AgentCoordinator.Model() + agentInfo = proto.AgentInfo{ + Model: m.CatwalkCfg, + IsBusy: ws.AgentCoordinator.IsBusy(), + } + } + return agentInfo, nil +} + +// InitAgent initializes the coder agent for the workspace. +func (b *Backend) InitAgent(ctx context.Context, workspaceID string) error { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return err + } + + return ws.InitCoderAgent(ctx) +} + +// UpdateAgent reloads the agent model configuration. +func (b *Backend) UpdateAgent(ctx context.Context, workspaceID string) error { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return err + } + + return ws.UpdateAgentModel(ctx) +} + +// CancelSession cancels an ongoing agent operation for the given +// session. +func (b *Backend) CancelSession(workspaceID, sessionID string) error { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return err + } + + if ws.AgentCoordinator != nil { + ws.AgentCoordinator.Cancel(sessionID) + } + return nil +} + +// SummarizeSession triggers a session summarization. +func (b *Backend) SummarizeSession(ctx context.Context, workspaceID, sessionID string) error { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return err + } + + if ws.AgentCoordinator == nil { + return ErrAgentNotInitialized + } + + return ws.AgentCoordinator.Summarize(ctx, sessionID) +} + +// QueuedPrompts returns the number of queued prompts for the session. +func (b *Backend) QueuedPrompts(workspaceID, sessionID string) (int, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return 0, err + } + + if ws.AgentCoordinator == nil { + return 0, nil + } + + return ws.AgentCoordinator.QueuedPrompts(sessionID), nil +} + +// ClearQueue clears the prompt queue for the session. +func (b *Backend) ClearQueue(workspaceID, sessionID string) error { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return err + } + + if ws.AgentCoordinator != nil { + ws.AgentCoordinator.ClearQueue(sessionID) + } + return nil +} diff --git a/internal/backend/backend.go b/internal/backend/backend.go new file mode 100644 index 0000000000000000000000000000000000000000..ee7f03cc7046c180d80ea6d5ac4478c6c1d32db8 --- /dev/null +++ b/internal/backend/backend.go @@ -0,0 +1,206 @@ +// Package backend provides transport-agnostic operations for managing +// workspaces, sessions, agents, permissions, and events. It is consumed +// by protocol-specific layers such as HTTP (server) and ACP. +package backend + +import ( + "context" + "errors" + "fmt" + "log/slog" + "runtime" + + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/db" + "github.com/charmbracelet/crush/internal/proto" + "github.com/charmbracelet/crush/internal/ui/util" + "github.com/charmbracelet/crush/internal/version" + "github.com/google/uuid" +) + +// Common errors returned by backend operations. +var ( + ErrWorkspaceNotFound = errors.New("workspace not found") + ErrLSPClientNotFound = errors.New("LSP client not found") + ErrAgentNotInitialized = errors.New("agent coordinator not initialized") + ErrPathRequired = errors.New("path is required") + ErrInvalidPermissionAction = errors.New("invalid permission action") + ErrUnknownCommand = errors.New("unknown command") +) + +// ShutdownFunc is called when the backend needs to trigger a server +// shutdown (e.g. when the last workspace is removed). +type ShutdownFunc func() + +// Backend provides transport-agnostic business logic for the Crush +// server. It manages workspaces and delegates to [app.App] services. +type Backend struct { + workspaces *csync.Map[string, *Workspace] + cfg *config.Config + ctx context.Context + shutdownFn ShutdownFunc +} + +// Workspace represents a running [app.App] workspace with its +// associated resources and state. +type Workspace struct { + *app.App + ID string + Path string + Cfg *config.Config + Env []string +} + +// New creates a new [Backend]. +func New(ctx context.Context, cfg *config.Config, shutdownFn ShutdownFunc) *Backend { + return &Backend{ + workspaces: csync.NewMap[string, *Workspace](), + cfg: cfg, + ctx: ctx, + shutdownFn: shutdownFn, + } +} + +// GetWorkspace retrieves a workspace by ID. +func (b *Backend) GetWorkspace(id string) (*Workspace, error) { + ws, ok := b.workspaces.Get(id) + if !ok { + return nil, ErrWorkspaceNotFound + } + return ws, nil +} + +// ListWorkspaces returns all running workspaces. +func (b *Backend) ListWorkspaces() []proto.Workspace { + workspaces := []proto.Workspace{} + for _, ws := range b.workspaces.Seq2() { + workspaces = append(workspaces, workspaceToProto(ws)) + } + return workspaces +} + +// CreateWorkspace initializes a new workspace from the given +// parameters. It creates the config, database connection, and +// [app.App] instance. +func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Workspace, error) { + if args.Path == "" { + return nil, proto.Workspace{}, ErrPathRequired + } + + id := uuid.New().String() + cfg, err := config.Init(args.Path, args.DataDir, args.Debug) + if err != nil { + return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err) + } + + if cfg.Permissions == nil { + cfg.Permissions = &config.Permissions{} + } + cfg.Permissions.SkipRequests = args.YOLO + + if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil { + return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err) + } + + conn, err := db.Connect(b.ctx, cfg.Options.DataDirectory) + if err != nil { + return nil, proto.Workspace{}, fmt.Errorf("failed to connect to database: %w", err) + } + + appWorkspace, err := app.New(b.ctx, conn, cfg) + if err != nil { + return nil, proto.Workspace{}, fmt.Errorf("failed to create app workspace: %w", err) + } + + ws := &Workspace{ + App: appWorkspace, + ID: id, + Path: args.Path, + Cfg: cfg, + Env: args.Env, + } + + b.workspaces.Set(id, ws) + + if args.Version != "" && args.Version != version.Version { + slog.Warn("Client/server version mismatch", + "client", args.Version, + "server", version.Version, + ) + appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf( + "Server version %q differs from client version %q. Consider restarting the server.", + version.Version, args.Version, + ))) + } + + result := proto.Workspace{ + ID: id, + Path: args.Path, + DataDir: cfg.Options.DataDirectory, + Debug: cfg.Options.Debug, + YOLO: cfg.Permissions.SkipRequests, + Config: cfg, + Env: args.Env, + } + + return ws, result, nil +} + +// DeleteWorkspace shuts down and removes a workspace. If it was the +// last workspace, the shutdown callback is invoked. +func (b *Backend) DeleteWorkspace(id string) { + ws, ok := b.workspaces.Get(id) + if ok { + ws.Shutdown() + } + b.workspaces.Del(id) + + if b.workspaces.Len() == 0 && b.shutdownFn != nil { + slog.Info("Last workspace removed, shutting down server...") + b.shutdownFn() + } +} + +// GetWorkspaceProto returns the proto representation of a workspace. +func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) { + ws, err := b.GetWorkspace(id) + if err != nil { + return proto.Workspace{}, err + } + return workspaceToProto(ws), nil +} + +// VersionInfo returns server version information. +func (b *Backend) VersionInfo() proto.VersionInfo { + return proto.VersionInfo{ + Version: version.Version, + Commit: version.Commit, + GoVersion: runtime.Version(), + Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), + } +} + +// Config returns the server-level configuration. +func (b *Backend) Config() *config.Config { + return b.cfg +} + +// Shutdown initiates a graceful server shutdown. +func (b *Backend) Shutdown() { + if b.shutdownFn != nil { + b.shutdownFn() + } +} + +func workspaceToProto(ws *Workspace) proto.Workspace { + return proto.Workspace{ + ID: ws.ID, + Path: ws.Path, + YOLO: ws.Cfg.Permissions != nil && ws.Cfg.Permissions.SkipRequests, + DataDir: ws.Cfg.Options.DataDirectory, + Debug: ws.Cfg.Options.Debug, + Config: ws.Cfg, + } +} diff --git a/internal/backend/events.go b/internal/backend/events.go new file mode 100644 index 0000000000000000000000000000000000000000..bb104a18d0c220f7ceb3ef8ca96c8d65227409e3 --- /dev/null +++ b/internal/backend/events.go @@ -0,0 +1,67 @@ +package backend + +import ( + tea "charm.land/bubbletea/v2" + + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/config" +) + +// SubscribeEvents returns the event channel for a workspace's app. +func (b *Backend) SubscribeEvents(workspaceID string) (<-chan tea.Msg, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return nil, err + } + + return ws.Events(), nil +} + +// GetLSPStates returns the state of all LSP clients. +func (b *Backend) GetLSPStates(workspaceID string) (map[string]app.LSPClientInfo, error) { + _, err := b.GetWorkspace(workspaceID) + if err != nil { + return nil, err + } + + return app.GetLSPStates(), nil +} + +// GetLSPDiagnostics returns diagnostics for a specific LSP client in +// the workspace. +func (b *Backend) GetLSPDiagnostics(workspaceID, lspName string) (any, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return nil, err + } + + for name, client := range ws.LSPManager.Clients().Seq2() { + if name == lspName { + return client.GetDiagnostics(), nil + } + } + + return nil, ErrLSPClientNotFound +} + +// GetWorkspaceConfig returns the workspace-level configuration. +func (b *Backend) GetWorkspaceConfig(workspaceID string) (*config.Config, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return nil, err + } + + return ws.Cfg, nil +} + +// GetWorkspaceProviders returns the configured providers for a +// workspace. +func (b *Backend) GetWorkspaceProviders(workspaceID string) (any, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return nil, err + } + + providers, _ := config.Providers(ws.Cfg) + return providers, nil +} diff --git a/internal/backend/permission.go b/internal/backend/permission.go new file mode 100644 index 0000000000000000000000000000000000000000..bb7876d6989ec8bee6a99362cb5f5ef914fc5c49 --- /dev/null +++ b/internal/backend/permission.go @@ -0,0 +1,59 @@ +package backend + +import ( + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/proto" +) + +// GrantPermission grants, denies, or persistently grants a permission +// request. +func (b *Backend) GrantPermission(workspaceID string, req proto.PermissionGrant) error { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return err + } + + perm := permission.PermissionRequest{ + ID: req.Permission.ID, + SessionID: req.Permission.SessionID, + ToolCallID: req.Permission.ToolCallID, + ToolName: req.Permission.ToolName, + Description: req.Permission.Description, + Action: req.Permission.Action, + Params: req.Permission.Params, + Path: req.Permission.Path, + } + + switch req.Action { + case proto.PermissionAllow: + ws.Permissions.Grant(perm) + case proto.PermissionAllowForSession: + ws.Permissions.GrantPersistent(perm) + case proto.PermissionDeny: + ws.Permissions.Deny(perm) + default: + return ErrInvalidPermissionAction + } + return nil +} + +// SetPermissionsSkip sets whether permission prompts are skipped. +func (b *Backend) SetPermissionsSkip(workspaceID string, skip bool) error { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return err + } + + ws.Permissions.SetSkipRequests(skip) + return nil +} + +// GetPermissionsSkip returns whether permission prompts are skipped. +func (b *Backend) GetPermissionsSkip(workspaceID string) (bool, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return false, err + } + + return ws.Permissions.SkipRequests(), nil +} diff --git a/internal/backend/session.go b/internal/backend/session.go new file mode 100644 index 0000000000000000000000000000000000000000..20592f6d9f4fdcd0afe95c54914403cb13d6277c --- /dev/null +++ b/internal/backend/session.go @@ -0,0 +1,85 @@ +package backend + +import ( + "context" + + "github.com/charmbracelet/crush/internal/proto" + "github.com/charmbracelet/crush/internal/session" +) + +// CreateSession creates a new session in the given workspace. +func (b *Backend) CreateSession(ctx context.Context, workspaceID, title string) (session.Session, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return session.Session{}, err + } + + return ws.Sessions.Create(ctx, title) +} + +// GetSession retrieves a session by workspace and session ID. +func (b *Backend) GetSession(ctx context.Context, workspaceID, sessionID string) (session.Session, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return session.Session{}, err + } + + return ws.Sessions.Get(ctx, sessionID) +} + +// ListSessions returns all sessions in the given workspace. +func (b *Backend) ListSessions(ctx context.Context, workspaceID string) ([]session.Session, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return nil, err + } + + return ws.Sessions.List(ctx) +} + +// GetAgentSession returns session metadata with the agent's busy +// status. +func (b *Backend) GetAgentSession(ctx context.Context, workspaceID, sessionID string) (proto.AgentSession, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return proto.AgentSession{}, err + } + + se, err := ws.Sessions.Get(ctx, sessionID) + if err != nil { + return proto.AgentSession{}, err + } + + var isSessionBusy bool + if ws.AgentCoordinator != nil { + isSessionBusy = ws.AgentCoordinator.IsSessionBusy(sessionID) + } + + return proto.AgentSession{ + Session: proto.Session{ + ID: se.ID, + Title: se.Title, + }, + IsBusy: isSessionBusy, + }, nil +} + +// ListSessionMessages returns all messages for a session. +func (b *Backend) ListSessionMessages(ctx context.Context, workspaceID, sessionID string) (any, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return nil, err + } + + return ws.Messages.List(ctx, sessionID) +} + +// ListSessionHistory returns the history items for a session. +func (b *Backend) ListSessionHistory(ctx context.Context, workspaceID, sessionID string) (any, error) { + ws, err := b.GetWorkspace(workspaceID) + if err != nil { + return nil, err + } + + return ws.History.ListBySession(ctx, sessionID) +} diff --git a/internal/backend/util.go b/internal/backend/util.go new file mode 100644 index 0000000000000000000000000000000000000000..9b29d0f704ac7473963b5a3765ca60bb7e649bf5 --- /dev/null +++ b/internal/backend/util.go @@ -0,0 +1,22 @@ +package backend + +import ( + "fmt" + "os" + "path/filepath" +) + +func createDotCrushDir(dir string) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("failed to create data directory: %q %w", dir, err) + } + + gitIgnorePath := filepath.Join(dir, ".gitignore") + if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) { + if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil { + return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err) + } + } + + return nil +} diff --git a/internal/server/proto.go b/internal/server/proto.go index eca944db8e2473415594f2cb419afe82bb43296c..b2ba0c769e1796785ceeca41659316928bd05335 100644 --- a/internal/server/proto.go +++ b/internal/server/proto.go @@ -1,28 +1,19 @@ package server import ( - "context" "encoding/json" + "errors" "fmt" - "log/slog" "net/http" - "os" - "path/filepath" - "runtime" - - "github.com/charmbracelet/crush/internal/app" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/db" - "github.com/charmbracelet/crush/internal/permission" + + "github.com/charmbracelet/crush/internal/backend" "github.com/charmbracelet/crush/internal/proto" "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/ui/util" - "github.com/charmbracelet/crush/internal/version" - "github.com/google/uuid" ) type controllerV1 struct { - *Server + backend *backend.Backend + server *Server } func (c *controllerV1) handleGetHealth(w http.ResponseWriter, _ *http.Request) { @@ -30,641 +21,360 @@ func (c *controllerV1) handleGetHealth(w http.ResponseWriter, _ *http.Request) { } func (c *controllerV1) handleGetVersion(w http.ResponseWriter, _ *http.Request) { - jsonEncode(w, proto.VersionInfo{ - Version: version.Version, - Commit: version.Commit, - GoVersion: runtime.Version(), - Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), - }) + jsonEncode(w, c.backend.VersionInfo()) } func (c *controllerV1) handlePostControl(w http.ResponseWriter, r *http.Request) { var req proto.ServerControl if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - c.logError(r, "Failed to decode request", "error", err) + c.server.logError(r, "Failed to decode request", "error", err) jsonError(w, http.StatusBadRequest, "failed to decode request") return } switch req.Command { case "shutdown": - go func() { - slog.Info("Shutting down server...") - if err := c.Shutdown(context.Background()); err != nil { - slog.Error("Failed to shutdown server", "error", err) - } - }() + c.backend.Shutdown() default: - c.logError(r, "Unknown command", "command", req.Command) + c.server.logError(r, "Unknown command", "command", req.Command) jsonError(w, http.StatusBadRequest, "unknown command") return } } func (c *controllerV1) handleGetConfig(w http.ResponseWriter, _ *http.Request) { - jsonEncode(w, c.cfg) + jsonEncode(w, c.backend.Config()) } func (c *controllerV1) handleGetWorkspaces(w http.ResponseWriter, _ *http.Request) { - workspaces := []proto.Workspace{} - for _, ws := range c.workspaces.Seq2() { - workspaces = append(workspaces, proto.Workspace{ - ID: ws.id, - Path: ws.path, - YOLO: ws.cfg.Permissions != nil && ws.cfg.Permissions.SkipRequests, - DataDir: ws.cfg.Options.DataDirectory, - Debug: ws.cfg.Options.Debug, - Config: ws.cfg, - }) - } - jsonEncode(w, workspaces) + jsonEncode(w, c.backend.ListWorkspaces()) } -func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handleGetWorkspace(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") + ws, err := c.backend.GetWorkspaceProto(id) + if err != nil { + c.handleError(w, r, err) return } + jsonEncode(w, ws) +} - lspName := r.PathValue("lsp") - var found bool - for name, client := range ws.LSPManager.Clients().Seq2() { - if name == lspName { - diagnostics := client.GetDiagnostics() - jsonEncode(w, diagnostics) - found = true - break - } +func (c *controllerV1) handlePostWorkspaces(w http.ResponseWriter, r *http.Request) { + var args proto.Workspace + if err := json.NewDecoder(r.Body).Decode(&args); err != nil { + c.server.logError(r, "Failed to decode request", "error", err) + jsonError(w, http.StatusBadRequest, "failed to decode request") + return } - if !found { - c.logError(r, "LSP client not found", "id", id, "lsp", lspName) - jsonError(w, http.StatusNotFound, "LSP client not found") + _, result, err := c.backend.CreateWorkspace(args) + if err != nil { + c.handleError(w, r, err) + return } + jsonEncode(w, result) } -func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handleDeleteWorkspaces(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - _, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") - return - } - - lspClients := app.GetLSPStates() - jsonEncode(w, lspClients) + c.backend.DeleteWorkspace(id) } -func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handleGetWorkspaceConfig(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") + cfg, err := c.backend.GetWorkspaceConfig(id) + if err != nil { + c.handleError(w, r, err) return } - - sid := r.PathValue("sid") - queued := ws.App.AgentCoordinator.QueuedPrompts(sid) - jsonEncode(w, queued) + jsonEncode(w, cfg) } -func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handleGetWorkspaceProviders(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") + providers, err := c.backend.GetWorkspaceProviders(id) + if err != nil { + c.handleError(w, r, err) return } - - sid := r.PathValue("sid") - ws.App.AgentCoordinator.ClearQueue(sid) - w.WriteHeader(http.StatusOK) + jsonEncode(w, providers) } -func (c *controllerV1) handleGetWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.Request) { + flusher := http.NewResponseController(w) id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") + events, err := c.backend.SubscribeEvents(id) + if err != nil { + c.handleError(w, r, err) return } - sid := r.PathValue("sid") - if err := ws.App.AgentCoordinator.Summarize(r.Context(), sid); err != nil { - c.logError(r, "Failed to summarize session", "error", err, "id", id, "sid", sid) - jsonError(w, http.StatusInternalServerError, "failed to summarize session") - return - } -} + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") -func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") - return - } + for { + select { + case <-r.Context().Done(): + c.server.logDebug(r, "Stopping event stream") + return + case ev, ok := <-events: + if !ok { + return + } + c.server.logDebug(r, "Sending event", "event", fmt.Sprintf("%T %+v", ev, ev)) + data, err := json.Marshal(ev) + if err != nil { + c.server.logError(r, "Failed to marshal event", "error", err) + continue + } - sid := r.PathValue("sid") - if ws.App.AgentCoordinator != nil { - ws.App.AgentCoordinator.Cancel(sid) + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } } - w.WriteHeader(http.StatusOK) } -func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") - return - } - - sid := r.PathValue("sid") - se, err := ws.App.Sessions.Get(r.Context(), sid) + states, err := c.backend.GetLSPStates(id) if err != nil { - c.logError(r, "Failed to get session", "error", err, "id", id, "sid", sid) - jsonError(w, http.StatusInternalServerError, "failed to get session") + c.handleError(w, r, err) return } - - var isSessionBusy bool - if ws.App.AgentCoordinator != nil { - isSessionBusy = ws.App.AgentCoordinator.IsSessionBusy(sid) - } - - jsonEncode(w, proto.AgentSession{ - Session: proto.Session{ - ID: se.ID, - Title: se.Title, - }, - IsBusy: isSessionBusy, - }) + jsonEncode(w, states) } -func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") - return - } - - w.Header().Set("Accept", "application/json") - - var msg proto.AgentMessage - if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { - c.logError(r, "Failed to decode request", "error", err) - jsonError(w, http.StatusBadRequest, "failed to decode request") - return - } - - if ws.App.AgentCoordinator == nil { - c.logError(r, "Agent coordinator not initialized", "id", id) - jsonError(w, http.StatusBadRequest, "agent coordinator not initialized") - return - } - - if _, err := ws.App.AgentCoordinator.Run(c.ctx, msg.SessionID, msg.Prompt); err != nil { - c.logError(r, "Failed to enqueue message", "error", err, "id", id, "sid", msg.SessionID) - jsonError(w, http.StatusInternalServerError, "failed to enqueue message") + lspName := r.PathValue("lsp") + diagnostics, err := c.backend.GetLSPDiagnostics(id, lspName) + if err != nil { + c.handleError(w, r, err) return } + jsonEncode(w, diagnostics) } -func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") + sessions, err := c.backend.ListSessions(r.Context(), id) + if err != nil { + c.handleError(w, r, err) return } - - var agentInfo proto.AgentInfo - if ws.App.AgentCoordinator != nil { - m := ws.App.AgentCoordinator.Model() - agentInfo = proto.AgentInfo{ - Model: m.CatwalkCfg, - IsBusy: ws.App.AgentCoordinator.IsBusy(), - } - } - jsonEncode(w, agentInfo) + jsonEncode(w, sessions) } -func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") + + var args session.Session + if err := json.NewDecoder(r.Body).Decode(&args); err != nil { + c.server.logError(r, "Failed to decode request", "error", err) + jsonError(w, http.StatusBadRequest, "failed to decode request") return } - if err := ws.App.UpdateAgentModel(r.Context()); err != nil { - c.logError(r, "Failed to update agent model", "error", err) - jsonError(w, http.StatusInternalServerError, "failed to update agent model") + sess, err := c.backend.CreateSession(r.Context(), id, args.Title) + if err != nil { + c.handleError(w, r, err) return } + jsonEncode(w, sess) } -func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") - return - } - - if err := ws.App.InitCoderAgent(r.Context()); err != nil { - c.logError(r, "Failed to initialize coder agent", "error", err) - jsonError(w, http.StatusInternalServerError, "failed to initialize coder agent") + sid := r.PathValue("sid") + sess, err := c.backend.GetSession(r.Context(), id, sid) + if err != nil { + c.handleError(w, r, err) return } + jsonEncode(w, sess) } func (c *controllerV1) handleGetWorkspaceSessionHistory(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") - return - } - sid := r.PathValue("sid") - historyItems, err := ws.App.History.ListBySession(r.Context(), sid) + history, err := c.backend.ListSessionHistory(r.Context(), id, sid) if err != nil { - c.logError(r, "Failed to list history", "error", err, "id", id, "sid", sid) - jsonError(w, http.StatusInternalServerError, "failed to list history") + c.handleError(w, r, err) return } - - jsonEncode(w, historyItems) + jsonEncode(w, history) } func (c *controllerV1) handleGetWorkspaceSessionMessages(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") - return - } - sid := r.PathValue("sid") - messages, err := ws.App.Messages.List(r.Context(), sid) + messages, err := c.backend.ListSessionMessages(r.Context(), id, sid) if err != nil { - c.logError(r, "Failed to list messages", "error", err, "id", id, "sid", sid) - jsonError(w, http.StatusInternalServerError, "failed to list messages") + c.handleError(w, r, err) return } - jsonEncode(w, messages) } -func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") - return - } - - sid := r.PathValue("sid") - sess, err := ws.App.Sessions.Get(r.Context(), sid) + info, err := c.backend.GetAgentInfo(id) if err != nil { - c.logError(r, "Failed to get session", "error", err, "id", id, "sid", sid) - jsonError(w, http.StatusInternalServerError, "failed to get session") + c.handleError(w, r, err) return } - - jsonEncode(w, sess) + jsonEncode(w, info) } -func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") - return - } - var args session.Session - if err := json.NewDecoder(r.Body).Decode(&args); err != nil { - c.logError(r, "Failed to decode request", "error", err) + w.Header().Set("Accept", "application/json") + + var msg proto.AgentMessage + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + c.server.logError(r, "Failed to decode request", "error", err) jsonError(w, http.StatusBadRequest, "failed to decode request") return } - sess, err := ws.App.Sessions.Create(r.Context(), args.Title) - if err != nil { - c.logError(r, "Failed to create session", "error", err, "id", id) - jsonError(w, http.StatusInternalServerError, "failed to create session") + if err := c.backend.SendMessage(r.Context(), id, msg); err != nil { + c.handleError(w, r, err) return } - - jsonEncode(w, sess) } -func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") + if err := c.backend.InitAgent(r.Context(), id); err != nil { + c.handleError(w, r, err) return } - - sessions, err := ws.App.Sessions.List(r.Context()) - if err != nil { - c.logError(r, "Failed to list sessions", "error", err) - jsonError(w, http.StatusInternalServerError, "failed to list sessions") - return - } - - jsonEncode(w, sessions) } -func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") - return - } - - var req proto.PermissionGrant - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - c.logError(r, "Failed to decode request", "error", err) - jsonError(w, http.StatusBadRequest, "failed to decode request") - return - } - - perm := permission.PermissionRequest{ - ID: req.Permission.ID, - SessionID: req.Permission.SessionID, - ToolCallID: req.Permission.ToolCallID, - ToolName: req.Permission.ToolName, - Description: req.Permission.Description, - Action: req.Permission.Action, - Params: req.Permission.Params, - Path: req.Permission.Path, - } - - switch req.Action { - case proto.PermissionAllow: - ws.App.Permissions.Grant(perm) - case proto.PermissionAllowForSession: - ws.App.Permissions.GrantPersistent(perm) - case proto.PermissionDeny: - ws.App.Permissions.Deny(perm) - default: - c.logError(r, "Invalid permission action", "action", req.Action) - jsonError(w, http.StatusBadRequest, "invalid permission action") + if err := c.backend.UpdateAgent(r.Context(), id); err != nil { + c.handleError(w, r, err) return } } -func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") - return - } - - var req proto.PermissionSkipRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - c.logError(r, "Failed to decode request", "error", err) - jsonError(w, http.StatusBadRequest, "failed to decode request") + sid := r.PathValue("sid") + agentSession, err := c.backend.GetAgentSession(r.Context(), id, sid) + if err != nil { + c.handleError(w, r, err) return } - - ws.App.Permissions.SetSkipRequests(req.Skip) + jsonEncode(w, agentSession) } -func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") + sid := r.PathValue("sid") + if err := c.backend.CancelSession(id, sid); err != nil { + c.handleError(w, r, err) return } - - skip := ws.App.Permissions.SkipRequests() - jsonEncode(w, proto.PermissionSkipRequest{Skip: skip}) + w.WriteHeader(http.StatusOK) } -func (c *controllerV1) handleGetWorkspaceProviders(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") + sid := r.PathValue("sid") + queued, err := c.backend.QueuedPrompts(id, sid) + if err != nil { + c.handleError(w, r, err) return } - - providers, _ := config.Providers(ws.cfg) - jsonEncode(w, providers) + jsonEncode(w, queued) } -func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.Request) { - flusher := http.NewResponseController(w) +func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") + sid := r.PathValue("sid") + if err := c.backend.ClearQueue(id, sid); err != nil { + c.handleError(w, r, err) return } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - - events := ws.App.Events() - - for { - select { - case <-r.Context().Done(): - c.logDebug(r, "Stopping event stream") - return - case ev, ok := <-events: - if !ok { - return - } - c.logDebug(r, "Sending event", "event", fmt.Sprintf("%T %+v", ev, ev)) - data, err := json.Marshal(ev) - if err != nil { - c.logError(r, "Failed to marshal event", "error", err) - continue - } - - fmt.Fprintf(w, "data: %s\n\n", data) - flusher.Flush() - } - } + w.WriteHeader(http.StatusOK) } -func (c *controllerV1) handleGetWorkspaceConfig(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handleGetWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") + sid := r.PathValue("sid") + if err := c.backend.SummarizeSession(r.Context(), id, sid); err != nil { + c.handleError(w, r, err) return } - - jsonEncode(w, ws.cfg) -} - -func (c *controllerV1) handleDeleteWorkspaces(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if ok { - ws.App.Shutdown() - } - c.workspaces.Del(id) - - // When the last workspace is removed, shut down the server. - if c.workspaces.Len() == 0 { - slog.Info("Last workspace removed, shutting down server...") - go func() { - if err := c.Shutdown(context.Background()); err != nil { - slog.Error("Failed to shutdown server", "error", err) - } - }() - } } -func (c *controllerV1) handleGetWorkspace(w http.ResponseWriter, r *http.Request) { +func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - ws, ok := c.workspaces.Get(id) - if !ok { - c.logError(r, "Workspace not found", "id", id) - jsonError(w, http.StatusNotFound, "workspace not found") - return - } - - jsonEncode(w, proto.Workspace{ - ID: ws.id, - Path: ws.path, - YOLO: ws.cfg.Permissions != nil && ws.cfg.Permissions.SkipRequests, - DataDir: ws.cfg.Options.DataDirectory, - Debug: ws.cfg.Options.Debug, - Config: ws.cfg, - }) -} -func (c *controllerV1) handlePostWorkspaces(w http.ResponseWriter, r *http.Request) { - var args proto.Workspace - if err := json.NewDecoder(r.Body).Decode(&args); err != nil { - c.logError(r, "Failed to decode request", "error", err) + var req proto.PermissionGrant + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + c.server.logError(r, "Failed to decode request", "error", err) jsonError(w, http.StatusBadRequest, "failed to decode request") return } - if args.Path == "" { - c.logError(r, "Path is required") - jsonError(w, http.StatusBadRequest, "path is required") - return - } - - id := uuid.New().String() - cfg, err := config.Init(args.Path, args.DataDir, args.Debug) - if err != nil { - c.logError(r, "Failed to initialize config", "error", err) - jsonError(w, http.StatusBadRequest, fmt.Sprintf("failed to initialize config: %v", err)) + if err := c.backend.GrantPermission(id, req); err != nil { + c.handleError(w, r, err) return } +} - if cfg.Permissions == nil { - cfg.Permissions = &config.Permissions{} - } - cfg.Permissions.SkipRequests = args.YOLO +func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") - if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil { - c.logError(r, "Failed to create data directory", "error", err) - jsonError(w, http.StatusInternalServerError, "failed to create data directory") + var req proto.PermissionSkipRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + c.server.logError(r, "Failed to decode request", "error", err) + jsonError(w, http.StatusBadRequest, "failed to decode request") return } - conn, err := db.Connect(c.ctx, cfg.Options.DataDirectory) - if err != nil { - c.logError(r, "Failed to connect to database", "error", err) - jsonError(w, http.StatusInternalServerError, "failed to connect to database") + if err := c.backend.SetPermissionsSkip(id, req.Skip); err != nil { + c.handleError(w, r, err) return } +} - appWorkspace, err := app.New(c.ctx, conn, cfg) +func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + skip, err := c.backend.GetPermissionsSkip(id) if err != nil { - slog.Error("Failed to create app workspace", "error", err) - jsonError(w, http.StatusInternalServerError, "failed to create app workspace") + c.handleError(w, r, err) return } - - ws := &Workspace{ - App: appWorkspace, - id: id, - path: args.Path, - cfg: cfg, - env: args.Env, - } - - c.workspaces.Set(id, ws) - - if args.Version != "" && args.Version != version.Version { - slog.Warn("Client/server version mismatch", - "client", args.Version, - "server", version.Version, - ) - appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf( - "Server version %q differs from client version %q. Consider restarting the server.", - version.Version, args.Version, - ))) - } - - jsonEncode(w, proto.Workspace{ - ID: id, - Path: args.Path, - DataDir: cfg.Options.DataDirectory, - Debug: cfg.Options.Debug, - YOLO: cfg.Permissions.SkipRequests, - Config: cfg, - Env: args.Env, - }) + jsonEncode(w, proto.PermissionSkipRequest{Skip: skip}) } -func createDotCrushDir(dir string) error { - if err := os.MkdirAll(dir, 0o700); err != nil { - return fmt.Errorf("failed to create data directory: %q %w", dir, err) - } - - gitIgnorePath := filepath.Join(dir, ".gitignore") - if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) { - if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil { - return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err) - } - } - - return nil +// handleError maps backend errors to HTTP status codes and writes the +// JSON error response. +func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err error) { + status := http.StatusInternalServerError + switch { + case errors.Is(err, backend.ErrWorkspaceNotFound): + status = http.StatusNotFound + case errors.Is(err, backend.ErrLSPClientNotFound): + status = http.StatusNotFound + case errors.Is(err, backend.ErrAgentNotInitialized): + status = http.StatusBadRequest + case errors.Is(err, backend.ErrPathRequired): + status = http.StatusBadRequest + case errors.Is(err, backend.ErrInvalidPermissionAction): + status = http.StatusBadRequest + case errors.Is(err, backend.ErrUnknownCommand): + status = http.StatusBadRequest + } + c.server.logError(r, err.Error()) + jsonError(w, status, err.Error()) } func jsonEncode(w http.ResponseWriter, v any) { diff --git a/internal/server/server.go b/internal/server/server.go index 38fa9109a5257b00cdfd1d7ab2b9fc7c7ad9fc6f..72e64be0d9c82dbae7599d96acf5d9ded50fcc15 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,25 +11,13 @@ import ( "runtime" "strings" - "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/backend" "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/csync" ) // ErrServerClosed is returned when the server is closed. var ErrServerClosed = http.ErrServerClosed -// Workspace represents a running [app.App] workspace with its associated -// resources and state. -type Workspace struct { - *app.App - ln net.Listener - cfg *config.Config - id string - path string - env []string -} - // ParseHostURL parses a host URL into a [url.URL]. func ParseHostURL(host string) (*url.URL, error) { proto, addr, ok := strings.Cut(host, "://") @@ -72,14 +60,11 @@ type Server struct { Addr string network string - h *http.Server - ln net.Listener - ctx context.Context + h *http.Server + ln net.Listener - // workspaces is a map of running applications managed by the server. - workspaces *csync.Map[string, *Workspace] - cfg *config.Config - logger *slog.Logger + backend *backend.Backend + logger *slog.Logger } // SetLogger sets the logger for the server. @@ -101,14 +86,23 @@ func NewServer(cfg *config.Config, network, address string) *Server { s := new(Server) s.Addr = address s.network = network - s.cfg = cfg - s.workspaces = csync.NewMap[string, *Workspace]() - s.ctx = context.Background() + + // The backend is created with a shutdown callback that triggers + // a graceful server shutdown (e.g. when the last workspace is + // removed). + s.backend = backend.New(context.Background(), cfg, func() { + go func() { + slog.Info("Shutting down server...") + if err := s.Shutdown(context.Background()); err != nil { + slog.Error("Failed to shutdown server", "error", err) + } + }() + }) var p http.Protocols p.SetHTTP1(true) p.SetUnencryptedHTTP2(true) - c := &controllerV1{Server: s} + c := &controllerV1{backend: s.backend, server: s} mux := http.NewServeMux() mux.HandleFunc("GET /v1/health", c.handleGetHealth) mux.HandleFunc("GET /v1/version", c.handleGetVersion)