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