1package server
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "net/http"
8
9 "github.com/charmbracelet/crush/internal/backend"
10 "github.com/charmbracelet/crush/internal/proto"
11 "github.com/charmbracelet/crush/internal/session"
12 "github.com/google/uuid"
13)
14
15type controllerV1 struct {
16 backend *backend.Backend
17 server *Server
18}
19
20// handleGetHealth checks server health.
21//
22// @Summary Health check
23// @Tags system
24// @Success 200
25// @Router /health [get]
26func (c *controllerV1) handleGetHealth(w http.ResponseWriter, _ *http.Request) {
27 w.WriteHeader(http.StatusOK)
28}
29
30// handleGetVersion returns server version information.
31//
32// @Summary Get server version
33// @Tags system
34// @Produce json
35// @Success 200 {object} proto.VersionInfo
36// @Router /version [get]
37func (c *controllerV1) handleGetVersion(w http.ResponseWriter, _ *http.Request) {
38 jsonEncode(w, c.backend.VersionInfo())
39}
40
41// handlePostControl sends a control command to the server.
42//
43// @Summary Send server control command
44// @Tags system
45// @Accept json
46// @Param request body proto.ServerControl true "Control command (e.g. shutdown)"
47// @Success 200
48// @Failure 400 {object} proto.Error
49// @Router /control [post]
50func (c *controllerV1) handlePostControl(w http.ResponseWriter, r *http.Request) {
51 var req proto.ServerControl
52 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
53 c.server.logError(r, "Failed to decode request", "error", err)
54 jsonError(w, http.StatusBadRequest, "failed to decode request")
55 return
56 }
57
58 switch req.Command {
59 case "shutdown":
60 c.backend.Shutdown()
61 default:
62 c.server.logError(r, "Unknown command", "command", req.Command)
63 jsonError(w, http.StatusBadRequest, "unknown command")
64 return
65 }
66}
67
68// handleGetConfig returns global server configuration.
69//
70// @Summary Get server config
71// @Tags system
72// @Produce json
73// @Success 200 {object} object
74// @Router /config [get]
75func (c *controllerV1) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
76 jsonEncode(w, c.backend.Config())
77}
78
79// handleGetWorkspaces lists all workspaces.
80//
81// @Summary List workspaces
82// @Tags workspaces
83// @Produce json
84// @Success 200 {array} proto.Workspace
85// @Router /workspaces [get]
86func (c *controllerV1) handleGetWorkspaces(w http.ResponseWriter, _ *http.Request) {
87 jsonEncode(w, c.backend.ListWorkspaces())
88}
89
90// handleGetWorkspace returns a single workspace by ID.
91//
92// @Summary Get workspace
93// @Tags workspaces
94// @Produce json
95// @Param id path string true "Workspace ID"
96// @Success 200 {object} proto.Workspace
97// @Failure 404 {object} proto.Error
98// @Failure 500 {object} proto.Error
99// @Router /workspaces/{id} [get]
100func (c *controllerV1) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
101 id := r.PathValue("id")
102 ws, err := c.backend.GetWorkspaceProto(id)
103 if err != nil {
104 c.handleError(w, r, err)
105 return
106 }
107 jsonEncode(w, ws)
108}
109
110// handlePostWorkspaces creates a new workspace.
111//
112// @Summary Create workspace
113// @Tags workspaces
114// @Accept json
115// @Produce json
116// @Param request body proto.Workspace true "Workspace creation params"
117// @Success 200 {object} proto.Workspace
118// @Failure 400 {object} proto.Error
119// @Failure 500 {object} proto.Error
120// @Router /workspaces [post]
121func (c *controllerV1) handlePostWorkspaces(w http.ResponseWriter, r *http.Request) {
122 var args proto.Workspace
123 if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
124 c.server.logError(r, "Failed to decode request", "error", err)
125 jsonError(w, http.StatusBadRequest, "failed to decode request")
126 return
127 }
128
129 _, result, err := c.backend.CreateWorkspace(args)
130 if err != nil {
131 c.handleError(w, r, err)
132 return
133 }
134 jsonEncode(w, result)
135}
136
137// requireClientID reads the client_id query parameter and validates it
138// as a UUID. On failure it writes a 400 and returns false.
139func (c *controllerV1) requireClientID(w http.ResponseWriter, r *http.Request) (string, bool) {
140 cid := r.URL.Query().Get("client_id")
141 if cid == "" {
142 c.server.logError(r, "Missing client_id query parameter")
143 jsonError(w, http.StatusBadRequest, "client_id is required")
144 return "", false
145 }
146 if _, err := uuid.Parse(cid); err != nil {
147 c.server.logError(r, "Invalid client_id", "error", err)
148 jsonError(w, http.StatusBadRequest, "client_id is not a valid UUID")
149 return "", false
150 }
151 return cid, true
152}
153
154// handlePostWorkspaceCurrentSession records the calling client's
155// current session selection for the workspace. An empty session_id
156// clears the entry (e.g. the client is on the landing screen).
157//
158// @Summary Set current session for a client
159// @Tags workspaces
160// @Accept json
161// @Produce json
162// @Param id path string true "Workspace ID"
163// @Param client_id query string true "Client ID (UUID)"
164// @Param request body proto.CurrentSession true "Current session selection"
165// @Success 200
166// @Failure 400 {object} proto.Error
167// @Failure 404 {object} proto.Error
168// @Router /workspaces/{id}/current-session [post]
169func (c *controllerV1) handlePostWorkspaceCurrentSession(w http.ResponseWriter, r *http.Request) {
170 id := r.PathValue("id")
171 clientID, ok := c.requireClientID(w, r)
172 if !ok {
173 return
174 }
175 var req proto.CurrentSession
176 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
177 c.server.logError(r, "Failed to decode request", "error", err)
178 jsonError(w, http.StatusBadRequest, "failed to decode request")
179 return
180 }
181 if err := c.backend.SetCurrentSession(id, clientID, req.SessionID); err != nil {
182 c.handleError(w, r, err)
183 return
184 }
185}
186
187// handleDeleteWorkspaces deletes a workspace.
188//
189// @Summary Delete workspace
190// @Tags workspaces
191// @Param id path string true "Workspace ID"
192// @Success 200
193// @Failure 404 {object} proto.Error
194// @Router /workspaces/{id} [delete]
195func (c *controllerV1) handleDeleteWorkspaces(w http.ResponseWriter, r *http.Request) {
196 id := r.PathValue("id")
197 clientID, ok := c.requireClientID(w, r)
198 if !ok {
199 return
200 }
201 if err := c.backend.DeleteWorkspace(id, clientID); err != nil {
202 c.handleError(w, r, err)
203 return
204 }
205}
206
207// handleGetWorkspaceConfig returns workspace configuration.
208//
209// @Summary Get workspace config
210// @Tags workspaces
211// @Produce json
212// @Param id path string true "Workspace ID"
213// @Success 200 {object} object
214// @Failure 404 {object} proto.Error
215// @Failure 500 {object} proto.Error
216// @Router /workspaces/{id}/config [get]
217func (c *controllerV1) handleGetWorkspaceConfig(w http.ResponseWriter, r *http.Request) {
218 id := r.PathValue("id")
219 cfg, err := c.backend.GetWorkspaceConfig(id)
220 if err != nil {
221 c.handleError(w, r, err)
222 return
223 }
224 jsonEncode(w, cfg)
225}
226
227// handleGetWorkspaceProviders lists available providers for a workspace.
228//
229// @Summary Get workspace providers
230// @Tags workspaces
231// @Produce json
232// @Param id path string true "Workspace ID"
233// @Success 200 {object} object
234// @Failure 404 {object} proto.Error
235// @Failure 500 {object} proto.Error
236// @Router /workspaces/{id}/providers [get]
237func (c *controllerV1) handleGetWorkspaceProviders(w http.ResponseWriter, r *http.Request) {
238 id := r.PathValue("id")
239 providers, err := c.backend.GetWorkspaceProviders(id)
240 if err != nil {
241 c.handleError(w, r, err)
242 return
243 }
244 jsonEncode(w, providers)
245}
246
247// handleGetWorkspaceEvents streams workspace events as Server-Sent Events.
248//
249// @Summary Stream workspace events (SSE)
250// @Tags workspaces
251// @Produce text/event-stream
252// @Param id path string true "Workspace ID"
253// @Success 200
254// @Failure 404 {object} proto.Error
255// @Failure 500 {object} proto.Error
256// @Router /workspaces/{id}/events [get]
257func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.Request) {
258 flusher := http.NewResponseController(w)
259 id := r.PathValue("id")
260 clientID, ok := c.requireClientID(w, r)
261 if !ok {
262 return
263 }
264 // Subscribe to the event broker BEFORE attaching the client.
265 // AttachClient bumps the stream count that observers use to
266 // detect a live subscriber; subscribing first guarantees that
267 // once a client appears attached, any published event is
268 // delivered rather than dropped on a not-yet-registered stream.
269 events, err := c.backend.SubscribeEvents(r.Context(), id)
270 if err != nil {
271 c.handleError(w, r, err)
272 return
273 }
274 if err := c.backend.AttachClient(id, clientID); err != nil {
275 c.handleError(w, r, err)
276 return
277 }
278 defer c.backend.DetachClient(id, clientID)
279
280 w.Header().Set("Content-Type", "text/event-stream")
281 w.Header().Set("Cache-Control", "no-cache")
282 w.Header().Set("Connection", "keep-alive")
283 // Flush headers immediately so clients see the 200 response
284 // before any events arrive. Without this, a quiet workspace
285 // keeps the client's SubscribeEvents call blocked on the
286 // initial RoundTrip.
287 w.WriteHeader(http.StatusOK)
288 flusher.Flush()
289
290 for {
291 select {
292 case <-r.Context().Done():
293 c.server.logDebug(r, "Stopping event stream")
294 return
295 case ev, ok := <-events:
296 if !ok {
297 return
298 }
299 wrapped := wrapEvent(ev.Payload)
300 if wrapped == nil {
301 continue
302 }
303 data, err := json.Marshal(wrapped)
304 if err != nil {
305 c.server.logError(r, "Failed to marshal event", "error", err)
306 continue
307 }
308
309 fmt.Fprintf(w, "data: %s\n\n", data)
310 flusher.Flush()
311 }
312 }
313}
314
315// handleGetWorkspaceLSPs lists LSP clients for a workspace.
316//
317// @Summary List LSP clients
318// @Tags lsp
319// @Produce json
320// @Param id path string true "Workspace ID"
321// @Success 200 {object} map[string]proto.LSPClientInfo
322// @Failure 404 {object} proto.Error
323// @Failure 500 {object} proto.Error
324// @Router /workspaces/{id}/lsps [get]
325func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Request) {
326 id := r.PathValue("id")
327 states, err := c.backend.GetLSPStates(id)
328 if err != nil {
329 c.handleError(w, r, err)
330 return
331 }
332 result := make(map[string]proto.LSPClientInfo, len(states))
333 for k, v := range states {
334 result[k] = proto.LSPClientInfo{
335 Name: v.Name,
336 State: v.State,
337 Error: v.Error,
338 DiagnosticCount: v.DiagnosticCount,
339 ConnectedAt: v.ConnectedAt,
340 }
341 }
342 jsonEncode(w, result)
343}
344
345// handleGetWorkspaceLSPDiagnostics returns diagnostics for an LSP client.
346//
347// @Summary Get LSP diagnostics
348// @Tags lsp
349// @Produce json
350// @Param id path string true "Workspace ID"
351// @Param lsp path string true "LSP client name"
352// @Success 200 {object} object
353// @Failure 404 {object} proto.Error
354// @Failure 500 {object} proto.Error
355// @Router /workspaces/{id}/lsps/{lsp}/diagnostics [get]
356func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) {
357 id := r.PathValue("id")
358 lspName := r.PathValue("lsp")
359 diagnostics, err := c.backend.GetLSPDiagnostics(id, lspName)
360 if err != nil {
361 c.handleError(w, r, err)
362 return
363 }
364 jsonEncode(w, diagnostics)
365}
366
367// handleGetWorkspaceSessions lists sessions for a workspace.
368//
369// @Summary List sessions
370// @Tags sessions
371// @Produce json
372// @Param id path string true "Workspace ID"
373// @Success 200 {array} proto.Session
374// @Failure 404 {object} proto.Error
375// @Failure 500 {object} proto.Error
376// @Router /workspaces/{id}/sessions [get]
377func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
378 id := r.PathValue("id")
379 sessions, err := c.backend.ListSessions(r.Context(), id)
380 if err != nil {
381 c.handleError(w, r, err)
382 return
383 }
384 ws, _ := c.backend.GetWorkspace(id)
385 result := make([]proto.Session, len(sessions))
386 for i, s := range sessions {
387 result[i] = sessionToProto(s)
388 result[i].IsBusy = isSessionBusy(ws, s.ID)
389 result[i].AttachedClients = attachedClients(ws, s.ID)
390 }
391 jsonEncode(w, result)
392}
393
394// handlePostWorkspaceSessions creates a new session in a workspace.
395//
396// @Summary Create session
397// @Tags sessions
398// @Accept json
399// @Produce json
400// @Param id path string true "Workspace ID"
401// @Param request body proto.Session true "Session creation params (title)"
402// @Success 200 {object} proto.Session
403// @Failure 400 {object} proto.Error
404// @Failure 404 {object} proto.Error
405// @Failure 500 {object} proto.Error
406// @Router /workspaces/{id}/sessions [post]
407func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
408 id := r.PathValue("id")
409
410 var args session.Session
411 if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
412 c.server.logError(r, "Failed to decode request", "error", err)
413 jsonError(w, http.StatusBadRequest, "failed to decode request")
414 return
415 }
416
417 sess, err := c.backend.CreateSession(r.Context(), id, args.Title)
418 if err != nil {
419 c.handleError(w, r, err)
420 return
421 }
422 ws, _ := c.backend.GetWorkspace(id)
423 out := sessionToProto(sess)
424 out.IsBusy = isSessionBusy(ws, sess.ID)
425 out.AttachedClients = attachedClients(ws, sess.ID)
426 jsonEncode(w, out)
427}
428
429// handleGetWorkspaceSession returns a single session.
430//
431// @Summary Get session
432// @Tags sessions
433// @Produce json
434// @Param id path string true "Workspace ID"
435// @Param sid path string true "Session ID"
436// @Success 200 {object} proto.Session
437// @Failure 404 {object} proto.Error
438// @Failure 500 {object} proto.Error
439// @Router /workspaces/{id}/sessions/{sid} [get]
440func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.Request) {
441 id := r.PathValue("id")
442 sid := r.PathValue("sid")
443 sess, err := c.backend.GetSession(r.Context(), id, sid)
444 if err != nil {
445 c.handleError(w, r, err)
446 return
447 }
448 ws, _ := c.backend.GetWorkspace(id)
449 out := sessionToProto(sess)
450 out.IsBusy = isSessionBusy(ws, sess.ID)
451 out.AttachedClients = attachedClients(ws, sess.ID)
452 jsonEncode(w, out)
453}
454
455// handleGetWorkspaceSessionHistory returns the history for a session.
456//
457// @Summary Get session history
458// @Tags sessions
459// @Produce json
460// @Param id path string true "Workspace ID"
461// @Param sid path string true "Session ID"
462// @Success 200 {array} proto.File
463// @Failure 404 {object} proto.Error
464// @Failure 500 {object} proto.Error
465// @Router /workspaces/{id}/sessions/{sid}/history [get]
466func (c *controllerV1) handleGetWorkspaceSessionHistory(w http.ResponseWriter, r *http.Request) {
467 id := r.PathValue("id")
468 sid := r.PathValue("sid")
469 history, err := c.backend.ListSessionHistory(r.Context(), id, sid)
470 if err != nil {
471 c.handleError(w, r, err)
472 return
473 }
474 jsonEncode(w, history)
475}
476
477// handleGetWorkspaceSessionMessages returns all messages for a session.
478//
479// @Summary Get session messages
480// @Tags sessions
481// @Produce json
482// @Param id path string true "Workspace ID"
483// @Param sid path string true "Session ID"
484// @Success 200 {array} proto.Message
485// @Failure 404 {object} proto.Error
486// @Failure 500 {object} proto.Error
487// @Router /workspaces/{id}/sessions/{sid}/messages [get]
488func (c *controllerV1) handleGetWorkspaceSessionMessages(w http.ResponseWriter, r *http.Request) {
489 id := r.PathValue("id")
490 sid := r.PathValue("sid")
491 messages, err := c.backend.ListSessionMessages(r.Context(), id, sid)
492 if err != nil {
493 c.handleError(w, r, err)
494 return
495 }
496 jsonEncode(w, messagesToProto(messages))
497}
498
499// handlePutWorkspaceSession updates a session.
500//
501// @Summary Update session
502// @Tags sessions
503// @Accept json
504// @Produce json
505// @Param id path string true "Workspace ID"
506// @Param sid path string true "Session ID"
507// @Param request body proto.Session true "Updated session"
508// @Success 200 {object} proto.Session
509// @Failure 400 {object} proto.Error
510// @Failure 404 {object} proto.Error
511// @Failure 500 {object} proto.Error
512// @Router /workspaces/{id}/sessions/{sid} [put]
513func (c *controllerV1) handlePutWorkspaceSession(w http.ResponseWriter, r *http.Request) {
514 id := r.PathValue("id")
515
516 var sess session.Session
517 if err := json.NewDecoder(r.Body).Decode(&sess); err != nil {
518 c.server.logError(r, "Failed to decode request", "error", err)
519 jsonError(w, http.StatusBadRequest, "failed to decode request")
520 return
521 }
522
523 saved, err := c.backend.SaveSession(r.Context(), id, sess)
524 if err != nil {
525 c.handleError(w, r, err)
526 return
527 }
528 ws, _ := c.backend.GetWorkspace(id)
529 out := sessionToProto(saved)
530 out.IsBusy = isSessionBusy(ws, saved.ID)
531 out.AttachedClients = attachedClients(ws, saved.ID)
532 jsonEncode(w, out)
533}
534
535// handleDeleteWorkspaceSession deletes a session.
536//
537// @Summary Delete session
538// @Tags sessions
539// @Param id path string true "Workspace ID"
540// @Param sid path string true "Session ID"
541// @Success 200
542// @Failure 404 {object} proto.Error
543// @Failure 500 {object} proto.Error
544// @Router /workspaces/{id}/sessions/{sid} [delete]
545func (c *controllerV1) handleDeleteWorkspaceSession(w http.ResponseWriter, r *http.Request) {
546 id := r.PathValue("id")
547 sid := r.PathValue("sid")
548 if err := c.backend.DeleteSession(r.Context(), id, sid); err != nil {
549 c.handleError(w, r, err)
550 return
551 }
552 w.WriteHeader(http.StatusOK)
553}
554
555// handleGetWorkspaceSessionUserMessages returns user messages for a session.
556//
557// @Summary Get user messages for session
558// @Tags sessions
559// @Produce json
560// @Param id path string true "Workspace ID"
561// @Param sid path string true "Session ID"
562// @Success 200 {array} proto.Message
563// @Failure 404 {object} proto.Error
564// @Failure 500 {object} proto.Error
565// @Router /workspaces/{id}/sessions/{sid}/messages/user [get]
566func (c *controllerV1) handleGetWorkspaceSessionUserMessages(w http.ResponseWriter, r *http.Request) {
567 id := r.PathValue("id")
568 sid := r.PathValue("sid")
569 messages, err := c.backend.ListUserMessages(r.Context(), id, sid)
570 if err != nil {
571 c.handleError(w, r, err)
572 return
573 }
574 jsonEncode(w, messagesToProto(messages))
575}
576
577// handleGetWorkspaceAllUserMessages returns all user messages across sessions.
578//
579// @Summary Get all user messages for workspace
580// @Tags workspaces
581// @Produce json
582// @Param id path string true "Workspace ID"
583// @Success 200 {array} proto.Message
584// @Failure 404 {object} proto.Error
585// @Failure 500 {object} proto.Error
586// @Router /workspaces/{id}/messages/user [get]
587func (c *controllerV1) handleGetWorkspaceAllUserMessages(w http.ResponseWriter, r *http.Request) {
588 id := r.PathValue("id")
589 messages, err := c.backend.ListAllUserMessages(r.Context(), id)
590 if err != nil {
591 c.handleError(w, r, err)
592 return
593 }
594 jsonEncode(w, messagesToProto(messages))
595}
596
597// handleGetWorkspaceSessionFileTrackerFiles lists files read in a session.
598//
599// @Summary List tracked files for session
600// @Tags filetracker
601// @Produce json
602// @Param id path string true "Workspace ID"
603// @Param sid path string true "Session ID"
604// @Success 200 {array} string
605// @Failure 404 {object} proto.Error
606// @Failure 500 {object} proto.Error
607// @Router /workspaces/{id}/sessions/{sid}/filetracker/files [get]
608func (c *controllerV1) handleGetWorkspaceSessionFileTrackerFiles(w http.ResponseWriter, r *http.Request) {
609 id := r.PathValue("id")
610 sid := r.PathValue("sid")
611 files, err := c.backend.FileTrackerListReadFiles(r.Context(), id, sid)
612 if err != nil {
613 c.handleError(w, r, err)
614 return
615 }
616 jsonEncode(w, files)
617}
618
619// handlePostWorkspaceFileTrackerRead records a file read event.
620//
621// @Summary Record file read
622// @Tags filetracker
623// @Accept json
624// @Param id path string true "Workspace ID"
625// @Param request body proto.FileTrackerReadRequest true "File tracker read request"
626// @Success 200
627// @Failure 400 {object} proto.Error
628// @Failure 404 {object} proto.Error
629// @Failure 500 {object} proto.Error
630// @Router /workspaces/{id}/filetracker/read [post]
631func (c *controllerV1) handlePostWorkspaceFileTrackerRead(w http.ResponseWriter, r *http.Request) {
632 id := r.PathValue("id")
633
634 var req proto.FileTrackerReadRequest
635 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
636 c.server.logError(r, "Failed to decode request", "error", err)
637 jsonError(w, http.StatusBadRequest, "failed to decode request")
638 return
639 }
640
641 if err := c.backend.FileTrackerRecordRead(r.Context(), id, req.SessionID, req.Path); err != nil {
642 c.handleError(w, r, err)
643 return
644 }
645 w.WriteHeader(http.StatusOK)
646}
647
648// handleGetWorkspaceFileTrackerLastRead returns the last read time for a file.
649//
650// @Summary Get last read time for file
651// @Tags filetracker
652// @Produce json
653// @Param id path string true "Workspace ID"
654// @Param session_id query string false "Session ID"
655// @Param path query string true "File path"
656// @Success 200 {object} object
657// @Failure 404 {object} proto.Error
658// @Failure 500 {object} proto.Error
659// @Router /workspaces/{id}/filetracker/lastread [get]
660func (c *controllerV1) handleGetWorkspaceFileTrackerLastRead(w http.ResponseWriter, r *http.Request) {
661 id := r.PathValue("id")
662 sid := r.URL.Query().Get("session_id")
663 path := r.URL.Query().Get("path")
664
665 t, err := c.backend.FileTrackerLastReadTime(r.Context(), id, sid, path)
666 if err != nil {
667 c.handleError(w, r, err)
668 return
669 }
670 jsonEncode(w, t)
671}
672
673// handlePostWorkspaceLSPStart starts an LSP server for a path.
674//
675// @Summary Start LSP server
676// @Tags lsp
677// @Accept json
678// @Param id path string true "Workspace ID"
679// @Param request body proto.LSPStartRequest true "LSP start request"
680// @Success 200
681// @Failure 400 {object} proto.Error
682// @Failure 404 {object} proto.Error
683// @Failure 500 {object} proto.Error
684// @Router /workspaces/{id}/lsps/start [post]
685func (c *controllerV1) handlePostWorkspaceLSPStart(w http.ResponseWriter, r *http.Request) {
686 id := r.PathValue("id")
687
688 var req proto.LSPStartRequest
689 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
690 c.server.logError(r, "Failed to decode request", "error", err)
691 jsonError(w, http.StatusBadRequest, "failed to decode request")
692 return
693 }
694
695 if err := c.backend.LSPStart(r.Context(), id, req.Path); err != nil {
696 c.handleError(w, r, err)
697 return
698 }
699 w.WriteHeader(http.StatusOK)
700}
701
702// handlePostWorkspaceLSPStopAll stops all LSP servers.
703//
704// @Summary Stop all LSP servers
705// @Tags lsp
706// @Param id path string true "Workspace ID"
707// @Success 200
708// @Failure 404 {object} proto.Error
709// @Failure 500 {object} proto.Error
710// @Router /workspaces/{id}/lsps/stop [post]
711func (c *controllerV1) handlePostWorkspaceLSPStopAll(w http.ResponseWriter, r *http.Request) {
712 id := r.PathValue("id")
713 if err := c.backend.LSPStopAll(r.Context(), id); err != nil {
714 c.handleError(w, r, err)
715 return
716 }
717 w.WriteHeader(http.StatusOK)
718}
719
720// handleGetWorkspaceAgent returns agent info for a workspace.
721//
722// @Summary Get agent info
723// @Tags agent
724// @Produce json
725// @Param id path string true "Workspace ID"
726// @Success 200 {object} proto.AgentInfo
727// @Failure 404 {object} proto.Error
728// @Failure 500 {object} proto.Error
729// @Router /workspaces/{id}/agent [get]
730func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
731 id := r.PathValue("id")
732 info, err := c.backend.GetAgentInfo(id)
733 if err != nil {
734 c.handleError(w, r, err)
735 return
736 }
737 jsonEncode(w, info)
738}
739
740// handlePostWorkspaceAgent sends a message to the agent.
741//
742// @Summary Send message to agent
743// @Tags agent
744// @Accept json
745// @Param id path string true "Workspace ID"
746// @Param request body proto.AgentMessage true "Agent message"
747// @Success 202
748// @Failure 400 {object} proto.Error
749// @Failure 404 {object} proto.Error
750// @Failure 409 {object} proto.Error
751// @Failure 500 {object} proto.Error
752// @Router /workspaces/{id}/agent [post]
753func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
754 id := r.PathValue("id")
755
756 var msg proto.AgentMessage
757 if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
758 c.server.logError(r, "Failed to decode request", "error", err)
759 jsonError(w, http.StatusBadRequest, "failed to decode request")
760 return
761 }
762
763 // The run's lifetime is detached from the prompting client's HTTP
764 // request: SendMessage validates and accepts the prompt, dispatches
765 // the run on a goroutine bound to the workspace context, and returns
766 // immediately. A dropping its TCP connection (network blip, TUI
767 // restart) or B canceling the session via the explicit cancel
768 // endpoint can no longer tear down a turn that other subscribed
769 // clients are still watching. Only the explicit cancel endpoint
770 // should be able to end a run.
771 if err := c.backend.SendMessage(id, msg); err != nil {
772 c.handleError(w, r, err)
773 return
774 }
775 w.WriteHeader(http.StatusAccepted)
776}
777
778// handlePostWorkspaceAgentInit initializes the agent for a workspace.
779//
780// @Summary Initialize agent
781// @Tags agent
782// @Param id path string true "Workspace ID"
783// @Success 200
784// @Failure 404 {object} proto.Error
785// @Failure 500 {object} proto.Error
786// @Router /workspaces/{id}/agent/init [post]
787func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
788 id := r.PathValue("id")
789 if err := c.backend.InitAgent(r.Context(), id); err != nil {
790 c.handleError(w, r, err)
791 return
792 }
793 w.WriteHeader(http.StatusOK)
794}
795
796// handlePostWorkspaceAgentUpdate updates the agent for a workspace.
797//
798// @Summary Update agent
799// @Tags agent
800// @Param id path string true "Workspace ID"
801// @Success 200
802// @Failure 404 {object} proto.Error
803// @Failure 500 {object} proto.Error
804// @Router /workspaces/{id}/agent/update [post]
805func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) {
806 id := r.PathValue("id")
807 if err := c.backend.UpdateAgent(r.Context(), id); err != nil {
808 c.handleError(w, r, err)
809 return
810 }
811 w.WriteHeader(http.StatusOK)
812}
813
814// handleGetWorkspaceAgentSession returns a specific agent session.
815//
816// @Summary Get agent session
817// @Tags agent
818// @Produce json
819// @Param id path string true "Workspace ID"
820// @Param sid path string true "Session ID"
821// @Success 200 {object} proto.AgentSession
822// @Failure 404 {object} proto.Error
823// @Failure 500 {object} proto.Error
824// @Router /workspaces/{id}/agent/sessions/{sid} [get]
825func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) {
826 id := r.PathValue("id")
827 sid := r.PathValue("sid")
828 agentSession, err := c.backend.GetAgentSession(r.Context(), id, sid)
829 if err != nil {
830 c.handleError(w, r, err)
831 return
832 }
833 jsonEncode(w, agentSession)
834}
835
836// handlePostWorkspaceAgentSessionCancel cancels a running agent session.
837//
838// @Summary Cancel agent session
839// @Tags agent
840// @Param id path string true "Workspace ID"
841// @Param sid path string true "Session ID"
842// @Success 200
843// @Failure 404 {object} proto.Error
844// @Failure 500 {object} proto.Error
845// @Router /workspaces/{id}/agent/sessions/{sid}/cancel [post]
846func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) {
847 id := r.PathValue("id")
848 sid := r.PathValue("sid")
849 if err := c.backend.CancelSession(id, sid); err != nil {
850 c.handleError(w, r, err)
851 return
852 }
853 w.WriteHeader(http.StatusOK)
854}
855
856// handleGetWorkspaceAgentSessionPromptQueued returns whether a queued prompt exists.
857//
858// @Summary Get queued prompt status
859// @Tags agent
860// @Produce json
861// @Param id path string true "Workspace ID"
862// @Param sid path string true "Session ID"
863// @Success 200 {object} object
864// @Failure 404 {object} proto.Error
865// @Failure 500 {object} proto.Error
866// @Router /workspaces/{id}/agent/sessions/{sid}/prompts/queued [get]
867func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) {
868 id := r.PathValue("id")
869 sid := r.PathValue("sid")
870 queued, err := c.backend.QueuedPrompts(id, sid)
871 if err != nil {
872 c.handleError(w, r, err)
873 return
874 }
875 jsonEncode(w, queued)
876}
877
878// handlePostWorkspaceAgentSessionPromptClear clears the prompt queue for a session.
879//
880// @Summary Clear prompt queue
881// @Tags agent
882// @Param id path string true "Workspace ID"
883// @Param sid path string true "Session ID"
884// @Success 200
885// @Failure 404 {object} proto.Error
886// @Failure 500 {object} proto.Error
887// @Router /workspaces/{id}/agent/sessions/{sid}/prompts/clear [post]
888func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) {
889 id := r.PathValue("id")
890 sid := r.PathValue("sid")
891 if err := c.backend.ClearQueue(id, sid); err != nil {
892 c.handleError(w, r, err)
893 return
894 }
895 w.WriteHeader(http.StatusOK)
896}
897
898// handlePostWorkspaceAgentSessionSummarize summarizes a session.
899//
900// @Summary Summarize session
901// @Tags agent
902// @Param id path string true "Workspace ID"
903// @Param sid path string true "Session ID"
904// @Success 200
905// @Failure 404 {object} proto.Error
906// @Failure 500 {object} proto.Error
907// @Router /workspaces/{id}/agent/sessions/{sid}/summarize [post]
908func (c *controllerV1) handlePostWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
909 id := r.PathValue("id")
910 sid := r.PathValue("sid")
911 if err := c.backend.SummarizeSession(r.Context(), id, sid); err != nil {
912 c.handleError(w, r, err)
913 return
914 }
915 w.WriteHeader(http.StatusOK)
916}
917
918// handleGetWorkspaceAgentSessionPromptList returns the list of queued prompts.
919//
920// @Summary List queued prompts
921// @Tags agent
922// @Produce json
923// @Param id path string true "Workspace ID"
924// @Param sid path string true "Session ID"
925// @Success 200 {array} string
926// @Failure 404 {object} proto.Error
927// @Failure 500 {object} proto.Error
928// @Router /workspaces/{id}/agent/sessions/{sid}/prompts/list [get]
929func (c *controllerV1) handleGetWorkspaceAgentSessionPromptList(w http.ResponseWriter, r *http.Request) {
930 id := r.PathValue("id")
931 sid := r.PathValue("sid")
932 prompts, err := c.backend.QueuedPromptsList(id, sid)
933 if err != nil {
934 c.handleError(w, r, err)
935 return
936 }
937 jsonEncode(w, prompts)
938}
939
940// handleGetWorkspaceAgentDefaultSmallModel returns the default small model for a provider.
941//
942// @Summary Get default small model
943// @Tags agent
944// @Produce json
945// @Param id path string true "Workspace ID"
946// @Param provider_id query string false "Provider ID"
947// @Success 200 {object} object
948// @Failure 404 {object} proto.Error
949// @Failure 500 {object} proto.Error
950// @Router /workspaces/{id}/agent/default-small-model [get]
951func (c *controllerV1) handleGetWorkspaceAgentDefaultSmallModel(w http.ResponseWriter, r *http.Request) {
952 id := r.PathValue("id")
953 providerID := r.URL.Query().Get("provider_id")
954 model, err := c.backend.GetDefaultSmallModel(id, providerID)
955 if err != nil {
956 c.handleError(w, r, err)
957 return
958 }
959 jsonEncode(w, model)
960}
961
962// handlePostWorkspacePermissionsGrant grants a permission request.
963//
964// @Summary Grant permission
965// @Tags permissions
966// @Accept json
967// @Param id path string true "Workspace ID"
968// @Param request body proto.PermissionGrant true "Permission grant"
969// @Success 200 {object} proto.PermissionGrantResponse
970// @Failure 400 {object} proto.Error
971// @Failure 404 {object} proto.Error
972// @Failure 500 {object} proto.Error
973// @Router /workspaces/{id}/permissions/grant [post]
974func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) {
975 id := r.PathValue("id")
976
977 var req proto.PermissionGrant
978 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
979 c.server.logError(r, "Failed to decode request", "error", err)
980 jsonError(w, http.StatusBadRequest, "failed to decode request")
981 return
982 }
983
984 resolved, err := c.backend.GrantPermission(id, req)
985 if err != nil {
986 c.handleError(w, r, err)
987 return
988 }
989 jsonEncode(w, proto.PermissionGrantResponse{Resolved: resolved})
990}
991
992// handlePostWorkspacePermissionsSkip sets whether to skip permission prompts.
993//
994// @Summary Set skip permissions
995// @Tags permissions
996// @Accept json
997// @Param id path string true "Workspace ID"
998// @Param request body proto.PermissionSkipRequest true "Permission skip request"
999// @Success 200
1000// @Failure 400 {object} proto.Error
1001// @Failure 404 {object} proto.Error
1002// @Failure 500 {object} proto.Error
1003// @Router /workspaces/{id}/permissions/skip [post]
1004func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
1005 id := r.PathValue("id")
1006
1007 var req proto.PermissionSkipRequest
1008 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1009 c.server.logError(r, "Failed to decode request", "error", err)
1010 jsonError(w, http.StatusBadRequest, "failed to decode request")
1011 return
1012 }
1013
1014 if err := c.backend.SetPermissionsSkip(id, req.Skip); err != nil {
1015 c.handleError(w, r, err)
1016 return
1017 }
1018}
1019
1020// handleGetWorkspacePermissionsSkip returns whether permission prompts are skipped.
1021//
1022// @Summary Get skip permissions status
1023// @Tags permissions
1024// @Produce json
1025// @Param id path string true "Workspace ID"
1026// @Success 200 {object} proto.PermissionSkipRequest
1027// @Failure 404 {object} proto.Error
1028// @Failure 500 {object} proto.Error
1029// @Router /workspaces/{id}/permissions/skip [get]
1030func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
1031 id := r.PathValue("id")
1032 skip, err := c.backend.GetPermissionsSkip(id)
1033 if err != nil {
1034 c.handleError(w, r, err)
1035 return
1036 }
1037 jsonEncode(w, proto.PermissionSkipRequest{Skip: skip})
1038}
1039
1040// handleError maps backend errors to HTTP status codes and writes the
1041// JSON error response.
1042//
1043// Runtime cancellation of an agent run no longer reaches here for the
1044// agent-prompt path: SendMessage is fire-and-forget (the handler returns
1045// 202 before the run starts) and Backend.runAgent swallows
1046// context.Canceled, surfacing the FinishReasonCanceled marker to SSE
1047// subscribers instead. The remaining callers pass synchronous backend
1048// errors, so context.Canceled gets no special case and would fall through
1049// to the default 500 like any other unexpected error.
1050func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err error) {
1051 status := http.StatusInternalServerError
1052 switch {
1053 case errors.Is(err, backend.ErrWorkspaceNotFound):
1054 status = http.StatusNotFound
1055 case errors.Is(err, backend.ErrLSPClientNotFound):
1056 status = http.StatusNotFound
1057 case errors.Is(err, backend.ErrAgentNotInitialized):
1058 status = http.StatusBadRequest
1059 case errors.Is(err, backend.ErrPathRequired):
1060 status = http.StatusBadRequest
1061 case errors.Is(err, backend.ErrInvalidPermissionAction):
1062 status = http.StatusBadRequest
1063 case errors.Is(err, backend.ErrUnknownCommand):
1064 status = http.StatusBadRequest
1065 case errors.Is(err, backend.ErrInvalidClientID):
1066 status = http.StatusBadRequest
1067 case errors.Is(err, backend.ErrClientNotAttached):
1068 status = http.StatusNotFound
1069 case errors.Is(err, backend.ErrWorkspaceClosing):
1070 status = http.StatusConflict
1071 }
1072 c.server.logError(r, err.Error())
1073 jsonError(w, status, err.Error())
1074}
1075
1076func jsonEncode(w http.ResponseWriter, v any) {
1077 w.Header().Set("Content-Type", "application/json")
1078 _ = json.NewEncoder(w).Encode(v)
1079}
1080
1081func jsonError(w http.ResponseWriter, status int, message string) {
1082 w.Header().Set("Content-Type", "application/json")
1083 w.WriteHeader(status)
1084 _ = json.NewEncoder(w).Encode(proto.Error{Message: message})
1085}