on main help sign in
refactor(server): move agent, session, permission, and event logic to backend package
commit33863b10c24b8c59798349970989840c529d2b15 authorAyman Bagabas <ayman.bagabas@gmail.com> dateTue 10 Mar 2026, 23:20 (7 months ago) parent1b37d55
8 files changed+760−501
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+187−477 internal/server/server.go+18−24
internal/backend/agent.gonew file+116
@@ -0,0 +1,116 @@
1+package backend
2+
3+import (
4+ "context"
5+
6+ "github.com/charmbracelet/crush/internal/proto"
7+)
8+
9+// SendMessage sends a prompt to the agent coordinator for the given
10+// workspace and session.
11+func (b *Backend) SendMessage(ctx context.Context, workspaceID string, msg proto.AgentMessage) error {
12+ ws, err := b.GetWorkspace(workspaceID)
13+ if err != nil {
14+ return err
15+ }
16+
17+ if ws.AgentCoordinator == nil {
18+ return ErrAgentNotInitialized
19+ }
20+
21+ _, err = ws.AgentCoordinator.Run(ctx, msg.SessionID, msg.Prompt)
22+ return err
23+}
24+
25+// GetAgentInfo returns the agent's model and busy status.
26+func (b *Backend) GetAgentInfo(workspaceID string) (proto.AgentInfo, error) {
27+ ws, err := b.GetWorkspace(workspaceID)
28+ if err != nil {
29+ return proto.AgentInfo{}, err
30+ }
31+
32+ var agentInfo proto.AgentInfo
33+ if ws.AgentCoordinator != nil {
34+ m := ws.AgentCoordinator.Model()
35+ agentInfo = proto.AgentInfo{
36+ Model: m.CatwalkCfg,
37+ IsBusy: ws.AgentCoordinator.IsBusy(),
38+ }
39+ }
40+ return agentInfo, nil
41+}
42+
43+// InitAgent initializes the coder agent for the workspace.
44+func (b *Backend) InitAgent(ctx context.Context, workspaceID string) error {
45+ ws, err := b.GetWorkspace(workspaceID)
46+ if err != nil {
47+ return err
48+ }
49+
50+ return ws.InitCoderAgent(ctx)
51+}
52+
53+// UpdateAgent reloads the agent model configuration.
54+func (b *Backend) UpdateAgent(ctx context.Context, workspaceID string) error {
55+ ws, err := b.GetWorkspace(workspaceID)
56+ if err != nil {
57+ return err
58+ }
59+
60+ return ws.UpdateAgentModel(ctx)
61+}
62+
63+// CancelSession cancels an ongoing agent operation for the given
64+// session.
65+func (b *Backend) CancelSession(workspaceID, sessionID string) error {
66+ ws, err := b.GetWorkspace(workspaceID)
67+ if err != nil {
68+ return err
69+ }
70+
71+ if ws.AgentCoordinator != nil {
72+ ws.AgentCoordinator.Cancel(sessionID)
73+ }
74+ return nil
75+}
76+
77+// SummarizeSession triggers a session summarization.
78+func (b *Backend) SummarizeSession(ctx context.Context, workspaceID, sessionID string) error {
79+ ws, err := b.GetWorkspace(workspaceID)
80+ if err != nil {
81+ return err
82+ }
83+
84+ if ws.AgentCoordinator == nil {
85+ return ErrAgentNotInitialized
86+ }
87+
88+ return ws.AgentCoordinator.Summarize(ctx, sessionID)
89+}
90+
91+// QueuedPrompts returns the number of queued prompts for the session.
92+func (b *Backend) QueuedPrompts(workspaceID, sessionID string) (int, error) {
93+ ws, err := b.GetWorkspace(workspaceID)
94+ if err != nil {
95+ return 0, err
96+ }
97+
98+ if ws.AgentCoordinator == nil {
99+ return 0, nil
100+ }
101+
102+ return ws.AgentCoordinator.QueuedPrompts(sessionID), nil
103+}
104+
105+// ClearQueue clears the prompt queue for the session.
106+func (b *Backend) ClearQueue(workspaceID, sessionID string) error {
107+ ws, err := b.GetWorkspace(workspaceID)
108+ if err != nil {
109+ return err
110+ }
111+
112+ if ws.AgentCoordinator != nil {
113+ ws.AgentCoordinator.ClearQueue(sessionID)
114+ }
115+ return nil
116+}
internal/backend/backend.gonew file+206
@@ -0,0 +1,206 @@
1+// Package backend provides transport-agnostic operations for managing
2+// workspaces, sessions, agents, permissions, and events. It is consumed
3+// by protocol-specific layers such as HTTP (server) and ACP.
4+package backend
5+
6+import (
7+ "context"
8+ "errors"
9+ "fmt"
10+ "log/slog"
11+ "runtime"
12+
13+ "github.com/charmbracelet/crush/internal/app"
14+ "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/csync"
16+ "github.com/charmbracelet/crush/internal/db"
17+ "github.com/charmbracelet/crush/internal/proto"
18+ "github.com/charmbracelet/crush/internal/ui/util"
19+ "github.com/charmbracelet/crush/internal/version"
20+ "github.com/google/uuid"
21+)
22+
23+// Common errors returned by backend operations.
24+var (
25+ ErrWorkspaceNotFound = errors.New("workspace not found")
26+ ErrLSPClientNotFound = errors.New("LSP client not found")
27+ ErrAgentNotInitialized = errors.New("agent coordinator not initialized")
28+ ErrPathRequired = errors.New("path is required")
29+ ErrInvalidPermissionAction = errors.New("invalid permission action")
30+ ErrUnknownCommand = errors.New("unknown command")
31+)
32+
33+// ShutdownFunc is called when the backend needs to trigger a server
34+// shutdown (e.g. when the last workspace is removed).
35+type ShutdownFunc func()
36+
37+// Backend provides transport-agnostic business logic for the Crush
38+// server. It manages workspaces and delegates to [app.App] services.
39+type Backend struct {
40+ workspaces *csync.Map[string, *Workspace]
41+ cfg *config.Config
42+ ctx context.Context
43+ shutdownFn ShutdownFunc
44+}
45+
46+// Workspace represents a running [app.App] workspace with its
47+// associated resources and state.
48+type Workspace struct {
49+ *app.App
50+ ID string
51+ Path string
52+ Cfg *config.Config
53+ Env []string
54+}
55+
56+// New creates a new [Backend].
57+func New(ctx context.Context, cfg *config.Config, shutdownFn ShutdownFunc) *Backend {
58+ return &Backend{
59+ workspaces: csync.NewMap[string, *Workspace](),
60+ cfg: cfg,
61+ ctx: ctx,
62+ shutdownFn: shutdownFn,
63+ }
64+}
65+
66+// GetWorkspace retrieves a workspace by ID.
67+func (b *Backend) GetWorkspace(id string) (*Workspace, error) {
68+ ws, ok := b.workspaces.Get(id)
69+ if !ok {
70+ return nil, ErrWorkspaceNotFound
71+ }
72+ return ws, nil
73+}
74+
75+// ListWorkspaces returns all running workspaces.
76+func (b *Backend) ListWorkspaces() []proto.Workspace {
77+ workspaces := []proto.Workspace{}
78+ for _, ws := range b.workspaces.Seq2() {
79+ workspaces = append(workspaces, workspaceToProto(ws))
80+ }
81+ return workspaces
82+}
83+
84+// CreateWorkspace initializes a new workspace from the given
85+// parameters. It creates the config, database connection, and
86+// [app.App] instance.
87+func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Workspace, error) {
88+ if args.Path == "" {
89+ return nil, proto.Workspace{}, ErrPathRequired
90+ }
91+
92+ id := uuid.New().String()
93+ cfg, err := config.Init(args.Path, args.DataDir, args.Debug)
94+ if err != nil {
95+ return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err)
96+ }
97+
98+ if cfg.Permissions == nil {
99+ cfg.Permissions = &config.Permissions{}
100+ }
101+ cfg.Permissions.SkipRequests = args.YOLO
102+
103+ if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
104+ return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err)
105+ }
106+
107+ conn, err := db.Connect(b.ctx, cfg.Options.DataDirectory)
108+ if err != nil {
109+ return nil, proto.Workspace{}, fmt.Errorf("failed to connect to database: %w", err)
110+ }
111+
112+ appWorkspace, err := app.New(b.ctx, conn, cfg)
113+ if err != nil {
114+ return nil, proto.Workspace{}, fmt.Errorf("failed to create app workspace: %w", err)
115+ }
116+
117+ ws := &Workspace{
118+ App: appWorkspace,
119+ ID: id,
120+ Path: args.Path,
121+ Cfg: cfg,
122+ Env: args.Env,
123+ }
124+
125+ b.workspaces.Set(id, ws)
126+
127+ if args.Version != "" && args.Version != version.Version {
128+ slog.Warn("Client/server version mismatch",
129+ "client", args.Version,
130+ "server", version.Version,
131+ )
132+ appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf(
133+ "Server version %q differs from client version %q. Consider restarting the server.",
134+ version.Version, args.Version,
135+ )))
136+ }
137+
138+ result := proto.Workspace{
139+ ID: id,
140+ Path: args.Path,
141+ DataDir: cfg.Options.DataDirectory,
142+ Debug: cfg.Options.Debug,
143+ YOLO: cfg.Permissions.SkipRequests,
144+ Config: cfg,
145+ Env: args.Env,
146+ }
147+
148+ return ws, result, nil
149+}
150+
151+// DeleteWorkspace shuts down and removes a workspace. If it was the
152+// last workspace, the shutdown callback is invoked.
153+func (b *Backend) DeleteWorkspace(id string) {
154+ ws, ok := b.workspaces.Get(id)
155+ if ok {
156+ ws.Shutdown()
157+ }
158+ b.workspaces.Del(id)
159+
160+ if b.workspaces.Len() == 0 && b.shutdownFn != nil {
161+ slog.Info("Last workspace removed, shutting down server...")
162+ b.shutdownFn()
163+ }
164+}
165+
166+// GetWorkspaceProto returns the proto representation of a workspace.
167+func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
168+ ws, err := b.GetWorkspace(id)
169+ if err != nil {
170+ return proto.Workspace{}, err
171+ }
172+ return workspaceToProto(ws), nil
173+}
174+
175+// VersionInfo returns server version information.
176+func (b *Backend) VersionInfo() proto.VersionInfo {
177+ return proto.VersionInfo{
178+ Version: version.Version,
179+ Commit: version.Commit,
180+ GoVersion: runtime.Version(),
181+ Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
182+ }
183+}
184+
185+// Config returns the server-level configuration.
186+func (b *Backend) Config() *config.Config {
187+ return b.cfg
188+}
189+
190+// Shutdown initiates a graceful server shutdown.
191+func (b *Backend) Shutdown() {
192+ if b.shutdownFn != nil {
193+ b.shutdownFn()
194+ }
195+}
196+
197+func workspaceToProto(ws *Workspace) proto.Workspace {
198+ return proto.Workspace{
199+ ID: ws.ID,
200+ Path: ws.Path,
201+ YOLO: ws.Cfg.Permissions != nil && ws.Cfg.Permissions.SkipRequests,
202+ DataDir: ws.Cfg.Options.DataDirectory,
203+ Debug: ws.Cfg.Options.Debug,
204+ Config: ws.Cfg,
205+ }
206+}
internal/backend/events.gonew file+67
@@ -0,0 +1,67 @@
1+package backend
2+
3+import (
4+ tea "charm.land/bubbletea/v2"
5+
6+ "github.com/charmbracelet/crush/internal/app"
7+ "github.com/charmbracelet/crush/internal/config"
8+)
9+
10+// SubscribeEvents returns the event channel for a workspace's app.
11+func (b *Backend) SubscribeEvents(workspaceID string) (<-chan tea.Msg, error) {
12+ ws, err := b.GetWorkspace(workspaceID)
13+ if err != nil {
14+ return nil, err
15+ }
16+
17+ return ws.Events(), nil
18+}
19+
20+// GetLSPStates returns the state of all LSP clients.
21+func (b *Backend) GetLSPStates(workspaceID string) (map[string]app.LSPClientInfo, error) {
22+ _, err := b.GetWorkspace(workspaceID)
23+ if err != nil {
24+ return nil, err
25+ }
26+
27+ return app.GetLSPStates(), nil
28+}
29+
30+// GetLSPDiagnostics returns diagnostics for a specific LSP client in
31+// the workspace.
32+func (b *Backend) GetLSPDiagnostics(workspaceID, lspName string) (any, error) {
33+ ws, err := b.GetWorkspace(workspaceID)
34+ if err != nil {
35+ return nil, err
36+ }
37+
38+ for name, client := range ws.LSPManager.Clients().Seq2() {
39+ if name == lspName {
40+ return client.GetDiagnostics(), nil
41+ }
42+ }
43+
44+ return nil, ErrLSPClientNotFound
45+}
46+
47+// GetWorkspaceConfig returns the workspace-level configuration.
48+func (b *Backend) GetWorkspaceConfig(workspaceID string) (*config.Config, error) {
49+ ws, err := b.GetWorkspace(workspaceID)
50+ if err != nil {
51+ return nil, err
52+ }
53+
54+ return ws.Cfg, nil
55+}
56+
57+// GetWorkspaceProviders returns the configured providers for a
58+// workspace.
59+func (b *Backend) GetWorkspaceProviders(workspaceID string) (any, error) {
60+ ws, err := b.GetWorkspace(workspaceID)
61+ if err != nil {
62+ return nil, err
63+ }
64+
65+ providers, _ := config.Providers(ws.Cfg)
66+ return providers, nil
67+}
internal/backend/permission.gonew file+59
@@ -0,0 +1,59 @@
1+package backend
2+
3+import (
4+ "github.com/charmbracelet/crush/internal/permission"
5+ "github.com/charmbracelet/crush/internal/proto"
6+)
7+
8+// GrantPermission grants, denies, or persistently grants a permission
9+// request.
10+func (b *Backend) GrantPermission(workspaceID string, req proto.PermissionGrant) error {
11+ ws, err := b.GetWorkspace(workspaceID)
12+ if err != nil {
13+ return err
14+ }
15+
16+ perm := permission.PermissionRequest{
17+ ID: req.Permission.ID,
18+ SessionID: req.Permission.SessionID,
19+ ToolCallID: req.Permission.ToolCallID,
20+ ToolName: req.Permission.ToolName,
21+ Description: req.Permission.Description,
22+ Action: req.Permission.Action,
23+ Params: req.Permission.Params,
24+ Path: req.Permission.Path,
25+ }
26+
27+ switch req.Action {
28+ case proto.PermissionAllow:
29+ ws.Permissions.Grant(perm)
30+ case proto.PermissionAllowForSession:
31+ ws.Permissions.GrantPersistent(perm)
32+ case proto.PermissionDeny:
33+ ws.Permissions.Deny(perm)
34+ default:
35+ return ErrInvalidPermissionAction
36+ }
37+ return nil
38+}
39+
40+// SetPermissionsSkip sets whether permission prompts are skipped.
41+func (b *Backend) SetPermissionsSkip(workspaceID string, skip bool) error {
42+ ws, err := b.GetWorkspace(workspaceID)
43+ if err != nil {
44+ return err
45+ }
46+
47+ ws.Permissions.SetSkipRequests(skip)
48+ return nil
49+}
50+
51+// GetPermissionsSkip returns whether permission prompts are skipped.
52+func (b *Backend) GetPermissionsSkip(workspaceID string) (bool, error) {
53+ ws, err := b.GetWorkspace(workspaceID)
54+ if err != nil {
55+ return false, err
56+ }
57+
58+ return ws.Permissions.SkipRequests(), nil
59+}
internal/backend/session.gonew file+85
@@ -0,0 +1,85 @@
1+package backend
2+
3+import (
4+ "context"
5+
6+ "github.com/charmbracelet/crush/internal/proto"
7+ "github.com/charmbracelet/crush/internal/session"
8+)
9+
10+// CreateSession creates a new session in the given workspace.
11+func (b *Backend) CreateSession(ctx context.Context, workspaceID, title string) (session.Session, error) {
12+ ws, err := b.GetWorkspace(workspaceID)
13+ if err != nil {
14+ return session.Session{}, err
15+ }
16+
17+ return ws.Sessions.Create(ctx, title)
18+}
19+
20+// GetSession retrieves a session by workspace and session ID.
21+func (b *Backend) GetSession(ctx context.Context, workspaceID, sessionID string) (session.Session, error) {
22+ ws, err := b.GetWorkspace(workspaceID)
23+ if err != nil {
24+ return session.Session{}, err
25+ }
26+
27+ return ws.Sessions.Get(ctx, sessionID)
28+}
29+
30+// ListSessions returns all sessions in the given workspace.
31+func (b *Backend) ListSessions(ctx context.Context, workspaceID string) ([]session.Session, error) {
32+ ws, err := b.GetWorkspace(workspaceID)
33+ if err != nil {
34+ return nil, err
35+ }
36+
37+ return ws.Sessions.List(ctx)
38+}
39+
40+// GetAgentSession returns session metadata with the agent's busy
41+// status.
42+func (b *Backend) GetAgentSession(ctx context.Context, workspaceID, sessionID string) (proto.AgentSession, error) {
43+ ws, err := b.GetWorkspace(workspaceID)
44+ if err != nil {
45+ return proto.AgentSession{}, err
46+ }
47+
48+ se, err := ws.Sessions.Get(ctx, sessionID)
49+ if err != nil {
50+ return proto.AgentSession{}, err
51+ }
52+
53+ var isSessionBusy bool
54+ if ws.AgentCoordinator != nil {
55+ isSessionBusy = ws.AgentCoordinator.IsSessionBusy(sessionID)
56+ }
57+
58+ return proto.AgentSession{
59+ Session: proto.Session{
60+ ID: se.ID,
61+ Title: se.Title,
62+ },
63+ IsBusy: isSessionBusy,
64+ }, nil
65+}
66+
67+// ListSessionMessages returns all messages for a session.
68+func (b *Backend) ListSessionMessages(ctx context.Context, workspaceID, sessionID string) (any, error) {
69+ ws, err := b.GetWorkspace(workspaceID)
70+ if err != nil {
71+ return nil, err
72+ }
73+
74+ return ws.Messages.List(ctx, sessionID)
75+}
76+
77+// ListSessionHistory returns the history items for a session.
78+func (b *Backend) ListSessionHistory(ctx context.Context, workspaceID, sessionID string) (any, error) {
79+ ws, err := b.GetWorkspace(workspaceID)
80+ if err != nil {
81+ return nil, err
82+ }
83+
84+ return ws.History.ListBySession(ctx, sessionID)
85+}
internal/backend/util.gonew file+22
@@ -0,0 +1,22 @@
1+package backend
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+)
8+
9+func createDotCrushDir(dir string) error {
10+ if err := os.MkdirAll(dir, 0o700); err != nil {
11+ return fmt.Errorf("failed to create data directory: %q %w", dir, err)
12+ }
13+
14+ gitIgnorePath := filepath.Join(dir, ".gitignore")
15+ if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
16+ if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
17+ return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
18+ }
19+ }
20+
21+ return nil
22+}
internal/server/proto.go+187−477
@@ -1,28 +1,19 @@
11package server
22
33import (
4− "context"
54 "encoding/json"
5+ "errors"
66 "fmt"
7− "log/slog"
87 "net/http"
9− "os"
10− "path/filepath"
11− "runtime"
12−
13− "github.com/charmbracelet/crush/internal/app"
14− "github.com/charmbracelet/crush/internal/config"
15− "github.com/charmbracelet/crush/internal/db"
16− "github.com/charmbracelet/crush/internal/permission"
8+
9+ "github.com/charmbracelet/crush/internal/backend"
1710 "github.com/charmbracelet/crush/internal/proto"
1811 "github.com/charmbracelet/crush/internal/session"
19− "github.com/charmbracelet/crush/internal/ui/util"
20− "github.com/charmbracelet/crush/internal/version"
21− "github.com/google/uuid"
2212)
2313
2414type controllerV1 struct {
25− *Server
15+ backend *backend.Backend
16+ server *Server
2617}
2718
2819func (c *controllerV1) handleGetHealth(w http.ResponseWriter, _ *http.Request) {
@@ -30,641 +21,360 @@ func (c *controllerV1) handleGetHealth(w http.ResponseWriter, _ *http.Request) {
3021}
3122
3223func (c *controllerV1) handleGetVersion(w http.ResponseWriter, _ *http.Request) {
33− jsonEncode(w, proto.VersionInfo{
34− Version: version.Version,
35− Commit: version.Commit,
36− GoVersion: runtime.Version(),
37− Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
38− })
24+ jsonEncode(w, c.backend.VersionInfo())
3925}
4026
4127func (c *controllerV1) handlePostControl(w http.ResponseWriter, r *http.Request) {
4228 var req proto.ServerControl
4329 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
44− c.logError(r, "Failed to decode request", "error", err)
30+ c.server.logError(r, "Failed to decode request", "error", err)
4531 jsonError(w, http.StatusBadRequest, "failed to decode request")
4632 return
4733 }
4834
4935 switch req.Command {
5036 case "shutdown":
51− go func() {
52− slog.Info("Shutting down server...")
53− if err := c.Shutdown(context.Background()); err != nil {
54− slog.Error("Failed to shutdown server", "error", err)
55− }
56− }()
37+ c.backend.Shutdown()
5738 default:
58− c.logError(r, "Unknown command", "command", req.Command)
39+ c.server.logError(r, "Unknown command", "command", req.Command)
5940 jsonError(w, http.StatusBadRequest, "unknown command")
6041 return
6142 }
6243}
6344
6445func (c *controllerV1) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
65− jsonEncode(w, c.cfg)
46+ jsonEncode(w, c.backend.Config())
6647}
6748
6849func (c *controllerV1) handleGetWorkspaces(w http.ResponseWriter, _ *http.Request) {
69− workspaces := []proto.Workspace{}
70− for _, ws := range c.workspaces.Seq2() {
71− workspaces = append(workspaces, proto.Workspace{
72− ID: ws.id,
73− Path: ws.path,
74− YOLO: ws.cfg.Permissions != nil && ws.cfg.Permissions.SkipRequests,
75− DataDir: ws.cfg.Options.DataDirectory,
76− Debug: ws.cfg.Options.Debug,
77− Config: ws.cfg,
78− })
79− }
80− jsonEncode(w, workspaces)
50+ jsonEncode(w, c.backend.ListWorkspaces())
8151}
8252
83−func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) {
53+func (c *controllerV1) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
8454 id := r.PathValue("id")
85− ws, ok := c.workspaces.Get(id)
86− if !ok {
87− c.logError(r, "Workspace not found", "id", id)
88− jsonError(w, http.StatusNotFound, "workspace not found")
55+ ws, err := c.backend.GetWorkspaceProto(id)
56+ if err != nil {
57+ c.handleError(w, r, err)
8958 return
9059 }
60+ jsonEncode(w, ws)
61+}
9162
92− lspName := r.PathValue("lsp")
93− var found bool
94− for name, client := range ws.LSPManager.Clients().Seq2() {
95− if name == lspName {
96− diagnostics := client.GetDiagnostics()
97− jsonEncode(w, diagnostics)
98− found = true
99− break
100− }
63+func (c *controllerV1) handlePostWorkspaces(w http.ResponseWriter, r *http.Request) {
64+ var args proto.Workspace
65+ if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
66+ c.server.logError(r, "Failed to decode request", "error", err)
67+ jsonError(w, http.StatusBadRequest, "failed to decode request")
68+ return
10169 }
10270
103− if !found {
104− c.logError(r, "LSP client not found", "id", id, "lsp", lspName)
105− jsonError(w, http.StatusNotFound, "LSP client not found")
71+ _, result, err := c.backend.CreateWorkspace(args)
72+ if err != nil {
73+ c.handleError(w, r, err)
74+ return
10675 }
76+ jsonEncode(w, result)
10777}
10878
109−func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Request) {
79+func (c *controllerV1) handleDeleteWorkspaces(w http.ResponseWriter, r *http.Request) {
11080 id := r.PathValue("id")
111− _, ok := c.workspaces.Get(id)
112− if !ok {
113− c.logError(r, "Workspace not found", "id", id)
114− jsonError(w, http.StatusNotFound, "workspace not found")
115− return
116− }
117−
118− lspClients := app.GetLSPStates()
119− jsonEncode(w, lspClients)
81+ c.backend.DeleteWorkspace(id)
12082}
12183
122−func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) {
84+func (c *controllerV1) handleGetWorkspaceConfig(w http.ResponseWriter, r *http.Request) {
12385 id := r.PathValue("id")
124− ws, ok := c.workspaces.Get(id)
125− if !ok {
126− c.logError(r, "Workspace not found", "id", id)
127− jsonError(w, http.StatusNotFound, "workspace not found")
86+ cfg, err := c.backend.GetWorkspaceConfig(id)
87+ if err != nil {
88+ c.handleError(w, r, err)
12889 return
12990 }
130−
131− sid := r.PathValue("sid")
132− queued := ws.App.AgentCoordinator.QueuedPrompts(sid)
133− jsonEncode(w, queued)
91+ jsonEncode(w, cfg)
13492}
13593
136−func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) {
94+func (c *controllerV1) handleGetWorkspaceProviders(w http.ResponseWriter, r *http.Request) {
13795 id := r.PathValue("id")
138− ws, ok := c.workspaces.Get(id)
139− if !ok {
140− c.logError(r, "Workspace not found", "id", id)
141− jsonError(w, http.StatusNotFound, "workspace not found")
96+ providers, err := c.backend.GetWorkspaceProviders(id)
97+ if err != nil {
98+ c.handleError(w, r, err)
14299 return
143100 }
144−
145− sid := r.PathValue("sid")
146− ws.App.AgentCoordinator.ClearQueue(sid)
147− w.WriteHeader(http.StatusOK)
101+ jsonEncode(w, providers)
148102}
149103
150−func (c *controllerV1) handleGetWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
104+func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.Request) {
105+ flusher := http.NewResponseController(w)
151106 id := r.PathValue("id")
152− ws, ok := c.workspaces.Get(id)
153− if !ok {
154− c.logError(r, "Workspace not found", "id", id)
155− jsonError(w, http.StatusNotFound, "workspace not found")
107+ events, err := c.backend.SubscribeEvents(id)
108+ if err != nil {
109+ c.handleError(w, r, err)
156110 return
157111 }
158112
159− sid := r.PathValue("sid")
160− if err := ws.App.AgentCoordinator.Summarize(r.Context(), sid); err != nil {
161− c.logError(r, "Failed to summarize session", "error", err, "id", id, "sid", sid)
162− jsonError(w, http.StatusInternalServerError, "failed to summarize session")
163− return
164− }
165−}
113+ w.Header().Set("Content-Type", "text/event-stream")
114+ w.Header().Set("Cache-Control", "no-cache")
115+ w.Header().Set("Connection", "keep-alive")
166116
167−func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) {
168− id := r.PathValue("id")
169− ws, ok := c.workspaces.Get(id)
170− if !ok {
171− c.logError(r, "Workspace not found", "id", id)
172− jsonError(w, http.StatusNotFound, "workspace not found")
173− return
174− }
117+ for {
118+ select {
119+ case <-r.Context().Done():
120+ c.server.logDebug(r, "Stopping event stream")
121+ return
122+ case ev, ok := <-events:
123+ if !ok {
124+ return
125+ }
126+ c.server.logDebug(r, "Sending event", "event", fmt.Sprintf("%T %+v", ev, ev))
127+ data, err := json.Marshal(ev)
128+ if err != nil {
129+ c.server.logError(r, "Failed to marshal event", "error", err)
130+ continue
131+ }
175132
176− sid := r.PathValue("sid")
177− if ws.App.AgentCoordinator != nil {
178− ws.App.AgentCoordinator.Cancel(sid)
133+ fmt.Fprintf(w, "data: %s\n\n", data)
134+ flusher.Flush()
135+ }
179136 }
180− w.WriteHeader(http.StatusOK)
181137}
182138
183−func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) {
139+func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Request) {
184140 id := r.PathValue("id")
185− ws, ok := c.workspaces.Get(id)
186− if !ok {
187− c.logError(r, "Workspace not found", "id", id)
188− jsonError(w, http.StatusNotFound, "workspace not found")
189− return
190− }
191−
192− sid := r.PathValue("sid")
193− se, err := ws.App.Sessions.Get(r.Context(), sid)
141+ states, err := c.backend.GetLSPStates(id)
194142 if err != nil {
195− c.logError(r, "Failed to get session", "error", err, "id", id, "sid", sid)
196− jsonError(w, http.StatusInternalServerError, "failed to get session")
143+ c.handleError(w, r, err)
197144 return
198145 }
199−
200− var isSessionBusy bool
201− if ws.App.AgentCoordinator != nil {
202− isSessionBusy = ws.App.AgentCoordinator.IsSessionBusy(sid)
203− }
204−
205− jsonEncode(w, proto.AgentSession{
206− Session: proto.Session{
207− ID: se.ID,
208− Title: se.Title,
209− },
210− IsBusy: isSessionBusy,
211− })
146+ jsonEncode(w, states)
212147}
213148
214−func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
149+func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) {
215150 id := r.PathValue("id")
216− ws, ok := c.workspaces.Get(id)
217− if !ok {
218− c.logError(r, "Workspace not found", "id", id)
219− jsonError(w, http.StatusNotFound, "workspace not found")
220− return
221− }
222−
223− w.Header().Set("Accept", "application/json")
224−
225− var msg proto.AgentMessage
226− if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
227− c.logError(r, "Failed to decode request", "error", err)
228− jsonError(w, http.StatusBadRequest, "failed to decode request")
229− return
230− }
231−
232− if ws.App.AgentCoordinator == nil {
233− c.logError(r, "Agent coordinator not initialized", "id", id)
234− jsonError(w, http.StatusBadRequest, "agent coordinator not initialized")
235− return
236− }
237−
238− if _, err := ws.App.AgentCoordinator.Run(c.ctx, msg.SessionID, msg.Prompt); err != nil {
239− c.logError(r, "Failed to enqueue message", "error", err, "id", id, "sid", msg.SessionID)
240− jsonError(w, http.StatusInternalServerError, "failed to enqueue message")
151+ lspName := r.PathValue("lsp")
152+ diagnostics, err := c.backend.GetLSPDiagnostics(id, lspName)
153+ if err != nil {
154+ c.handleError(w, r, err)
241155 return
242156 }
157+ jsonEncode(w, diagnostics)
243158}
244159
245−func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
160+func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
246161 id := r.PathValue("id")
247− ws, ok := c.workspaces.Get(id)
248− if !ok {
249− c.logError(r, "Workspace not found", "id", id)
250− jsonError(w, http.StatusNotFound, "workspace not found")
162+ sessions, err := c.backend.ListSessions(r.Context(), id)
163+ if err != nil {
164+ c.handleError(w, r, err)
251165 return
252166 }
253−
254− var agentInfo proto.AgentInfo
255− if ws.App.AgentCoordinator != nil {
256− m := ws.App.AgentCoordinator.Model()
257− agentInfo = proto.AgentInfo{
258− Model: m.CatwalkCfg,
259− IsBusy: ws.App.AgentCoordinator.IsBusy(),
260− }
261− }
262− jsonEncode(w, agentInfo)
167+ jsonEncode(w, sessions)
263168}
264169
265−func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) {
170+func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
266171 id := r.PathValue("id")
267− ws, ok := c.workspaces.Get(id)
268− if !ok {
269− c.logError(r, "Workspace not found", "id", id)
270− jsonError(w, http.StatusNotFound, "workspace not found")
172+
173+ var args session.Session
174+ if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
175+ c.server.logError(r, "Failed to decode request", "error", err)
176+ jsonError(w, http.StatusBadRequest, "failed to decode request")
271177 return
272178 }
273179
274− if err := ws.App.UpdateAgentModel(r.Context()); err != nil {
275− c.logError(r, "Failed to update agent model", "error", err)
276− jsonError(w, http.StatusInternalServerError, "failed to update agent model")
180+ sess, err := c.backend.CreateSession(r.Context(), id, args.Title)
181+ if err != nil {
182+ c.handleError(w, r, err)
277183 return
278184 }
185+ jsonEncode(w, sess)
279186}
280187
281−func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
188+func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.Request) {
282189 id := r.PathValue("id")
283− ws, ok := c.workspaces.Get(id)
284− if !ok {
285− c.logError(r, "Workspace not found", "id", id)
286− jsonError(w, http.StatusNotFound, "workspace not found")
287− return
288− }
289−
290− if err := ws.App.InitCoderAgent(r.Context()); err != nil {
291− c.logError(r, "Failed to initialize coder agent", "error", err)
292− jsonError(w, http.StatusInternalServerError, "failed to initialize coder agent")
190+ sid := r.PathValue("sid")
191+ sess, err := c.backend.GetSession(r.Context(), id, sid)
192+ if err != nil {
193+ c.handleError(w, r, err)
293194 return
294195 }
196+ jsonEncode(w, sess)
295197}
296198
297199func (c *controllerV1) handleGetWorkspaceSessionHistory(w http.ResponseWriter, r *http.Request) {
298200 id := r.PathValue("id")
299− ws, ok := c.workspaces.Get(id)
300− if !ok {
301− c.logError(r, "Workspace not found", "id", id)
302− jsonError(w, http.StatusNotFound, "workspace not found")
303− return
304− }
305−
306201 sid := r.PathValue("sid")
307− historyItems, err := ws.App.History.ListBySession(r.Context(), sid)
202+ history, err := c.backend.ListSessionHistory(r.Context(), id, sid)
308203 if err != nil {
309− c.logError(r, "Failed to list history", "error", err, "id", id, "sid", sid)
310− jsonError(w, http.StatusInternalServerError, "failed to list history")
204+ c.handleError(w, r, err)
311205 return
312206 }
313−
314− jsonEncode(w, historyItems)
207+ jsonEncode(w, history)
315208}
316209
317210func (c *controllerV1) handleGetWorkspaceSessionMessages(w http.ResponseWriter, r *http.Request) {
318211 id := r.PathValue("id")
319− ws, ok := c.workspaces.Get(id)
320− if !ok {
321− c.logError(r, "Workspace not found", "id", id)
322− jsonError(w, http.StatusNotFound, "workspace not found")
323− return
324− }
325−
326212 sid := r.PathValue("sid")
327− messages, err := ws.App.Messages.List(r.Context(), sid)
213+ messages, err := c.backend.ListSessionMessages(r.Context(), id, sid)
328214 if err != nil {
329− c.logError(r, "Failed to list messages", "error", err, "id", id, "sid", sid)
330− jsonError(w, http.StatusInternalServerError, "failed to list messages")
215+ c.handleError(w, r, err)
331216 return
332217 }
333−
334218 jsonEncode(w, messages)
335219}
336220
337−func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.Request) {
221+func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
338222 id := r.PathValue("id")
339− ws, ok := c.workspaces.Get(id)
340− if !ok {
341− c.logError(r, "Workspace not found", "id", id)
342− jsonError(w, http.StatusNotFound, "workspace not found")
343− return
344− }
345−
346− sid := r.PathValue("sid")
347− sess, err := ws.App.Sessions.Get(r.Context(), sid)
223+ info, err := c.backend.GetAgentInfo(id)
348224 if err != nil {
349− c.logError(r, "Failed to get session", "error", err, "id", id, "sid", sid)
350− jsonError(w, http.StatusInternalServerError, "failed to get session")
225+ c.handleError(w, r, err)
351226 return
352227 }
353−
354− jsonEncode(w, sess)
228+ jsonEncode(w, info)
355229}
356230
357−func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
231+func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
358232 id := r.PathValue("id")
359− ws, ok := c.workspaces.Get(id)
360− if !ok {
361− c.logError(r, "Workspace not found", "id", id)
362− jsonError(w, http.StatusNotFound, "workspace not found")
363− return
364− }
365233
366− var args session.Session
367− if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
368− c.logError(r, "Failed to decode request", "error", err)
234+ w.Header().Set("Accept", "application/json")
235+
236+ var msg proto.AgentMessage
237+ if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
238+ c.server.logError(r, "Failed to decode request", "error", err)
369239 jsonError(w, http.StatusBadRequest, "failed to decode request")
370240 return
371241 }
372242
373− sess, err := ws.App.Sessions.Create(r.Context(), args.Title)
374− if err != nil {
375− c.logError(r, "Failed to create session", "error", err, "id", id)
376− jsonError(w, http.StatusInternalServerError, "failed to create session")
243+ if err := c.backend.SendMessage(r.Context(), id, msg); err != nil {
244+ c.handleError(w, r, err)
377245 return
378246 }
379−
380− jsonEncode(w, sess)
381247}
382248
383−func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
249+func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
384250 id := r.PathValue("id")
385− ws, ok := c.workspaces.Get(id)
386− if !ok {
387− c.logError(r, "Workspace not found", "id", id)
388− jsonError(w, http.StatusNotFound, "workspace not found")
251+ if err := c.backend.InitAgent(r.Context(), id); err != nil {
252+ c.handleError(w, r, err)
389253 return
390254 }
391−
392− sessions, err := ws.App.Sessions.List(r.Context())
393− if err != nil {
394− c.logError(r, "Failed to list sessions", "error", err)
395− jsonError(w, http.StatusInternalServerError, "failed to list sessions")
396− return
397− }
398−
399− jsonEncode(w, sessions)
400255}
401256
402−func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) {
257+func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) {
403258 id := r.PathValue("id")
404− ws, ok := c.workspaces.Get(id)
405− if !ok {
406− c.logError(r, "Workspace not found", "id", id)
407− jsonError(w, http.StatusNotFound, "workspace not found")
408− return
409− }
410−
411− var req proto.PermissionGrant
412− if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
413− c.logError(r, "Failed to decode request", "error", err)
414− jsonError(w, http.StatusBadRequest, "failed to decode request")
415− return
416− }
417−
418− perm := permission.PermissionRequest{
419− ID: req.Permission.ID,
420− SessionID: req.Permission.SessionID,
421− ToolCallID: req.Permission.ToolCallID,
422− ToolName: req.Permission.ToolName,
423− Description: req.Permission.Description,
424− Action: req.Permission.Action,
425− Params: req.Permission.Params,
426− Path: req.Permission.Path,
427− }
428−
429− switch req.Action {
430− case proto.PermissionAllow:
431− ws.App.Permissions.Grant(perm)
432− case proto.PermissionAllowForSession:
433− ws.App.Permissions.GrantPersistent(perm)
434− case proto.PermissionDeny:
435− ws.App.Permissions.Deny(perm)
436− default:
437− c.logError(r, "Invalid permission action", "action", req.Action)
438− jsonError(w, http.StatusBadRequest, "invalid permission action")
259+ if err := c.backend.UpdateAgent(r.Context(), id); err != nil {
260+ c.handleError(w, r, err)
439261 return
440262 }
441263}
442264
443−func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
265+func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) {
444266 id := r.PathValue("id")
445− ws, ok := c.workspaces.Get(id)
446− if !ok {
447− c.logError(r, "Workspace not found", "id", id)
448− jsonError(w, http.StatusNotFound, "workspace not found")
449− return
450− }
451−
452− var req proto.PermissionSkipRequest
453− if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
454− c.logError(r, "Failed to decode request", "error", err)
455− jsonError(w, http.StatusBadRequest, "failed to decode request")
267+ sid := r.PathValue("sid")
268+ agentSession, err := c.backend.GetAgentSession(r.Context(), id, sid)
269+ if err != nil {
270+ c.handleError(w, r, err)
456271 return
457272 }
458−
459− ws.App.Permissions.SetSkipRequests(req.Skip)
273+ jsonEncode(w, agentSession)
460274}
461275
462−func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
276+func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) {
463277 id := r.PathValue("id")
464− ws, ok := c.workspaces.Get(id)
465− if !ok {
466− c.logError(r, "Workspace not found", "id", id)
467− jsonError(w, http.StatusNotFound, "workspace not found")
278+ sid := r.PathValue("sid")
279+ if err := c.backend.CancelSession(id, sid); err != nil {
280+ c.handleError(w, r, err)
468281 return
469282 }
470−
471− skip := ws.App.Permissions.SkipRequests()
472− jsonEncode(w, proto.PermissionSkipRequest{Skip: skip})
283+ w.WriteHeader(http.StatusOK)
473284}
474285
475−func (c *controllerV1) handleGetWorkspaceProviders(w http.ResponseWriter, r *http.Request) {
286+func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) {
476287 id := r.PathValue("id")
477− ws, ok := c.workspaces.Get(id)
478− if !ok {
479− c.logError(r, "Workspace not found", "id", id)
480− jsonError(w, http.StatusNotFound, "workspace not found")
288+ sid := r.PathValue("sid")
289+ queued, err := c.backend.QueuedPrompts(id, sid)
290+ if err != nil {
291+ c.handleError(w, r, err)
481292 return
482293 }
483−
484− providers, _ := config.Providers(ws.cfg)
485− jsonEncode(w, providers)
294+ jsonEncode(w, queued)
486295}
487296
488−func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.Request) {
489− flusher := http.NewResponseController(w)
297+func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) {
490298 id := r.PathValue("id")
491− ws, ok := c.workspaces.Get(id)
492− if !ok {
493− c.logError(r, "Workspace not found", "id", id)
494− jsonError(w, http.StatusNotFound, "workspace not found")
299+ sid := r.PathValue("sid")
300+ if err := c.backend.ClearQueue(id, sid); err != nil {
301+ c.handleError(w, r, err)
495302 return
496303 }
497−
498− w.Header().Set("Content-Type", "text/event-stream")
499− w.Header().Set("Cache-Control", "no-cache")
500− w.Header().Set("Connection", "keep-alive")
501−
502− events := ws.App.Events()
503−
504− for {
505− select {
506− case <-r.Context().Done():
507− c.logDebug(r, "Stopping event stream")
508− return
509− case ev, ok := <-events:
510− if !ok {
511− return
512− }
513− c.logDebug(r, "Sending event", "event", fmt.Sprintf("%T %+v", ev, ev))
514− data, err := json.Marshal(ev)
515− if err != nil {
516− c.logError(r, "Failed to marshal event", "error", err)
517− continue
518− }
519−
520− fmt.Fprintf(w, "data: %s\n\n", data)
521− flusher.Flush()
522− }
523− }
304+ w.WriteHeader(http.StatusOK)
524305}
525306
526−func (c *controllerV1) handleGetWorkspaceConfig(w http.ResponseWriter, r *http.Request) {
307+func (c *controllerV1) handleGetWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
527308 id := r.PathValue("id")
528− ws, ok := c.workspaces.Get(id)
529− if !ok {
530− c.logError(r, "Workspace not found", "id", id)
531− jsonError(w, http.StatusNotFound, "workspace not found")
309+ sid := r.PathValue("sid")
310+ if err := c.backend.SummarizeSession(r.Context(), id, sid); err != nil {
311+ c.handleError(w, r, err)
532312 return
533313 }
534−
535− jsonEncode(w, ws.cfg)
536−}
537−
538−func (c *controllerV1) handleDeleteWorkspaces(w http.ResponseWriter, r *http.Request) {
539− id := r.PathValue("id")
540− ws, ok := c.workspaces.Get(id)
541− if ok {
542− ws.App.Shutdown()
543− }
544− c.workspaces.Del(id)
545−
546− // When the last workspace is removed, shut down the server.
547− if c.workspaces.Len() == 0 {
548− slog.Info("Last workspace removed, shutting down server...")
549− go func() {
550− if err := c.Shutdown(context.Background()); err != nil {
551− slog.Error("Failed to shutdown server", "error", err)
552− }
553− }()
554− }
555314}
556315
557−func (c *controllerV1) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
316+func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) {
558317 id := r.PathValue("id")
559− ws, ok := c.workspaces.Get(id)
560− if !ok {
561− c.logError(r, "Workspace not found", "id", id)
562− jsonError(w, http.StatusNotFound, "workspace not found")
563− return
564− }
565−
566− jsonEncode(w, proto.Workspace{
567− ID: ws.id,
568− Path: ws.path,
569− YOLO: ws.cfg.Permissions != nil && ws.cfg.Permissions.SkipRequests,
570− DataDir: ws.cfg.Options.DataDirectory,
571− Debug: ws.cfg.Options.Debug,
572− Config: ws.cfg,
573− })
574−}
575318
576−func (c *controllerV1) handlePostWorkspaces(w http.ResponseWriter, r *http.Request) {
577− var args proto.Workspace
578− if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
579− c.logError(r, "Failed to decode request", "error", err)
319+ var req proto.PermissionGrant
320+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
321+ c.server.logError(r, "Failed to decode request", "error", err)
580322 jsonError(w, http.StatusBadRequest, "failed to decode request")
581323 return
582324 }
583325
584− if args.Path == "" {
585− c.logError(r, "Path is required")
586− jsonError(w, http.StatusBadRequest, "path is required")
587− return
588− }
589−
590− id := uuid.New().String()
591− cfg, err := config.Init(args.Path, args.DataDir, args.Debug)
592− if err != nil {
593− c.logError(r, "Failed to initialize config", "error", err)
594− jsonError(w, http.StatusBadRequest, fmt.Sprintf("failed to initialize config: %v", err))
326+ if err := c.backend.GrantPermission(id, req); err != nil {
327+ c.handleError(w, r, err)
595328 return
596329 }
330+}
597331
598− if cfg.Permissions == nil {
599− cfg.Permissions = &config.Permissions{}
600− }
601− cfg.Permissions.SkipRequests = args.YOLO
332+func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
333+ id := r.PathValue("id")
602334
603− if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
604− c.logError(r, "Failed to create data directory", "error", err)
605− jsonError(w, http.StatusInternalServerError, "failed to create data directory")
335+ var req proto.PermissionSkipRequest
336+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
337+ c.server.logError(r, "Failed to decode request", "error", err)
338+ jsonError(w, http.StatusBadRequest, "failed to decode request")
606339 return
607340 }
608341
609− conn, err := db.Connect(c.ctx, cfg.Options.DataDirectory)
610− if err != nil {
611− c.logError(r, "Failed to connect to database", "error", err)
612− jsonError(w, http.StatusInternalServerError, "failed to connect to database")
342+ if err := c.backend.SetPermissionsSkip(id, req.Skip); err != nil {
343+ c.handleError(w, r, err)
613344 return
614345 }
346+}
615347
616− appWorkspace, err := app.New(c.ctx, conn, cfg)
348+func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
349+ id := r.PathValue("id")
350+ skip, err := c.backend.GetPermissionsSkip(id)
617351 if err != nil {
618− slog.Error("Failed to create app workspace", "error", err)
619− jsonError(w, http.StatusInternalServerError, "failed to create app workspace")
352+ c.handleError(w, r, err)
620353 return
621354 }
622−
623− ws := &Workspace{
624− App: appWorkspace,
625− id: id,
626− path: args.Path,
627− cfg: cfg,
628− env: args.Env,
629− }
630−
631− c.workspaces.Set(id, ws)
632−
633− if args.Version != "" && args.Version != version.Version {
634− slog.Warn("Client/server version mismatch",
635− "client", args.Version,
636− "server", version.Version,
637− )
638− appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf(
639− "Server version %q differs from client version %q. Consider restarting the server.",
640− version.Version, args.Version,
641− )))
642− }
643−
644− jsonEncode(w, proto.Workspace{
645− ID: id,
646− Path: args.Path,
647− DataDir: cfg.Options.DataDirectory,
648− Debug: cfg.Options.Debug,
649− YOLO: cfg.Permissions.SkipRequests,
650− Config: cfg,
651− Env: args.Env,
652− })
355+ jsonEncode(w, proto.PermissionSkipRequest{Skip: skip})
653356}
654357
655−func createDotCrushDir(dir string) error {
656− if err := os.MkdirAll(dir, 0o700); err != nil {
657− return fmt.Errorf("failed to create data directory: %q %w", dir, err)
658− }
659−
660− gitIgnorePath := filepath.Join(dir, ".gitignore")
661− if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
662− if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
663− return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
664− }
665− }
666−
667− return nil
358+// handleError maps backend errors to HTTP status codes and writes the
359+// JSON error response.
360+func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err error) {
361+ status := http.StatusInternalServerError
362+ switch {
363+ case errors.Is(err, backend.ErrWorkspaceNotFound):
364+ status = http.StatusNotFound
365+ case errors.Is(err, backend.ErrLSPClientNotFound):
366+ status = http.StatusNotFound
367+ case errors.Is(err, backend.ErrAgentNotInitialized):
368+ status = http.StatusBadRequest
369+ case errors.Is(err, backend.ErrPathRequired):
370+ status = http.StatusBadRequest
371+ case errors.Is(err, backend.ErrInvalidPermissionAction):
372+ status = http.StatusBadRequest
373+ case errors.Is(err, backend.ErrUnknownCommand):
374+ status = http.StatusBadRequest
375+ }
376+ c.server.logError(r, err.Error())
377+ jsonError(w, status, err.Error())
668378}
669379
670380func jsonEncode(w http.ResponseWriter, v any) {
internal/server/server.go+18−24
@@ -11,25 +11,13 @@ import (
1111 "runtime"
1212 "strings"
1313
14− "github.com/charmbracelet/crush/internal/app"
14+ "github.com/charmbracelet/crush/internal/backend"
1515 "github.com/charmbracelet/crush/internal/config"
16− "github.com/charmbracelet/crush/internal/csync"
1716)
1817
1918// ErrServerClosed is returned when the server is closed.
2019var ErrServerClosed = http.ErrServerClosed
2120
22−// Workspace represents a running [app.App] workspace with its associated
23−// resources and state.
24−type Workspace struct {
25− *app.App
26− ln net.Listener
27− cfg *config.Config
28− id string
29− path string
30− env []string
31−}
32−
3321// ParseHostURL parses a host URL into a [url.URL].
3422func ParseHostURL(host string) (*url.URL, error) {
3523 proto, addr, ok := strings.Cut(host, "://")
@@ -72,14 +60,11 @@ type Server struct {
7260 Addr string
7361 network string
7462
75− h *http.Server
76− ln net.Listener
77− ctx context.Context
63+ h *http.Server
64+ ln net.Listener
7865
79− // workspaces is a map of running applications managed by the server.
80− workspaces *csync.Map[string, *Workspace]
81− cfg *config.Config
82− logger *slog.Logger
66+ backend *backend.Backend
67+ logger *slog.Logger
8368}
8469
8570// SetLogger sets the logger for the server.
@@ -101,14 +86,23 @@ func NewServer(cfg *config.Config, network, address string) *Server {
10186 s := new(Server)
10287 s.Addr = address
10388 s.network = network
104− s.cfg = cfg
105− s.workspaces = csync.NewMap[string, *Workspace]()
106− s.ctx = context.Background()
89+
90+ // The backend is created with a shutdown callback that triggers
91+ // a graceful server shutdown (e.g. when the last workspace is
92+ // removed).
93+ s.backend = backend.New(context.Background(), cfg, func() {
94+ go func() {
95+ slog.Info("Shutting down server...")
96+ if err := s.Shutdown(context.Background()); err != nil {
97+ slog.Error("Failed to shutdown server", "error", err)
98+ }
99+ }()
100+ })
107101
108102 var p http.Protocols
109103 p.SetHTTP1(true)
110104 p.SetUnencryptedHTTP2(true)
111− c := &controllerV1{Server: s}
105+ c := &controllerV1{backend: s.backend, server: s}
112106 mux := http.NewServeMux()
113107 mux.HandleFunc("GET /v1/health", c.handleGetHealth)
114108 mux.HandleFunc("GET /v1/version", c.handleGetVersion)