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	"path/filepath"
 12	"runtime"
 13	"sync"
 14	"time"
 15
 16	"github.com/charmbracelet/crush/internal/app"
 17	"github.com/charmbracelet/crush/internal/config"
 18	"github.com/charmbracelet/crush/internal/csync"
 19	"github.com/charmbracelet/crush/internal/db"
 20	"github.com/charmbracelet/crush/internal/proto"
 21	"github.com/charmbracelet/crush/internal/skills"
 22	"github.com/charmbracelet/crush/internal/ui/util"
 23	"github.com/charmbracelet/crush/internal/version"
 24	"github.com/google/uuid"
 25)
 26
 27// Common errors returned by backend operations.
 28var (
 29	ErrWorkspaceNotFound       = errors.New("workspace not found")
 30	ErrLSPClientNotFound       = errors.New("LSP client not found")
 31	ErrAgentNotInitialized     = errors.New("agent coordinator not initialized")
 32	ErrPathRequired            = errors.New("path is required")
 33	ErrInvalidPermissionAction = errors.New("invalid permission action")
 34	ErrUnknownCommand          = errors.New("unknown command")
 35	ErrInvalidClientID         = errors.New("invalid client_id")
 36	ErrClientNotAttached       = errors.New("client not attached")
 37	ErrWorkspaceClosing        = errors.New("workspace closing")
 38)
 39
 40// DefaultCreateGrace is the window in which a client must open an SSE
 41// stream after creating a workspace before its creation hold is
 42// released. Exposed as a package variable so tests can shorten it.
 43var DefaultCreateGrace = 30 * time.Second
 44
 45// ShutdownFunc is called when the backend needs to trigger a server
 46// shutdown (e.g. when the last workspace is removed).
 47type ShutdownFunc func()
 48
 49// Backend provides transport-agnostic business logic for the Crush
 50// server. It manages workspaces and delegates to [app.App] services.
 51//
 52// Locking order: when both [Backend.mu] and [Workspace.clientsMu] are
 53// held at once, [Backend.mu] is acquired first. Detach paths
 54// ([detachStream], [releaseHoldLocked], [expireHold]) only hold
 55// [Workspace.clientsMu] briefly, drop it, then call [teardown] which
 56// takes [Backend.mu] (and then re-takes [Workspace.clientsMu] to
 57// re-check that the workspace has not been re-claimed). This avoids
 58// the AB/BA hazard with [CreateWorkspace], which holds [Backend.mu]
 59// while calling [registerClient] so that a workspace cannot be torn
 60// down beneath it.
 61type Backend struct {
 62	workspaces *csync.Map[string, *Workspace]
 63	// pathIndex maps a resolved absolute workspace path to its
 64	// workspace ID. Reads and writes are serialised via mu so
 65	// concurrent CreateWorkspace calls at the same path deduplicate
 66	// deterministically.
 67	pathIndex map[string]string
 68	mu        sync.Mutex
 69
 70	cfg         *config.ConfigStore
 71	ctx         context.Context
 72	shutdownFn  ShutdownFunc
 73	createGrace time.Duration
 74}
 75
 76// clientState tracks one client's claim on a workspace.
 77//
 78//   - streams counts the number of live SSE event streams the client
 79//     currently has open against the workspace.
 80//   - holdTimer is non-nil iff the client created the workspace but has
 81//     not yet attached an SSE stream; it fires after createGrace and
 82//     releases the hold.
 83//   - currentSessionID records which session this client is currently
 84//     viewing. Empty string means the client has no session selected
 85//     (e.g. the landing screen). Cleared automatically when the
 86//     clientState entry is removed.
 87//
 88// streams and holdTimer are mutually exclusive in practice (the hold
 89// timer is stopped the moment an SSE stream attaches), but both being
 90// zero/nil means the entry has been released and should be removed.
 91type clientState struct {
 92	streams          int
 93	holdTimer        *time.Timer
 94	currentSessionID string
 95}
 96
 97// Workspace represents a running [app.App] workspace with its
 98// associated resources and state.
 99type Workspace struct {
100	*app.App
101	ID     string
102	Path   string
103	Cfg    *config.ConfigStore
104	Env    []string
105	Skills *skills.Manager
106
107	// resolvedPath is the path used as the dedup key in
108	// Backend.pathIndex. It is filepath.EvalSymlinks(filepath.Abs(Path))
109	// with fallback to the cleaned absolute path.
110	resolvedPath string
111
112	// ctx is the workspace-scoped run context. It is derived from
113	// the backend context in CreateWorkspace and lives for the
114	// lifetime of the workspace; cancel tears it down. Agent runs
115	// dispatched on behalf of this workspace are bound to ctx so
116	// their lifetime is owned by the workspace, not by any single
117	// client's HTTP request.
118	ctx    context.Context
119	cancel context.CancelFunc
120
121	// runMu guards closing and gates dispatch of new agent runs.
122	// closing is set by Shutdown so no new runs are accepted once
123	// teardown has begun. runWG tracks dispatched agent goroutines
124	// so Shutdown can wait for them to return before app cleanup.
125	runMu   sync.Mutex
126	closing bool
127	runWG   sync.WaitGroup
128
129	// clientsMu guards clients. It is held only briefly (no IO).
130	clientsMu sync.Mutex
131	// clients tracks each client's claim on this workspace. Refcount
132	// is a derived value: len(clients).
133	clients map[string]*clientState
134
135	// shutdownFn is the function invoked by [Backend.teardown] to
136	// release the workspace's underlying resources. It defaults to the
137	// embedded [app.App.Shutdown]; tests may override it to avoid
138	// driving a full [app.App] through shutdown.
139	shutdownFn func()
140}
141
142// invokeShutdown calls the workspace shutdown hook if set, falling
143// back to the workspace [Workspace.Shutdown] wrapper when not.
144func (w *Workspace) invokeShutdown() {
145	if w.shutdownFn != nil {
146		w.shutdownFn()
147		return
148	}
149	if w.App != nil {
150		w.Shutdown()
151	}
152}
153
154// Shutdown tears the workspace down in an order that is safe for
155// agent runs whose lifetime is bound to the workspace context. It
156// shadows the promoted [app.App.Shutdown] so callers reaching
157// ws.Shutdown() always observe this ordering:
158//
159//  1. Mark the workspace closing so no new agent runs are accepted.
160//  2. Cancel the workspace run context so any dispatched goroutine
161//     that has not yet registered its per-session cancel still
162//     observes cancellation.
163//  3. Cancel active coordinator work for runs that already
164//     registered their per-session cancel function.
165//  4. Wait for dispatched agent goroutines to return.
166//  5. Run the embedded [app.App.Shutdown] cleanup (DB, LSP, etc).
167//
168// CancelAll is idempotent, so the second call inside app.App.Shutdown
169// is harmless; the important guarantee is that cancel -> CancelAll ->
170// runWG.Wait completes before the embedded cleanup touches the DB.
171func (w *Workspace) Shutdown() {
172	w.runMu.Lock()
173	w.closing = true
174	w.runMu.Unlock()
175
176	if w.cancel != nil {
177		w.cancel()
178	}
179	if w.App != nil && w.AgentCoordinator != nil {
180		w.AgentCoordinator.CancelAll()
181	}
182	w.runWG.Wait()
183	if w.App != nil {
184		w.App.Shutdown()
185	}
186}
187
188// New creates a new [Backend].
189func New(ctx context.Context, cfg *config.ConfigStore, shutdownFn ShutdownFunc) *Backend {
190	return &Backend{
191		workspaces:  csync.NewMap[string, *Workspace](),
192		pathIndex:   make(map[string]string),
193		cfg:         cfg,
194		ctx:         ctx,
195		shutdownFn:  shutdownFn,
196		createGrace: DefaultCreateGrace,
197	}
198}
199
200// SetCreateGrace overrides the create-grace window. Intended for tests
201// that need short timeouts.
202func (b *Backend) SetCreateGrace(d time.Duration) {
203	b.mu.Lock()
204	defer b.mu.Unlock()
205	b.createGrace = d
206}
207
208// GetWorkspace retrieves a workspace by ID.
209func (b *Backend) GetWorkspace(id string) (*Workspace, error) {
210	ws, ok := b.workspaces.Get(id)
211	if !ok {
212		return nil, ErrWorkspaceNotFound
213	}
214	return ws, nil
215}
216
217// ListWorkspaces returns all running workspaces.
218func (b *Backend) ListWorkspaces() []proto.Workspace {
219	workspaces := []proto.Workspace{}
220	for _, ws := range b.workspaces.Seq2() {
221		workspaces = append(workspaces, workspaceToProto(ws))
222	}
223	return workspaces
224}
225
226// CreateWorkspace initializes a new workspace from the given
227// parameters, or returns an existing workspace if one already exists at
228// the same resolved path (first-wins semantics).
229//
230// args.ClientID must be a valid UUID identifying the calling client;
231// the resulting workspace registers a creation hold on behalf of that
232// client which is released either by the first SSE attach (which
233// converts it into a stream claim) or by the grace window expiring.
234func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Workspace, error) {
235	if args.Path == "" {
236		return nil, proto.Workspace{}, ErrPathRequired
237	}
238	clientID, err := validateClientID(args.ClientID)
239	if err != nil {
240		return nil, proto.Workspace{}, err
241	}
242
243	key, err := resolveWorkspaceKey(args.Path)
244	if err != nil {
245		return nil, proto.Workspace{}, fmt.Errorf("failed to resolve workspace path: %w", err)
246	}
247
248	b.mu.Lock()
249	if existingID, ok := b.pathIndex[key]; ok {
250		if ws, found := b.workspaces.Get(existingID); found {
251			// Hold b.mu while registering: teardown also
252			// acquires b.mu before tearing the workspace
253			// down, so this guarantees the workspace we
254			// return cannot be torn out from under us
255			// between lookup and registerClient. Lock order
256			// here is b.mu -> ws.clientsMu.
257			logFirstWinsMismatch(ws, args)
258			b.registerClient(ws, clientID)
259			b.mu.Unlock()
260			return ws, workspaceToProto(ws), nil
261		}
262		// pathIndex referenced a workspace that has since been
263		// removed; clean the stale entry and fall through.
264		delete(b.pathIndex, key)
265	}
266	b.mu.Unlock()
267
268	id := uuid.New().String()
269	cfg, err := config.Init(args.Path, args.DataDir, args.Debug)
270	if err != nil {
271		return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err)
272	}
273
274	cfg.Overrides().SkipPermissionRequests = args.YOLO
275
276	if err := createDotCrushDir(cfg.Config().Options.DataDirectory); err != nil {
277		return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err)
278	}
279
280	conn, err := db.Connect(b.ctx, cfg.Config().Options.DataDirectory, db.WithDataDirLock(true))
281	if err != nil {
282		return nil, proto.Workspace{}, fmt.Errorf("failed to connect to database: %w", err)
283	}
284
285	// Discover skills once per workspace, before app.New. The backend
286	// hosts multiple workspaces concurrently, so the manager is
287	// constructed WITHOUT WithGlobalMirror to prevent last-writer-wins
288	// cross-talk between workspaces.
289	discoveryCfg := skillsDiscoveryConfig(cfg)
290	allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
291	skillsMgr := skills.NewManager(
292		allSkills, activeSkills, skillStates,
293		skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
294		skills.WithWorkingDir(discoveryCfg.WorkingDir),
295	)
296
297	appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
298	if err != nil {
299		return nil, proto.Workspace{}, fmt.Errorf("failed to create app workspace: %w", err)
300	}
301
302	wsCtx, wsCancel := context.WithCancel(b.ctx)
303	ws := &Workspace{
304		App:          appWorkspace,
305		ID:           id,
306		Path:         args.Path,
307		Cfg:          cfg,
308		Env:          args.Env,
309		Skills:       skillsMgr,
310		resolvedPath: key,
311		ctx:          wsCtx,
312		cancel:       wsCancel,
313		clients:      make(map[string]*clientState),
314	}
315
316	b.mu.Lock()
317	// Re-check the index under the lock: a concurrent caller may have
318	// won the race between the initial unlock and here.
319	if existingID, ok := b.pathIndex[key]; ok {
320		if existing, found := b.workspaces.Get(existingID); found {
321			// Register under b.mu so teardown cannot run
322			// between lookup and registerClient. Lock order
323			// is b.mu -> ws.clientsMu.
324			logFirstWinsMismatch(existing, args)
325			b.registerClient(existing, clientID)
326			b.mu.Unlock()
327			ws.invokeShutdown()
328			return existing, workspaceToProto(existing), nil
329		}
330		delete(b.pathIndex, key)
331	}
332	b.workspaces.Set(id, ws)
333	b.pathIndex[key] = id
334	// Register the originating client's hold while still holding
335	// b.mu so the workspace is observable with its claim from the
336	// moment it appears in the index.
337	b.registerClient(ws, clientID)
338	b.mu.Unlock()
339
340	if args.Version != "" && args.Version != version.Version {
341		slog.Warn(
342			"Client/server version mismatch",
343			"client", args.Version,
344			"server", version.Version,
345		)
346		appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf(
347			"Server version %q differs from client version %q. Consider restarting the server.",
348			version.Version, args.Version,
349		)))
350	}
351
352	return ws, workspaceToProto(ws), nil
353}
354
355// skillsDiscoveryConfig adapts a *config.ConfigStore to the
356// skills.DiscoveryConfig that DiscoverFromConfig consumes.
357func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig {
358	opts := cfg.Config().Options
359	var paths, disabled []string
360	if opts != nil {
361		paths = opts.SkillsPaths
362		disabled = opts.DisabledSkills
363	}
364	var resolver func(string) (string, error)
365	if r := cfg.Resolver(); r != nil {
366		resolver = r.ResolveValue
367	}
368	return skills.DiscoveryConfig{
369		SkillsPaths:    paths,
370		DisabledSkills: disabled,
371		WorkingDir:     cfg.WorkingDir(),
372		Resolver:       resolver,
373	}
374}
375
376// skillStatesToProto converts internal skill discovery states into the
377// wire format.
378func skillStatesToProto(states []*skills.SkillState) []proto.SkillState {
379	if len(states) == 0 {
380		return nil
381	}
382	out := make([]proto.SkillState, len(states))
383	for i, s := range states {
384		entry := proto.SkillState{
385			Name:  s.Name,
386			Path:  s.Path,
387			State: proto.SkillDiscoveryState(s.State),
388		}
389		if s.Err != nil {
390			entry.Error = s.Err.Error()
391		}
392		out[i] = entry
393	}
394	return out
395}
396
397// AttachClient registers a new SSE stream for the given client on the
398// workspace. The stream's deferred cleanup must call DetachClient with
399// the same arguments to release the claim.
400//
401// The lookup and the clients-map mutation are performed under
402// [Backend.mu] so that AttachClient cannot race with [Backend.teardown]:
403// teardown also holds [Backend.mu] while removing the workspace from
404// b.workspaces, so once AttachClient observes the workspace and takes
405// ws.clientsMu (under b.mu), no concurrent teardown can succeed without
406// re-checking the (now non-empty) clients map. Lock order is the
407// canonical b.mu -> ws.clientsMu.
408func (b *Backend) AttachClient(workspaceID, clientID string) error {
409	if _, err := validateClientID(clientID); err != nil {
410		return err
411	}
412
413	b.mu.Lock()
414	defer b.mu.Unlock()
415	ws, ok := b.workspaces.Get(workspaceID)
416	if !ok {
417		return ErrWorkspaceNotFound
418	}
419
420	ws.clientsMu.Lock()
421	defer ws.clientsMu.Unlock()
422	cs, ok := ws.clients[clientID]
423	if !ok {
424		// Defensive: SSE attach without a prior CreateWorkspace by
425		// this client still installs a stream claim so the stream
426		// stays alive for its duration.
427		ws.clients[clientID] = &clientState{streams: 1}
428		return nil
429	}
430	if cs.holdTimer != nil {
431		cs.holdTimer.Stop()
432		cs.holdTimer = nil
433	}
434	cs.streams++
435	return nil
436}
437
438// DetachClient releases one SSE stream's hold on the workspace. If the
439// client has no other streams and no pending creation hold, its claim
440// is removed and the workspace is torn down once refcount hits zero.
441func (b *Backend) DetachClient(workspaceID, clientID string) {
442	ws, ok := b.workspaces.Get(workspaceID)
443	if !ok {
444		return
445	}
446	b.detachStream(ws, clientID)
447}
448
449// releaseHold releases the creation hold for a client, if any. Active
450// stream claims are unaffected. Idempotent: returns nil if the
451// workspace or the client's hold no longer exist.
452func (b *Backend) releaseHold(workspaceID, clientID string) error {
453	if _, err := validateClientID(clientID); err != nil {
454		return err
455	}
456	ws, ok := b.workspaces.Get(workspaceID)
457	if !ok {
458		return nil
459	}
460	b.releaseHoldLocked(ws, clientID)
461	return nil
462}
463
464// registerClient installs (idempotently) the given client's claim on
465// the workspace and starts a grace timer if the entry is fresh.
466func (b *Backend) registerClient(ws *Workspace, clientID string) {
467	ws.clientsMu.Lock()
468	defer ws.clientsMu.Unlock()
469	if _, ok := ws.clients[clientID]; ok {
470		// Idempotent: a duplicate CreateWorkspace from the same
471		// client does not add a second claim.
472		return
473	}
474	cs := &clientState{}
475	cs.holdTimer = time.AfterFunc(b.createGrace, func() {
476		b.expireHold(ws, clientID, cs)
477	})
478	ws.clients[clientID] = cs
479}
480
481// expireHold is the body of the grace timer. It runs in its own
482// goroutine and races against AttachClient/releaseHold; the timer
483// stays valid only while the entry's holdTimer still points at it.
484func (b *Backend) expireHold(ws *Workspace, clientID string, timer *clientState) {
485	ws.clientsMu.Lock()
486	cs, ok := ws.clients[clientID]
487	if !ok || cs != timer || cs.holdTimer == nil || cs.streams > 0 {
488		ws.clientsMu.Unlock()
489		return
490	}
491	cs.holdTimer = nil
492	delete(ws.clients, clientID)
493	teardown := len(ws.clients) == 0
494	ws.clientsMu.Unlock()
495	if teardown {
496		b.teardown(ws)
497	}
498}
499
500func (b *Backend) releaseHoldLocked(ws *Workspace, clientID string) {
501	ws.clientsMu.Lock()
502	cs, ok := ws.clients[clientID]
503	if !ok {
504		ws.clientsMu.Unlock()
505		return
506	}
507	if cs.holdTimer != nil {
508		cs.holdTimer.Stop()
509		cs.holdTimer = nil
510	}
511	teardown := false
512	if cs.streams == 0 {
513		delete(ws.clients, clientID)
514		teardown = len(ws.clients) == 0
515	}
516	ws.clientsMu.Unlock()
517	if teardown {
518		b.teardown(ws)
519	}
520}
521
522func (b *Backend) detachStream(ws *Workspace, clientID string) {
523	ws.clientsMu.Lock()
524	cs, ok := ws.clients[clientID]
525	if !ok {
526		ws.clientsMu.Unlock()
527		return
528	}
529	if cs.streams > 0 {
530		cs.streams--
531	}
532	teardown := false
533	if cs.streams == 0 && cs.holdTimer == nil {
534		delete(ws.clients, clientID)
535		teardown = len(ws.clients) == 0
536	}
537	ws.clientsMu.Unlock()
538	if teardown {
539		b.teardown(ws)
540	}
541}
542
543// teardown removes the workspace from the index, shuts down its
544// underlying [app.App], and triggers a server shutdown if it was the
545// last workspace alive.
546//
547// Callers reach teardown after observing len(ws.clients) == 0 while
548// holding ws.clientsMu and then releasing it. Between that release
549// and the b.mu.Lock below, a concurrent CreateWorkspace may have
550// re-registered a client (CreateWorkspace holds b.mu while doing so,
551// so it is mutually exclusive with this critical section). teardown
552// re-checks under both locks (in the canonical b.mu -> ws.clientsMu
553// order) and aborts if the workspace has been re-claimed.
554func (b *Backend) teardown(ws *Workspace) {
555	b.mu.Lock()
556	ws.clientsMu.Lock()
557	if len(ws.clients) > 0 {
558		// Race: a CreateWorkspace re-registered a client
559		// between the detach path dropping ws.clientsMu and us
560		// taking b.mu. Abort: the workspace is still alive.
561		ws.clientsMu.Unlock()
562		b.mu.Unlock()
563		return
564	}
565	ws.clientsMu.Unlock()
566	if existing, ok := b.pathIndex[ws.resolvedPath]; ok && existing == ws.ID {
567		delete(b.pathIndex, ws.resolvedPath)
568	}
569	b.workspaces.Del(ws.ID)
570	remaining := b.workspaces.Len()
571	b.mu.Unlock()
572
573	ws.invokeShutdown()
574
575	if remaining == 0 && b.shutdownFn != nil {
576		slog.Info("Last workspace removed, shutting down server...")
577		b.shutdownFn()
578	}
579}
580
581// DeleteWorkspace is the public entry point used by the HTTP DELETE
582// handler. It releases the named client's creation hold; live streams
583// from the same client remain attached and continue holding the
584// workspace open until their own deferred DetachClient runs.
585func (b *Backend) DeleteWorkspace(id, clientID string) error {
586	return b.releaseHold(id, clientID)
587}
588
589// SetCurrentSession records which session the given client is
590// currently viewing within the workspace. Passing an empty sessionID
591// clears the client's current-session entry (e.g. the client has
592// returned to the landing screen).
593//
594// The client must be actually attached — i.e. its [clientState] entry
595// must exist and have at least one live stream. A bare creation hold
596// (streams == 0) is rejected with [ErrClientNotAttached]. This
597// guards against zombie writes from a client that has detached and
598// against ghost presence from a hold-only client that never opened an
599// SSE stream.
600func (b *Backend) SetCurrentSession(workspaceID, clientID, sessionID string) error {
601	if _, err := validateClientID(clientID); err != nil {
602		return err
603	}
604	ws, ok := b.workspaces.Get(workspaceID)
605	if !ok {
606		return ErrWorkspaceNotFound
607	}
608	ws.clientsMu.Lock()
609	defer ws.clientsMu.Unlock()
610	cs, ok := ws.clients[clientID]
611	if !ok || cs.streams == 0 {
612		// No entry, or hold-only (no live stream): refuse the
613		// write. The presence record this is meant to feed
614		// should only reflect clients that can actually observe
615		// session events.
616		return ErrClientNotAttached
617	}
618	cs.currentSessionID = sessionID
619	return nil
620}
621
622// AttachedClients returns the number of clients currently viewing
623// sessionID in the given workspace. Only clients with at least one live
624// SSE stream (streams > 0) AND a matching currentSessionID are counted;
625// pure creation holds do not contribute. Returns [ErrWorkspaceNotFound]
626// if the workspace is unknown.
627func (b *Backend) AttachedClients(workspaceID, sessionID string) (int, error) {
628	ws, ok := b.workspaces.Get(workspaceID)
629	if !ok {
630		return 0, ErrWorkspaceNotFound
631	}
632	return ws.AttachedClientsForSession(sessionID), nil
633}
634
635// AttachedClientsForSession returns the number of clients in this
636// workspace whose currentSessionID equals sessionID and which have at
637// least one live SSE stream. Hold-only clients (streams == 0) do not
638// contribute. Acquires the workspace's [clientsMu] briefly; the
639// returned count is a point-in-time snapshot.
640func (w *Workspace) AttachedClientsForSession(sessionID string) int {
641	w.clientsMu.Lock()
642	defer w.clientsMu.Unlock()
643	n := 0
644	for _, cs := range w.clients {
645		if cs.streams > 0 && cs.currentSessionID == sessionID {
646			n++
647		}
648	}
649	return n
650}
651
652// GetWorkspaceProto returns the proto representation of a workspace.
653func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
654	ws, err := b.GetWorkspace(id)
655	if err != nil {
656		return proto.Workspace{}, err
657	}
658	return workspaceToProto(ws), nil
659}
660
661// VersionInfo returns server version information.
662func (b *Backend) VersionInfo() proto.VersionInfo {
663	return proto.VersionInfo{
664		Version:   version.Version,
665		Commit:    version.Commit,
666		BuildID:   version.BuildID,
667		GoVersion: runtime.Version(),
668		Platform:  fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
669	}
670}
671
672// Config returns the server-level configuration.
673func (b *Backend) Config() *config.ConfigStore {
674	return b.cfg
675}
676
677// Shutdown initiates a graceful server shutdown.
678func (b *Backend) Shutdown() {
679	if b.shutdownFn != nil {
680		b.shutdownFn()
681	}
682}
683
684// resolveWorkspaceKey returns a stable canonical form of path suitable
685// for use as a dedup key. It applies filepath.Abs, then attempts
686// filepath.EvalSymlinks; because EvalSymlinks errors on non-existent
687// paths, it falls back to the cleaned absolute path in that case.
688func resolveWorkspaceKey(path string) (string, error) {
689	abs, err := filepath.Abs(path)
690	if err != nil {
691		return "", err
692	}
693	if resolved, err := filepath.EvalSymlinks(abs); err == nil {
694		return resolved, nil
695	}
696	return abs, nil
697}
698
699// validateClientID returns the trimmed UUID string or an error if the
700// input is empty or not a valid UUID.
701func validateClientID(id string) (string, error) {
702	if id == "" {
703		return "", ErrInvalidClientID
704	}
705	if _, err := uuid.Parse(id); err != nil {
706		return "", fmt.Errorf("%w: %v", ErrInvalidClientID, err)
707	}
708	return id, nil
709}
710
711func workspaceToProto(ws *Workspace) proto.Workspace {
712	cfg := ws.Cfg.Config()
713	out := proto.Workspace{
714		ID:      ws.ID,
715		Path:    ws.Path,
716		YOLO:    ws.Cfg.Overrides().SkipPermissionRequests,
717		DataDir: cfg.Options.DataDirectory,
718		Debug:   cfg.Options.Debug,
719		Config:  cfg,
720		Env:     ws.Env,
721		Version: version.Version,
722	}
723	if ws.Skills != nil {
724		out.Skills = skillStatesToProto(ws.Skills.States())
725	}
726	return out
727}
728
729// logFirstWinsMismatch emits a debug line whenever a second
730// CreateWorkspace at the same resolved path arrives with flags that
731// differ from the originating workspace. The existing workspace wins;
732// the incoming flags are silently ignored.
733//
734// The comparison is done against the incoming args as the caller sent
735// them — including empty/zero values — rather than after defaulting.
736// This means that, for example, a second caller who omits DataDir
737// while the first set one will still log the mismatch.
738func logFirstWinsMismatch(existing *Workspace, args proto.Workspace) {
739	existingCfg := existing.Cfg.Config()
740	existingYOLO := existing.Cfg.Overrides().SkipPermissionRequests
741	if existingYOLO == args.YOLO &&
742		existingCfg.Options.Debug == args.Debug &&
743		existingCfg.Options.DataDirectory == args.DataDir &&
744		stringSlicesEqual(existing.Env, args.Env) {
745		return
746	}
747	slog.Debug(
748		"Workspace flag mismatch on duplicate create; first wins",
749		"workspace_id", existing.ID,
750		"path", existing.Path,
751		"existing_yolo", existingYOLO,
752		"requested_yolo", args.YOLO,
753		"existing_debug", existingCfg.Options.Debug,
754		"requested_debug", args.Debug,
755		"existing_data_dir", existingCfg.Options.DataDirectory,
756		"requested_data_dir", args.DataDir,
757		"existing_env", existing.Env,
758		"requested_env", args.Env,
759	)
760}
761
762// stringSlicesEqual reports whether a and b contain the same strings
763// in the same order. nil and empty are treated as equal.
764func stringSlicesEqual(a, b []string) bool {
765	if len(a) != len(b) {
766		return false
767	}
768	for i := range a {
769		if a[i] != b[i] {
770			return false
771		}
772	}
773	return true
774}