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	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}