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(
240 allSkills, activeSkills, skillStates,
241 skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
242 skills.WithWorkingDir(discoveryCfg.WorkingDir),
243 )
244
245 appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
246 if err != nil {
247 return nil, proto.Workspace{}, fmt.Errorf("failed to create app workspace: %w", err)
248 }
249
250 ws := &Workspace{
251 App: appWorkspace,
252 ID: id,
253 Path: args.Path,
254 Cfg: cfg,
255 Env: args.Env,
256 Skills: skillsMgr,
257 resolvedPath: key,
258 clients: make(map[string]*clientState),
259 }
260
261 b.mu.Lock()
262 // Re-check the index under the lock: a concurrent caller may have
263 // won the race between the initial unlock and here.
264 if existingID, ok := b.pathIndex[key]; ok {
265 if existing, found := b.workspaces.Get(existingID); found {
266 // Register under b.mu so teardown cannot run
267 // between lookup and registerClient. Lock order
268 // is b.mu -> ws.clientsMu.
269 logFirstWinsMismatch(existing, args)
270 b.registerClient(existing, clientID)
271 b.mu.Unlock()
272 ws.invokeShutdown()
273 return existing, workspaceToProto(existing), nil
274 }
275 delete(b.pathIndex, key)
276 }
277 b.workspaces.Set(id, ws)
278 b.pathIndex[key] = id
279 // Register the originating client's hold while still holding
280 // b.mu so the workspace is observable with its claim from the
281 // moment it appears in the index.
282 b.registerClient(ws, clientID)
283 b.mu.Unlock()
284
285 if args.Version != "" && args.Version != version.Version {
286 slog.Warn(
287 "Client/server version mismatch",
288 "client", args.Version,
289 "server", version.Version,
290 )
291 appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf(
292 "Server version %q differs from client version %q. Consider restarting the server.",
293 version.Version, args.Version,
294 )))
295 }
296
297 return ws, workspaceToProto(ws), nil
298}
299
300// skillsDiscoveryConfig adapts a *config.ConfigStore to the
301// skills.DiscoveryConfig that DiscoverFromConfig consumes.
302func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig {
303 opts := cfg.Config().Options
304 var paths, disabled []string
305 if opts != nil {
306 paths = opts.SkillsPaths
307 disabled = opts.DisabledSkills
308 }
309 var resolver func(string) (string, error)
310 if r := cfg.Resolver(); r != nil {
311 resolver = r.ResolveValue
312 }
313 return skills.DiscoveryConfig{
314 SkillsPaths: paths,
315 DisabledSkills: disabled,
316 WorkingDir: cfg.WorkingDir(),
317 Resolver: resolver,
318 }
319}
320
321// skillStatesToProto converts internal skill discovery states into the
322// wire format.
323func skillStatesToProto(states []*skills.SkillState) []proto.SkillState {
324 if len(states) == 0 {
325 return nil
326 }
327 out := make([]proto.SkillState, len(states))
328 for i, s := range states {
329 entry := proto.SkillState{
330 Name: s.Name,
331 Path: s.Path,
332 State: proto.SkillDiscoveryState(s.State),
333 }
334 if s.Err != nil {
335 entry.Error = s.Err.Error()
336 }
337 out[i] = entry
338 }
339 return out
340}
341
342// AttachClient registers a new SSE stream for the given client on the
343// workspace. The stream's deferred cleanup must call DetachClient with
344// the same arguments to release the claim.
345//
346// The lookup and the clients-map mutation are performed under
347// [Backend.mu] so that AttachClient cannot race with [Backend.teardown]:
348// teardown also holds [Backend.mu] while removing the workspace from
349// b.workspaces, so once AttachClient observes the workspace and takes
350// ws.clientsMu (under b.mu), no concurrent teardown can succeed without
351// re-checking the (now non-empty) clients map. Lock order is the
352// canonical b.mu -> ws.clientsMu.
353func (b *Backend) AttachClient(workspaceID, clientID string) error {
354 if _, err := validateClientID(clientID); err != nil {
355 return err
356 }
357
358 b.mu.Lock()
359 defer b.mu.Unlock()
360 ws, ok := b.workspaces.Get(workspaceID)
361 if !ok {
362 return ErrWorkspaceNotFound
363 }
364
365 ws.clientsMu.Lock()
366 defer ws.clientsMu.Unlock()
367 cs, ok := ws.clients[clientID]
368 if !ok {
369 // Defensive: SSE attach without a prior CreateWorkspace by
370 // this client still installs a stream claim so the stream
371 // stays alive for its duration.
372 ws.clients[clientID] = &clientState{streams: 1}
373 return nil
374 }
375 if cs.holdTimer != nil {
376 cs.holdTimer.Stop()
377 cs.holdTimer = nil
378 }
379 cs.streams++
380 return nil
381}
382
383// DetachClient releases one SSE stream's hold on the workspace. If the
384// client has no other streams and no pending creation hold, its claim
385// is removed and the workspace is torn down once refcount hits zero.
386func (b *Backend) DetachClient(workspaceID, clientID string) {
387 ws, ok := b.workspaces.Get(workspaceID)
388 if !ok {
389 return
390 }
391 b.detachStream(ws, clientID)
392}
393
394// releaseHold releases the creation hold for a client, if any. Active
395// stream claims are unaffected. Idempotent: returns nil if the
396// workspace or the client's hold no longer exist.
397func (b *Backend) releaseHold(workspaceID, clientID string) error {
398 if _, err := validateClientID(clientID); err != nil {
399 return err
400 }
401 ws, ok := b.workspaces.Get(workspaceID)
402 if !ok {
403 return nil
404 }
405 b.releaseHoldLocked(ws, clientID)
406 return nil
407}
408
409// registerClient installs (idempotently) the given client's claim on
410// the workspace and starts a grace timer if the entry is fresh.
411func (b *Backend) registerClient(ws *Workspace, clientID string) {
412 ws.clientsMu.Lock()
413 defer ws.clientsMu.Unlock()
414 if _, ok := ws.clients[clientID]; ok {
415 // Idempotent: a duplicate CreateWorkspace from the same
416 // client does not add a second claim.
417 return
418 }
419 cs := &clientState{}
420 cs.holdTimer = time.AfterFunc(b.createGrace, func() {
421 b.expireHold(ws, clientID, cs)
422 })
423 ws.clients[clientID] = cs
424}
425
426// expireHold is the body of the grace timer. It runs in its own
427// goroutine and races against AttachClient/releaseHold; the timer
428// stays valid only while the entry's holdTimer still points at it.
429func (b *Backend) expireHold(ws *Workspace, clientID string, timer *clientState) {
430 ws.clientsMu.Lock()
431 cs, ok := ws.clients[clientID]
432 if !ok || cs != timer || cs.holdTimer == nil || cs.streams > 0 {
433 ws.clientsMu.Unlock()
434 return
435 }
436 cs.holdTimer = nil
437 delete(ws.clients, clientID)
438 teardown := len(ws.clients) == 0
439 ws.clientsMu.Unlock()
440 if teardown {
441 b.teardown(ws)
442 }
443}
444
445func (b *Backend) releaseHoldLocked(ws *Workspace, clientID string) {
446 ws.clientsMu.Lock()
447 cs, ok := ws.clients[clientID]
448 if !ok {
449 ws.clientsMu.Unlock()
450 return
451 }
452 if cs.holdTimer != nil {
453 cs.holdTimer.Stop()
454 cs.holdTimer = nil
455 }
456 teardown := false
457 if cs.streams == 0 {
458 delete(ws.clients, clientID)
459 teardown = len(ws.clients) == 0
460 }
461 ws.clientsMu.Unlock()
462 if teardown {
463 b.teardown(ws)
464 }
465}
466
467func (b *Backend) detachStream(ws *Workspace, clientID string) {
468 ws.clientsMu.Lock()
469 cs, ok := ws.clients[clientID]
470 if !ok {
471 ws.clientsMu.Unlock()
472 return
473 }
474 if cs.streams > 0 {
475 cs.streams--
476 }
477 teardown := false
478 if cs.streams == 0 && cs.holdTimer == nil {
479 delete(ws.clients, clientID)
480 teardown = len(ws.clients) == 0
481 }
482 ws.clientsMu.Unlock()
483 if teardown {
484 b.teardown(ws)
485 }
486}
487
488// teardown removes the workspace from the index, shuts down its
489// underlying [app.App], and triggers a server shutdown if it was the
490// last workspace alive.
491//
492// Callers reach teardown after observing len(ws.clients) == 0 while
493// holding ws.clientsMu and then releasing it. Between that release
494// and the b.mu.Lock below, a concurrent CreateWorkspace may have
495// re-registered a client (CreateWorkspace holds b.mu while doing so,
496// so it is mutually exclusive with this critical section). teardown
497// re-checks under both locks (in the canonical b.mu -> ws.clientsMu
498// order) and aborts if the workspace has been re-claimed.
499func (b *Backend) teardown(ws *Workspace) {
500 b.mu.Lock()
501 ws.clientsMu.Lock()
502 if len(ws.clients) > 0 {
503 // Race: a CreateWorkspace re-registered a client
504 // between the detach path dropping ws.clientsMu and us
505 // taking b.mu. Abort: the workspace is still alive.
506 ws.clientsMu.Unlock()
507 b.mu.Unlock()
508 return
509 }
510 ws.clientsMu.Unlock()
511 if existing, ok := b.pathIndex[ws.resolvedPath]; ok && existing == ws.ID {
512 delete(b.pathIndex, ws.resolvedPath)
513 }
514 b.workspaces.Del(ws.ID)
515 remaining := b.workspaces.Len()
516 b.mu.Unlock()
517
518 ws.invokeShutdown()
519
520 if remaining == 0 && b.shutdownFn != nil {
521 slog.Info("Last workspace removed, shutting down server...")
522 b.shutdownFn()
523 }
524}
525
526// DeleteWorkspace is the public entry point used by the HTTP DELETE
527// handler. It releases the named client's creation hold; live streams
528// from the same client remain attached and continue holding the
529// workspace open until their own deferred DetachClient runs.
530func (b *Backend) DeleteWorkspace(id, clientID string) error {
531 return b.releaseHold(id, clientID)
532}
533
534// SetCurrentSession records which session the given client is
535// currently viewing within the workspace. Passing an empty sessionID
536// clears the client's current-session entry (e.g. the client has
537// returned to the landing screen).
538//
539// The client must be actually attached — i.e. its [clientState] entry
540// must exist and have at least one live stream. A bare creation hold
541// (streams == 0) is rejected with [ErrClientNotAttached]. This
542// guards against zombie writes from a client that has detached and
543// against ghost presence from a hold-only client that never opened an
544// SSE stream.
545func (b *Backend) SetCurrentSession(workspaceID, clientID, sessionID string) error {
546 if _, err := validateClientID(clientID); err != nil {
547 return err
548 }
549 ws, ok := b.workspaces.Get(workspaceID)
550 if !ok {
551 return ErrWorkspaceNotFound
552 }
553 ws.clientsMu.Lock()
554 defer ws.clientsMu.Unlock()
555 cs, ok := ws.clients[clientID]
556 if !ok || cs.streams == 0 {
557 // No entry, or hold-only (no live stream): refuse the
558 // write. The presence record this is meant to feed
559 // should only reflect clients that can actually observe
560 // session events.
561 return ErrClientNotAttached
562 }
563 cs.currentSessionID = sessionID
564 return nil
565}
566
567// AttachedClients returns the number of clients currently viewing
568// sessionID in the given workspace. Only clients with at least one live
569// SSE stream (streams > 0) AND a matching currentSessionID are counted;
570// pure creation holds do not contribute. Returns [ErrWorkspaceNotFound]
571// if the workspace is unknown.
572func (b *Backend) AttachedClients(workspaceID, sessionID string) (int, error) {
573 ws, ok := b.workspaces.Get(workspaceID)
574 if !ok {
575 return 0, ErrWorkspaceNotFound
576 }
577 return ws.AttachedClientsForSession(sessionID), nil
578}
579
580// AttachedClientsForSession returns the number of clients in this
581// workspace whose currentSessionID equals sessionID and which have at
582// least one live SSE stream. Hold-only clients (streams == 0) do not
583// contribute. Acquires the workspace's [clientsMu] briefly; the
584// returned count is a point-in-time snapshot.
585func (w *Workspace) AttachedClientsForSession(sessionID string) int {
586 w.clientsMu.Lock()
587 defer w.clientsMu.Unlock()
588 n := 0
589 for _, cs := range w.clients {
590 if cs.streams > 0 && cs.currentSessionID == sessionID {
591 n++
592 }
593 }
594 return n
595}
596
597// GetWorkspaceProto returns the proto representation of a workspace.
598func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
599 ws, err := b.GetWorkspace(id)
600 if err != nil {
601 return proto.Workspace{}, err
602 }
603 return workspaceToProto(ws), nil
604}
605
606// VersionInfo returns server version information.
607func (b *Backend) VersionInfo() proto.VersionInfo {
608 return proto.VersionInfo{
609 Version: version.Version,
610 Commit: version.Commit,
611 BuildID: version.BuildID,
612 GoVersion: runtime.Version(),
613 Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
614 }
615}
616
617// Config returns the server-level configuration.
618func (b *Backend) Config() *config.ConfigStore {
619 return b.cfg
620}
621
622// Shutdown initiates a graceful server shutdown.
623func (b *Backend) Shutdown() {
624 if b.shutdownFn != nil {
625 b.shutdownFn()
626 }
627}
628
629// resolveWorkspaceKey returns a stable canonical form of path suitable
630// for use as a dedup key. It applies filepath.Abs, then attempts
631// filepath.EvalSymlinks; because EvalSymlinks errors on non-existent
632// paths, it falls back to the cleaned absolute path in that case.
633func resolveWorkspaceKey(path string) (string, error) {
634 abs, err := filepath.Abs(path)
635 if err != nil {
636 return "", err
637 }
638 if resolved, err := filepath.EvalSymlinks(abs); err == nil {
639 return resolved, nil
640 }
641 return abs, nil
642}
643
644// validateClientID returns the trimmed UUID string or an error if the
645// input is empty or not a valid UUID.
646func validateClientID(id string) (string, error) {
647 if id == "" {
648 return "", ErrInvalidClientID
649 }
650 if _, err := uuid.Parse(id); err != nil {
651 return "", fmt.Errorf("%w: %v", ErrInvalidClientID, err)
652 }
653 return id, nil
654}
655
656func workspaceToProto(ws *Workspace) proto.Workspace {
657 cfg := ws.Cfg.Config()
658 out := proto.Workspace{
659 ID: ws.ID,
660 Path: ws.Path,
661 YOLO: ws.Cfg.Overrides().SkipPermissionRequests,
662 DataDir: cfg.Options.DataDirectory,
663 Debug: cfg.Options.Debug,
664 Config: cfg,
665 Env: ws.Env,
666 Version: version.Version,
667 }
668 if ws.Skills != nil {
669 out.Skills = skillStatesToProto(ws.Skills.States())
670 }
671 return out
672}
673
674// logFirstWinsMismatch emits a debug line whenever a second
675// CreateWorkspace at the same resolved path arrives with flags that
676// differ from the originating workspace. The existing workspace wins;
677// the incoming flags are silently ignored.
678//
679// The comparison is done against the incoming args as the caller sent
680// them — including empty/zero values — rather than after defaulting.
681// This means that, for example, a second caller who omits DataDir
682// while the first set one will still log the mismatch.
683func logFirstWinsMismatch(existing *Workspace, args proto.Workspace) {
684 existingCfg := existing.Cfg.Config()
685 existingYOLO := existing.Cfg.Overrides().SkipPermissionRequests
686 if existingYOLO == args.YOLO &&
687 existingCfg.Options.Debug == args.Debug &&
688 existingCfg.Options.DataDirectory == args.DataDir &&
689 stringSlicesEqual(existing.Env, args.Env) {
690 return
691 }
692 slog.Debug(
693 "Workspace flag mismatch on duplicate create; first wins",
694 "workspace_id", existing.ID,
695 "path", existing.Path,
696 "existing_yolo", existingYOLO,
697 "requested_yolo", args.YOLO,
698 "existing_debug", existingCfg.Options.Debug,
699 "requested_debug", args.Debug,
700 "existing_data_dir", existingCfg.Options.DataDirectory,
701 "requested_data_dir", args.DataDir,
702 "existing_env", existing.Env,
703 "requested_env", args.Env,
704 )
705}
706
707// stringSlicesEqual reports whether a and b contain the same strings
708// in the same order. nil and empty are treated as equal.
709func stringSlicesEqual(a, b []string) bool {
710 if len(a) != len(b) {
711 return false
712 }
713 for i := range a {
714 if a[i] != b[i] {
715 return false
716 }
717 }
718 return true
719}