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