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 if cfg.Config().Permissions == nil {
99 cfg.Config().Permissions = &config.Permissions{}
100 }
101 cfg.Config().Permissions.SkipRequests = args.YOLO
102
103 if err := createDotCrushDir(cfg.Config().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.Config().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.Config().Options.DataDirectory,
142 Debug: cfg.Config().Options.Debug,
143 YOLO: cfg.Config().Permissions.SkipRequests,
144 Config: cfg.Config(),
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.
153func (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.
167func (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.
176func (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.
186func (b *Backend) Config() *config.ConfigStore {
187 return b.cfg
188}
189
190// Shutdown initiates a graceful server shutdown.
191func (b *Backend) Shutdown() {
192 if b.shutdownFn != nil {
193 b.shutdownFn()
194 }
195}
196
197func workspaceToProto(ws *Workspace) proto.Workspace {
198 cfg := ws.Cfg.Config()
199 return proto.Workspace{
200 ID: ws.ID,
201 Path: ws.Path,
202 YOLO: cfg.Permissions != nil && cfg.Permissions.SkipRequests,
203 DataDir: cfg.Options.DataDirectory,
204 Debug: cfg.Options.Debug,
205 Config: cfg,
206 }
207}