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 200
744// @Failure 400 {object} proto.Error
745// @Failure 404 {object} proto.Error
746// @Failure 500 {object} proto.Error
747// @Router /workspaces/{id}/agent [post]
748func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
749 id := r.PathValue("id")
750
751 var msg proto.AgentMessage
752 if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
753 c.server.logError(r, "Failed to decode request", "error", err)
754 jsonError(w, http.StatusBadRequest, "failed to decode request")
755 return
756 }
757
758 // The run's lifetime is detached from the prompting client's HTTP
759 // request: SendMessage validates and accepts the prompt, dispatches
760 // the run on a goroutine bound to the workspace context, and returns
761 // immediately. A dropping its TCP connection (network blip, TUI
762 // restart) or B canceling the session via the explicit cancel
763 // endpoint can no longer tear down a turn that other subscribed
764 // clients are still watching. Only the explicit cancel endpoint
765 // should be able to end a run.
766 if err := c.backend.SendMessage(id, msg); err != nil {
767 c.handleError(w, r, err)
768 return
769 }
770 w.WriteHeader(http.StatusOK)
771}
772
773// handlePostWorkspaceAgentInit initializes the agent for a workspace.
774//
775// @Summary Initialize agent
776// @Tags agent
777// @Param id path string true "Workspace ID"
778// @Success 200
779// @Failure 404 {object} proto.Error
780// @Failure 500 {object} proto.Error
781// @Router /workspaces/{id}/agent/init [post]
782func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
783 id := r.PathValue("id")
784 if err := c.backend.InitAgent(r.Context(), id); err != nil {
785 c.handleError(w, r, err)
786 return
787 }
788 w.WriteHeader(http.StatusOK)
789}
790
791// handlePostWorkspaceAgentUpdate updates the agent for a workspace.
792//
793// @Summary Update agent
794// @Tags agent
795// @Param id path string true "Workspace ID"
796// @Success 200
797// @Failure 404 {object} proto.Error
798// @Failure 500 {object} proto.Error
799// @Router /workspaces/{id}/agent/update [post]
800func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) {
801 id := r.PathValue("id")
802 if err := c.backend.UpdateAgent(r.Context(), id); err != nil {
803 c.handleError(w, r, err)
804 return
805 }
806 w.WriteHeader(http.StatusOK)
807}
808
809// handleGetWorkspaceAgentSession returns a specific agent session.
810//
811// @Summary Get agent session
812// @Tags agent
813// @Produce json
814// @Param id path string true "Workspace ID"
815// @Param sid path string true "Session ID"
816// @Success 200 {object} proto.AgentSession
817// @Failure 404 {object} proto.Error
818// @Failure 500 {object} proto.Error
819// @Router /workspaces/{id}/agent/sessions/{sid} [get]
820func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) {
821 id := r.PathValue("id")
822 sid := r.PathValue("sid")
823 agentSession, err := c.backend.GetAgentSession(r.Context(), id, sid)
824 if err != nil {
825 c.handleError(w, r, err)
826 return
827 }
828 jsonEncode(w, agentSession)
829}
830
831// handlePostWorkspaceAgentSessionCancel cancels a running agent session.
832//
833// @Summary Cancel agent session
834// @Tags agent
835// @Param id path string true "Workspace ID"
836// @Param sid path string true "Session ID"
837// @Success 200
838// @Failure 404 {object} proto.Error
839// @Failure 500 {object} proto.Error
840// @Router /workspaces/{id}/agent/sessions/{sid}/cancel [post]
841func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) {
842 id := r.PathValue("id")
843 sid := r.PathValue("sid")
844 if err := c.backend.CancelSession(id, sid); err != nil {
845 c.handleError(w, r, err)
846 return
847 }
848 w.WriteHeader(http.StatusOK)
849}
850
851// handleGetWorkspaceAgentSessionPromptQueued returns whether a queued prompt exists.
852//
853// @Summary Get queued prompt status
854// @Tags agent
855// @Produce json
856// @Param id path string true "Workspace ID"
857// @Param sid path string true "Session ID"
858// @Success 200 {object} object
859// @Failure 404 {object} proto.Error
860// @Failure 500 {object} proto.Error
861// @Router /workspaces/{id}/agent/sessions/{sid}/prompts/queued [get]
862func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) {
863 id := r.PathValue("id")
864 sid := r.PathValue("sid")
865 queued, err := c.backend.QueuedPrompts(id, sid)
866 if err != nil {
867 c.handleError(w, r, err)
868 return
869 }
870 jsonEncode(w, queued)
871}
872
873// handlePostWorkspaceAgentSessionPromptClear clears the prompt queue for a session.
874//
875// @Summary Clear prompt queue
876// @Tags agent
877// @Param id path string true "Workspace ID"
878// @Param sid path string true "Session ID"
879// @Success 200
880// @Failure 404 {object} proto.Error
881// @Failure 500 {object} proto.Error
882// @Router /workspaces/{id}/agent/sessions/{sid}/prompts/clear [post]
883func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) {
884 id := r.PathValue("id")
885 sid := r.PathValue("sid")
886 if err := c.backend.ClearQueue(id, sid); err != nil {
887 c.handleError(w, r, err)
888 return
889 }
890 w.WriteHeader(http.StatusOK)
891}
892
893// handlePostWorkspaceAgentSessionSummarize summarizes a session.
894//
895// @Summary Summarize session
896// @Tags agent
897// @Param id path string true "Workspace ID"
898// @Param sid path string true "Session ID"
899// @Success 200
900// @Failure 404 {object} proto.Error
901// @Failure 500 {object} proto.Error
902// @Router /workspaces/{id}/agent/sessions/{sid}/summarize [post]
903func (c *controllerV1) handlePostWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
904 id := r.PathValue("id")
905 sid := r.PathValue("sid")
906 if err := c.backend.SummarizeSession(r.Context(), id, sid); err != nil {
907 c.handleError(w, r, err)
908 return
909 }
910 w.WriteHeader(http.StatusOK)
911}
912
913// handleGetWorkspaceAgentSessionPromptList returns the list of queued prompts.
914//
915// @Summary List queued prompts
916// @Tags agent
917// @Produce json
918// @Param id path string true "Workspace ID"
919// @Param sid path string true "Session ID"
920// @Success 200 {array} string
921// @Failure 404 {object} proto.Error
922// @Failure 500 {object} proto.Error
923// @Router /workspaces/{id}/agent/sessions/{sid}/prompts/list [get]
924func (c *controllerV1) handleGetWorkspaceAgentSessionPromptList(w http.ResponseWriter, r *http.Request) {
925 id := r.PathValue("id")
926 sid := r.PathValue("sid")
927 prompts, err := c.backend.QueuedPromptsList(id, sid)
928 if err != nil {
929 c.handleError(w, r, err)
930 return
931 }
932 jsonEncode(w, prompts)
933}
934
935// handleGetWorkspaceAgentDefaultSmallModel returns the default small model for a provider.
936//
937// @Summary Get default small model
938// @Tags agent
939// @Produce json
940// @Param id path string true "Workspace ID"
941// @Param provider_id query string false "Provider ID"
942// @Success 200 {object} object
943// @Failure 404 {object} proto.Error
944// @Failure 500 {object} proto.Error
945// @Router /workspaces/{id}/agent/default-small-model [get]
946func (c *controllerV1) handleGetWorkspaceAgentDefaultSmallModel(w http.ResponseWriter, r *http.Request) {
947 id := r.PathValue("id")
948 providerID := r.URL.Query().Get("provider_id")
949 model, err := c.backend.GetDefaultSmallModel(id, providerID)
950 if err != nil {
951 c.handleError(w, r, err)
952 return
953 }
954 jsonEncode(w, model)
955}
956
957// handlePostWorkspacePermissionsGrant grants a permission request.
958//
959// @Summary Grant permission
960// @Tags permissions
961// @Accept json
962// @Param id path string true "Workspace ID"
963// @Param request body proto.PermissionGrant true "Permission grant"
964// @Success 200 {object} proto.PermissionGrantResponse
965// @Failure 400 {object} proto.Error
966// @Failure 404 {object} proto.Error
967// @Failure 500 {object} proto.Error
968// @Router /workspaces/{id}/permissions/grant [post]
969func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) {
970 id := r.PathValue("id")
971
972 var req proto.PermissionGrant
973 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
974 c.server.logError(r, "Failed to decode request", "error", err)
975 jsonError(w, http.StatusBadRequest, "failed to decode request")
976 return
977 }
978
979 resolved, err := c.backend.GrantPermission(id, req)
980 if err != nil {
981 c.handleError(w, r, err)
982 return
983 }
984 jsonEncode(w, proto.PermissionGrantResponse{Resolved: resolved})
985}
986
987// handlePostWorkspacePermissionsSkip sets whether to skip permission prompts.
988//
989// @Summary Set skip permissions
990// @Tags permissions
991// @Accept json
992// @Param id path string true "Workspace ID"
993// @Param request body proto.PermissionSkipRequest true "Permission skip request"
994// @Success 200
995// @Failure 400 {object} proto.Error
996// @Failure 404 {object} proto.Error
997// @Failure 500 {object} proto.Error
998// @Router /workspaces/{id}/permissions/skip [post]
999func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
1000 id := r.PathValue("id")
1001
1002 var req proto.PermissionSkipRequest
1003 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1004 c.server.logError(r, "Failed to decode request", "error", err)
1005 jsonError(w, http.StatusBadRequest, "failed to decode request")
1006 return
1007 }
1008
1009 if err := c.backend.SetPermissionsSkip(id, req.Skip); err != nil {
1010 c.handleError(w, r, err)
1011 return
1012 }
1013}
1014
1015// handleGetWorkspacePermissionsSkip returns whether permission prompts are skipped.
1016//
1017// @Summary Get skip permissions status
1018// @Tags permissions
1019// @Produce json
1020// @Param id path string true "Workspace ID"
1021// @Success 200 {object} proto.PermissionSkipRequest
1022// @Failure 404 {object} proto.Error
1023// @Failure 500 {object} proto.Error
1024// @Router /workspaces/{id}/permissions/skip [get]
1025func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
1026 id := r.PathValue("id")
1027 skip, err := c.backend.GetPermissionsSkip(id)
1028 if err != nil {
1029 c.handleError(w, r, err)
1030 return
1031 }
1032 jsonEncode(w, proto.PermissionSkipRequest{Skip: skip})
1033}
1034
1035// handleError maps backend errors to HTTP status codes and writes the
1036// JSON error response.
1037func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err error) {
1038 // A canceled agent run is not an error from the prompting
1039 // client's perspective. The cancellation reaches every SSE
1040 // subscriber via the FinishReasonCanceled marker on the assistant
1041 // message; the still-open POST should not surface a 500.
1042 if errors.Is(err, context.Canceled) {
1043 w.WriteHeader(http.StatusOK)
1044 return
1045 }
1046 status := http.StatusInternalServerError
1047 switch {
1048 case errors.Is(err, backend.ErrWorkspaceNotFound):
1049 status = http.StatusNotFound
1050 case errors.Is(err, backend.ErrLSPClientNotFound):
1051 status = http.StatusNotFound
1052 case errors.Is(err, backend.ErrAgentNotInitialized):
1053 status = http.StatusBadRequest
1054 case errors.Is(err, backend.ErrPathRequired):
1055 status = http.StatusBadRequest
1056 case errors.Is(err, backend.ErrInvalidPermissionAction):
1057 status = http.StatusBadRequest
1058 case errors.Is(err, backend.ErrUnknownCommand):
1059 status = http.StatusBadRequest
1060 case errors.Is(err, backend.ErrInvalidClientID):
1061 status = http.StatusBadRequest
1062 case errors.Is(err, backend.ErrClientNotAttached):
1063 status = http.StatusNotFound
1064 }
1065 c.server.logError(r, err.Error())
1066 jsonError(w, status, err.Error())
1067}
1068
1069func jsonEncode(w http.ResponseWriter, v any) {
1070 w.Header().Set("Content-Type", "application/json")
1071 _ = json.NewEncoder(w).Encode(v)
1072}
1073
1074func jsonError(w http.ResponseWriter, status int, message string) {
1075 w.Header().Set("Content-Type", "application/json")
1076 w.WriteHeader(status)
1077 _ = json.NewEncoder(w).Encode(proto.Error{Message: message})
1078}