refactor(server): move agent, session, permission, and event logic to backend package

Ayman Bagabas created

Change summary

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(-)

Detailed changes

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
+}

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,
+	}
+}

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
+}

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
+}

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)
+}

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
+}

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) {

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)