backend.go

  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}