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/skills"
19 "github.com/charmbracelet/crush/internal/ui/util"
20 "github.com/charmbracelet/crush/internal/version"
21 "github.com/google/uuid"
22)
23
24// Common errors returned by backend operations.
25var (
26 ErrWorkspaceNotFound = errors.New("workspace not found")
27 ErrLSPClientNotFound = errors.New("LSP client not found")
28 ErrAgentNotInitialized = errors.New("agent coordinator not initialized")
29 ErrPathRequired = errors.New("path is required")
30 ErrInvalidPermissionAction = errors.New("invalid permission action")
31 ErrUnknownCommand = errors.New("unknown command")
32)
33
34// ShutdownFunc is called when the backend needs to trigger a server
35// shutdown (e.g. when the last workspace is removed).
36type ShutdownFunc func()
37
38// Backend provides transport-agnostic business logic for the Crush
39// server. It manages workspaces and delegates to [app.App] services.
40type Backend struct {
41 workspaces *csync.Map[string, *Workspace]
42 cfg *config.ConfigStore
43 ctx context.Context
44 shutdownFn ShutdownFunc
45}
46
47// Workspace represents a running [app.App] workspace with its
48// associated resources and state.
49type Workspace struct {
50 *app.App
51 ID string
52 Path string
53 Cfg *config.ConfigStore
54 Env []string
55 Skills *skills.Manager
56}
57
58// New creates a new [Backend].
59func New(ctx context.Context, cfg *config.ConfigStore, shutdownFn ShutdownFunc) *Backend {
60 return &Backend{
61 workspaces: csync.NewMap[string, *Workspace](),
62 cfg: cfg,
63 ctx: ctx,
64 shutdownFn: shutdownFn,
65 }
66}
67
68// GetWorkspace retrieves a workspace by ID.
69func (b *Backend) GetWorkspace(id string) (*Workspace, error) {
70 ws, ok := b.workspaces.Get(id)
71 if !ok {
72 return nil, ErrWorkspaceNotFound
73 }
74 return ws, nil
75}
76
77// ListWorkspaces returns all running workspaces.
78func (b *Backend) ListWorkspaces() []proto.Workspace {
79 workspaces := []proto.Workspace{}
80 for _, ws := range b.workspaces.Seq2() {
81 workspaces = append(workspaces, workspaceToProto(ws))
82 }
83 return workspaces
84}
85
86// CreateWorkspace initializes a new workspace from the given
87// parameters. It creates the config, database connection, and
88// [app.App] instance.
89func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Workspace, error) {
90 if args.Path == "" {
91 return nil, proto.Workspace{}, ErrPathRequired
92 }
93
94 id := uuid.New().String()
95 cfg, err := config.Init(args.Path, args.DataDir, args.Debug)
96 if err != nil {
97 return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err)
98 }
99
100 cfg.Overrides().SkipPermissionRequests = args.YOLO
101
102 if err := createDotCrushDir(cfg.Config().Options.DataDirectory); err != nil {
103 return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err)
104 }
105
106 conn, err := db.Connect(b.ctx, cfg.Config().Options.DataDirectory)
107 if err != nil {
108 return nil, proto.Workspace{}, fmt.Errorf("failed to connect to database: %w", err)
109 }
110
111 // Discover skills once per workspace, before app.New. The backend
112 // hosts multiple workspaces concurrently, so the manager is
113 // constructed WITHOUT WithGlobalMirror to prevent last-writer-wins
114 // cross-talk between workspaces.
115 allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(skillsDiscoveryConfig(cfg))
116 skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates)
117
118 appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
119 if err != nil {
120 return nil, proto.Workspace{}, fmt.Errorf("failed to create app workspace: %w", err)
121 }
122
123 ws := &Workspace{
124 App: appWorkspace,
125 ID: id,
126 Path: args.Path,
127 Cfg: cfg,
128 Env: args.Env,
129 Skills: skillsMgr,
130 }
131
132 b.workspaces.Set(id, ws)
133
134 if args.Version != "" && args.Version != version.Version {
135 slog.Warn(
136 "Client/server version mismatch",
137 "client", args.Version,
138 "server", version.Version,
139 )
140 appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf(
141 "Server version %q differs from client version %q. Consider restarting the server.",
142 version.Version, args.Version,
143 )))
144 }
145
146 result := proto.Workspace{
147 ID: id,
148 Path: args.Path,
149 DataDir: cfg.Config().Options.DataDirectory,
150 Debug: cfg.Config().Options.Debug,
151 YOLO: cfg.Overrides().SkipPermissionRequests,
152 Config: cfg.Config(),
153 Env: args.Env,
154 Skills: skillStatesToProto(skillStates),
155 }
156
157 return ws, result, nil
158}
159
160// skillsDiscoveryConfig adapts a *config.ConfigStore to the
161// skills.DiscoveryConfig that DiscoverFromConfig consumes.
162func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig {
163 opts := cfg.Config().Options
164 var paths, disabled []string
165 if opts != nil {
166 paths = opts.SkillsPaths
167 disabled = opts.DisabledSkills
168 }
169 var resolver func(string) (string, error)
170 if r := cfg.Resolver(); r != nil {
171 resolver = r.ResolveValue
172 }
173 return skills.DiscoveryConfig{
174 SkillsPaths: paths,
175 DisabledSkills: disabled,
176 Resolver: resolver,
177 }
178}
179
180// skillStatesToProto converts internal skill discovery states into the
181// wire format.
182func skillStatesToProto(states []*skills.SkillState) []proto.SkillState {
183 if len(states) == 0 {
184 return nil
185 }
186 out := make([]proto.SkillState, len(states))
187 for i, s := range states {
188 entry := proto.SkillState{
189 Name: s.Name,
190 Path: s.Path,
191 State: proto.SkillDiscoveryState(s.State),
192 }
193 if s.Err != nil {
194 entry.Error = s.Err.Error()
195 }
196 out[i] = entry
197 }
198 return out
199}
200
201// DeleteWorkspace shuts down and removes a workspace. If it was the
202// last workspace, the shutdown callback is invoked.
203func (b *Backend) DeleteWorkspace(id string) {
204 ws, ok := b.workspaces.Get(id)
205 if ok {
206 ws.Shutdown()
207 }
208 b.workspaces.Del(id)
209
210 if b.workspaces.Len() == 0 && b.shutdownFn != nil {
211 slog.Info("Last workspace removed, shutting down server...")
212 b.shutdownFn()
213 }
214}
215
216// GetWorkspaceProto returns the proto representation of a workspace.
217func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
218 ws, err := b.GetWorkspace(id)
219 if err != nil {
220 return proto.Workspace{}, err
221 }
222 return workspaceToProto(ws), nil
223}
224
225// VersionInfo returns server version information.
226func (b *Backend) VersionInfo() proto.VersionInfo {
227 return proto.VersionInfo{
228 Version: version.Version,
229 Commit: version.Commit,
230 BuildID: version.BuildID,
231 GoVersion: runtime.Version(),
232 Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
233 }
234}
235
236// Config returns the server-level configuration.
237func (b *Backend) Config() *config.ConfigStore {
238 return b.cfg
239}
240
241// Shutdown initiates a graceful server shutdown.
242func (b *Backend) Shutdown() {
243 if b.shutdownFn != nil {
244 b.shutdownFn()
245 }
246}
247
248func workspaceToProto(ws *Workspace) proto.Workspace {
249 cfg := ws.Cfg.Config()
250 out := proto.Workspace{
251 ID: ws.ID,
252 Path: ws.Path,
253 YOLO: ws.Cfg.Overrides().SkipPermissionRequests,
254 DataDir: cfg.Options.DataDirectory,
255 Debug: cfg.Options.Debug,
256 Config: cfg,
257 }
258 if ws.Skills != nil {
259 out.Skills = skillStatesToProto(ws.Skills.States())
260 }
261 return out
262}