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 "git.secluded.site/crush/internal/app"
14 "git.secluded.site/crush/internal/config"
15 "git.secluded.site/crush/internal/csync"
16 "git.secluded.site/crush/internal/db"
17 "git.secluded.site/crush/internal/proto"
18 "git.secluded.site/crush/internal/skills"
19 "git.secluded.site/crush/internal/ui/util"
20 "git.secluded.site/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 discoveryCfg := skillsDiscoveryConfig(cfg)
116 allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
117 skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
118 skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
119 skills.WithWorkingDir(discoveryCfg.WorkingDir),
120 )
121
122 appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
123 if err != nil {
124 return nil, proto.Workspace{}, fmt.Errorf("failed to create app workspace: %w", err)
125 }
126
127 ws := &Workspace{
128 App: appWorkspace,
129 ID: id,
130 Path: args.Path,
131 Cfg: cfg,
132 Env: args.Env,
133 Skills: skillsMgr,
134 }
135
136 b.workspaces.Set(id, ws)
137
138 if args.Version != "" && args.Version != version.Version {
139 slog.Warn(
140 "Client/server version mismatch",
141 "client", args.Version,
142 "server", version.Version,
143 )
144 appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf(
145 "Server version %q differs from client version %q. Consider restarting the server.",
146 version.Version, args.Version,
147 )))
148 }
149
150 result := proto.Workspace{
151 ID: id,
152 Path: args.Path,
153 DataDir: cfg.Config().Options.DataDirectory,
154 Debug: cfg.Config().Options.Debug,
155 YOLO: cfg.Overrides().SkipPermissionRequests,
156 Config: cfg.Config(),
157 Env: args.Env,
158 Skills: skillStatesToProto(skillStates),
159 }
160
161 return ws, result, nil
162}
163
164// skillsDiscoveryConfig adapts a *config.ConfigStore to the
165// skills.DiscoveryConfig that DiscoverFromConfig consumes.
166func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig {
167 opts := cfg.Config().Options
168 var paths, disabled []string
169 if opts != nil {
170 paths = opts.SkillsPaths
171 disabled = opts.DisabledSkills
172 }
173 var resolver func(string) (string, error)
174 if r := cfg.Resolver(); r != nil {
175 resolver = r.ResolveValue
176 }
177 return skills.DiscoveryConfig{
178 SkillsPaths: paths,
179 DisabledSkills: disabled,
180 WorkingDir: cfg.WorkingDir(),
181 Resolver: resolver,
182 }
183}
184
185// skillStatesToProto converts internal skill discovery states into the
186// wire format.
187func skillStatesToProto(states []*skills.SkillState) []proto.SkillState {
188 if len(states) == 0 {
189 return nil
190 }
191 out := make([]proto.SkillState, len(states))
192 for i, s := range states {
193 entry := proto.SkillState{
194 Name: s.Name,
195 Path: s.Path,
196 State: proto.SkillDiscoveryState(s.State),
197 }
198 if s.Err != nil {
199 entry.Error = s.Err.Error()
200 }
201 out[i] = entry
202 }
203 return out
204}
205
206// DeleteWorkspace shuts down and removes a workspace. If it was the
207// last workspace, the shutdown callback is invoked.
208func (b *Backend) DeleteWorkspace(id string) {
209 ws, ok := b.workspaces.Get(id)
210 if ok {
211 ws.Shutdown()
212 }
213 b.workspaces.Del(id)
214
215 if b.workspaces.Len() == 0 && b.shutdownFn != nil {
216 slog.Info("Last workspace removed, shutting down server...")
217 b.shutdownFn()
218 }
219}
220
221// GetWorkspaceProto returns the proto representation of a workspace.
222func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
223 ws, err := b.GetWorkspace(id)
224 if err != nil {
225 return proto.Workspace{}, err
226 }
227 return workspaceToProto(ws), nil
228}
229
230// VersionInfo returns server version information.
231func (b *Backend) VersionInfo() proto.VersionInfo {
232 return proto.VersionInfo{
233 Version: version.Version,
234 Commit: version.Commit,
235 BuildID: version.BuildID,
236 GoVersion: runtime.Version(),
237 Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
238 }
239}
240
241// Config returns the server-level configuration.
242func (b *Backend) Config() *config.ConfigStore {
243 return b.cfg
244}
245
246// Shutdown initiates a graceful server shutdown.
247func (b *Backend) Shutdown() {
248 if b.shutdownFn != nil {
249 b.shutdownFn()
250 }
251}
252
253func workspaceToProto(ws *Workspace) proto.Workspace {
254 cfg := ws.Cfg.Config()
255 out := proto.Workspace{
256 ID: ws.ID,
257 Path: ws.Path,
258 YOLO: ws.Cfg.Overrides().SkipPermissionRequests,
259 DataDir: cfg.Options.DataDirectory,
260 Debug: cfg.Options.Debug,
261 Config: cfg,
262 }
263 if ws.Skills != nil {
264 out.Skills = skillStatesToProto(ws.Skills.States())
265 }
266 return out
267}