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}