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.
4package backend
5
6import (
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.
24var (
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).
35type ShutdownFunc func()
36
37// Backend provides transport-agnostic business logic for the Crush
38// server. It manages workspaces and delegates to [app.App] services.
39type Backend struct {
40 workspaces *csync.Map[string, *Workspace]
41 cfg *config.ConfigStore
42 ctx context.Context
43 shutdownFn ShutdownFunc
44}
45
46// Workspace represents a running [app.App] workspace with its
47// associated resources and state.
48type Workspace struct {
49 *app.App
50 ID string
51 Path string
52 Cfg *config.ConfigStore
53 Env []string
54}
55
56// New creates a new [Backend].
57func New(ctx context.Context, cfg *config.ConfigStore, 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.
67func (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.
76func (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.
87func (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 cfg.Overrides().SkipPermissionRequests = args.YOLO
99
100 if err := createDotCrushDir(cfg.Config().Options.DataDirectory); err != nil {
101 return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err)
102 }
103
104 conn, err := db.Connect(b.ctx, cfg.Config().Options.DataDirectory)
105 if err != nil {
106 return nil, proto.Workspace{}, fmt.Errorf("failed to connect to database: %w", err)
107 }
108
109 appWorkspace, err := app.New(b.ctx, conn, cfg)
110 if err != nil {
111 return nil, proto.Workspace{}, fmt.Errorf("failed to create app workspace: %w", err)
112 }
113
114 ws := &Workspace{
115 App: appWorkspace,
116 ID: id,
117 Path: args.Path,
118 Cfg: cfg,
119 Env: args.Env,
120 }
121
122 b.workspaces.Set(id, ws)
123
124 if args.Version != "" && args.Version != version.Version {
125 slog.Warn("Client/server version mismatch",
126 "client", args.Version,
127 "server", version.Version,
128 )
129 appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf(
130 "Server version %q differs from client version %q. Consider restarting the server.",
131 version.Version, args.Version,
132 )))
133 }
134
135 result := proto.Workspace{
136 ID: id,
137 Path: args.Path,
138 DataDir: cfg.Config().Options.DataDirectory,
139 Debug: cfg.Config().Options.Debug,
140 YOLO: cfg.Overrides().SkipPermissionRequests,
141 Config: cfg.Config(),
142 Env: args.Env,
143 }
144
145 return ws, result, nil
146}
147
148// DeleteWorkspace shuts down and removes a workspace. If it was the
149// last workspace, the shutdown callback is invoked.
150func (b *Backend) DeleteWorkspace(id string) {
151 ws, ok := b.workspaces.Get(id)
152 if ok {
153 ws.Shutdown()
154 }
155 b.workspaces.Del(id)
156
157 if b.workspaces.Len() == 0 && b.shutdownFn != nil {
158 slog.Info("Last workspace removed, shutting down server...")
159 b.shutdownFn()
160 }
161}
162
163// GetWorkspaceProto returns the proto representation of a workspace.
164func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
165 ws, err := b.GetWorkspace(id)
166 if err != nil {
167 return proto.Workspace{}, err
168 }
169 return workspaceToProto(ws), nil
170}
171
172// VersionInfo returns server version information.
173func (b *Backend) VersionInfo() proto.VersionInfo {
174 return proto.VersionInfo{
175 Version: version.Version,
176 Commit: version.Commit,
177 GoVersion: runtime.Version(),
178 Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
179 }
180}
181
182// Config returns the server-level configuration.
183func (b *Backend) Config() *config.ConfigStore {
184 return b.cfg
185}
186
187// Shutdown initiates a graceful server shutdown.
188func (b *Backend) Shutdown() {
189 if b.shutdownFn != nil {
190 b.shutdownFn()
191 }
192}
193
194func workspaceToProto(ws *Workspace) proto.Workspace {
195 cfg := ws.Cfg.Config()
196 return proto.Workspace{
197 ID: ws.ID,
198 Path: ws.Path,
199 YOLO: ws.Cfg.Overrides().SkipPermissionRequests,
200 DataDir: cfg.Options.DataDirectory,
201 Debug: cfg.Options.Debug,
202 Config: cfg,
203 }
204}