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