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 if err := c.backend.AttachClient(id, clientID); err != nil {
265 c.handleError(w, r, err)
266 return
267 }
268 defer c.backend.DetachClient(id, clientID)
269 events, err := c.backend.SubscribeEvents(r.Context(), id)
270 if err != nil {
271 c.handleError(w, r, err)
272 return
273 }
274
275 w.Header().Set("Content-Type", "text/event-stream")
276 w.Header().Set("Cache-Control", "no-cache")
277 w.Header().Set("Connection", "keep-alive")
278 // Flush headers immediately so clients see the 200 response
279 // before any events arrive. Without this, a quiet workspace
280 // keeps the client's SubscribeEvents call blocked on the
281 // initial RoundTrip.
282 w.WriteHeader(http.StatusOK)
283 flusher.Flush()
284
285 for {
286 select {
287 case <-r.Context().Done():
288 c.server.logDebug(r, "Stopping event stream")
289 return
290 case ev, ok := <-events:
291 if !ok {
292 return
293 }
294 wrapped := wrapEvent(ev.Payload)
295 if wrapped == nil {
296 continue
297 }
298 data, err := json.Marshal(wrapped)
299 if err != nil {
300 c.server.logError(r, "Failed to marshal event", "error", err)
301 continue
302 }
303
304 fmt.Fprintf(w, "data: %s\n\n", data)
305 flusher.Flush()
306 }
307 }
308}
309
310// handleGetWorkspaceLSPs lists LSP clients for a workspace.
311//
312// @Summary List LSP clients
313// @Tags lsp
314// @Produce json
315// @Param id path string true "Workspace ID"
316// @Success 200 {object} map[string]proto.LSPClientInfo
317// @Failure 404 {object} proto.Error
318// @Failure 500 {object} proto.Error
319// @Router /workspaces/{id}/lsps [get]
320func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Request) {
321 id := r.PathValue("id")
322 states, err := c.backend.GetLSPStates(id)
323 if err != nil {
324 c.handleError(w, r, err)
325 return
326 }
327 result := make(map[string]proto.LSPClientInfo, len(states))
328 for k, v := range states {
329 result[k] = proto.LSPClientInfo{
330 Name: v.Name,
331 State: v.State,
332 Error: v.Error,
333 DiagnosticCount: v.DiagnosticCount,
334 ConnectedAt: v.ConnectedAt,
335 }
336 }
337 jsonEncode(w, result)
338}
339
340// handleGetWorkspaceLSPDiagnostics returns diagnostics for an LSP client.
341//
342// @Summary Get LSP diagnostics
343// @Tags lsp
344// @Produce json
345// @Param id path string true "Workspace ID"
346// @Param lsp path string true "LSP client name"
347// @Success 200 {object} object
348// @Failure 404 {object} proto.Error
349// @Failure 500 {object} proto.Error
350// @Router /workspaces/{id}/lsps/{lsp}/diagnostics [get]
351func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) {
352 id := r.PathValue("id")
353 lspName := r.PathValue("lsp")
354 diagnostics, err := c.backend.GetLSPDiagnostics(id, lspName)
355 if err != nil {
356 c.handleError(w, r, err)
357 return
358 }
359 jsonEncode(w, diagnostics)
360}
361
362// handleGetWorkspaceSessions lists sessions for a workspace.
363//
364// @Summary List sessions
365// @Tags sessions
366// @Produce json
367// @Param id path string true "Workspace ID"
368// @Success 200 {array} proto.Session
369// @Failure 404 {object} proto.Error
370// @Failure 500 {object} proto.Error
371// @Router /workspaces/{id}/sessions [get]
372func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
373 id := r.PathValue("id")
374 sessions, err := c.backend.ListSessions(r.Context(), id)
375 if err != nil {
376 c.handleError(w, r, err)
377 return
378 }
379 ws, _ := c.backend.GetWorkspace(id)
380 result := make([]proto.Session, len(sessions))
381 for i, s := range sessions {
382 result[i] = sessionToProto(s)
383 result[i].IsBusy = isSessionBusy(ws, s.ID)
384 result[i].AttachedClients = attachedClients(ws, s.ID)
385 }
386 jsonEncode(w, result)
387}
388
389// handlePostWorkspaceSessions creates a new session in a workspace.
390//
391// @Summary Create session
392// @Tags sessions
393// @Accept json
394// @Produce json
395// @Param id path string true "Workspace ID"
396// @Param request body proto.Session true "Session creation params (title)"
397// @Success 200 {object} proto.Session
398// @Failure 400 {object} proto.Error
399// @Failure 404 {object} proto.Error
400// @Failure 500 {object} proto.Error
401// @Router /workspaces/{id}/sessions [post]
402func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
403 id := r.PathValue("id")
404
405 var args session.Session
406 if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
407 c.server.logError(r, "Failed to decode request", "error", err)
408 jsonError(w, http.StatusBadRequest, "failed to decode request")
409 return
410 }
411
412 sess, err := c.backend.CreateSession(r.Context(), id, args.Title)
413 if err != nil {
414 c.handleError(w, r, err)
415 return
416 }
417 ws, _ := c.backend.GetWorkspace(id)
418 out := sessionToProto(sess)
419 out.IsBusy = isSessionBusy(ws, sess.ID)
420 out.AttachedClients = attachedClients(ws, sess.ID)
421 jsonEncode(w, out)
422}
423
424// handleGetWorkspaceSession returns a single session.
425//
426// @Summary Get session
427// @Tags sessions
428// @Produce json
429// @Param id path string true "Workspace ID"
430// @Param sid path string true "Session ID"
431// @Success 200 {object} proto.Session
432// @Failure 404 {object} proto.Error
433// @Failure 500 {object} proto.Error
434// @Router /workspaces/{id}/sessions/{sid} [get]
435func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.Request) {
436 id := r.PathValue("id")
437 sid := r.PathValue("sid")
438 sess, err := c.backend.GetSession(r.Context(), id, sid)
439 if err != nil {
440 c.handleError(w, r, err)
441 return
442 }
443 ws, _ := c.backend.GetWorkspace(id)
444 out := sessionToProto(sess)
445 out.IsBusy = isSessionBusy(ws, sess.ID)
446 out.AttachedClients = attachedClients(ws, sess.ID)
447 jsonEncode(w, out)
448}
449
450// handleGetWorkspaceSessionHistory returns the history for a session.
451//
452// @Summary Get session history
453// @Tags sessions
454// @Produce json
455// @Param id path string true "Workspace ID"
456// @Param sid path string true "Session ID"
457// @Success 200 {array} proto.File
458// @Failure 404 {object} proto.Error
459// @Failure 500 {object} proto.Error
460// @Router /workspaces/{id}/sessions/{sid}/history [get]
461func (c *controllerV1) handleGetWorkspaceSessionHistory(w http.ResponseWriter, r *http.Request) {
462 id := r.PathValue("id")
463 sid := r.PathValue("sid")
464 history, err := c.backend.ListSessionHistory(r.Context(), id, sid)
465 if err != nil {
466 c.handleError(w, r, err)
467 return
468 }
469 jsonEncode(w, history)
470}
471
472// handleGetWorkspaceSessionMessages returns all messages for a session.
473//
474// @Summary Get session messages
475// @Tags sessions
476// @Produce json
477// @Param id path string true "Workspace ID"
478// @Param sid path string true "Session ID"
479// @Success 200 {array} proto.Message
480// @Failure 404 {object} proto.Error
481// @Failure 500 {object} proto.Error
482// @Router /workspaces/{id}/sessions/{sid}/messages [get]
483func (c *controllerV1) handleGetWorkspaceSessionMessages(w http.ResponseWriter, r *http.Request) {
484 id := r.PathValue("id")
485 sid := r.PathValue("sid")
486 messages, err := c.backend.ListSessionMessages(r.Context(), id, sid)
487 if err != nil {
488 c.handleError(w, r, err)
489 return
490 }
491 jsonEncode(w, messagesToProto(messages))
492}
493
494// handlePutWorkspaceSession updates a session.
495//
496// @Summary Update session
497// @Tags sessions
498// @Accept json
499// @Produce json
500// @Param id path string true "Workspace ID"
501// @Param sid path string true "Session ID"
502// @Param request body proto.Session true "Updated session"
503// @Success 200 {object} proto.Session
504// @Failure 400 {object} proto.Error
505// @Failure 404 {object} proto.Error
506// @Failure 500 {object} proto.Error
507// @Router /workspaces/{id}/sessions/{sid} [put]
508func (c *controllerV1) handlePutWorkspaceSession(w http.ResponseWriter, r *http.Request) {
509 id := r.PathValue("id")
510
511 var sess session.Session
512 if err := json.NewDecoder(r.Body).Decode(&sess); err != nil {
513 c.server.logError(r, "Failed to decode request", "error", err)
514 jsonError(w, http.StatusBadRequest, "failed to decode request")
515 return
516 }
517
518 saved, err := c.backend.SaveSession(r.Context(), id, sess)
519 if err != nil {
520 c.handleError(w, r, err)
521 return
522 }
523 ws, _ := c.backend.GetWorkspace(id)
524 out := sessionToProto(saved)
525 out.IsBusy = isSessionBusy(ws, saved.ID)
526 out.AttachedClients = attachedClients(ws, saved.ID)
527 jsonEncode(w, out)
528}
529
530// handleDeleteWorkspaceSession deletes a session.
531//
532// @Summary Delete session
533// @Tags sessions
534// @Param id path string true "Workspace ID"
535// @Param sid path string true "Session ID"
536// @Success 200
537// @Failure 404 {object} proto.Error
538// @Failure 500 {object} proto.Error
539// @Router /workspaces/{id}/sessions/{sid} [delete]
540func (c *controllerV1) handleDeleteWorkspaceSession(w http.ResponseWriter, r *http.Request) {
541 id := r.PathValue("id")
542 sid := r.PathValue("sid")
543 if err := c.backend.DeleteSession(r.Context(), id, sid); err != nil {
544 c.handleError(w, r, err)
545 return
546 }
547 w.WriteHeader(http.StatusOK)
548}
549
550// handleGetWorkspaceSessionUserMessages returns user messages for a session.
551//
552// @Summary Get user messages for session
553// @Tags sessions
554// @Produce json
555// @Param id path string true "Workspace ID"
556// @Param sid path string true "Session ID"
557// @Success 200 {array} proto.Message
558// @Failure 404 {object} proto.Error
559// @Failure 500 {object} proto.Error
560// @Router /workspaces/{id}/sessions/{sid}/messages/user [get]
561func (c *controllerV1) handleGetWorkspaceSessionUserMessages(w http.ResponseWriter, r *http.Request) {
562 id := r.PathValue("id")
563 sid := r.PathValue("sid")
564 messages, err := c.backend.ListUserMessages(r.Context(), id, sid)
565 if err != nil {
566 c.handleError(w, r, err)
567 return
568 }
569 jsonEncode(w, messagesToProto(messages))
570}
571
572// handleGetWorkspaceAllUserMessages returns all user messages across sessions.
573//
574// @Summary Get all user messages for workspace
575// @Tags workspaces
576// @Produce json
577// @Param id path string true "Workspace ID"
578// @Success 200 {array} proto.Message
579// @Failure 404 {object} proto.Error
580// @Failure 500 {object} proto.Error
581// @Router /workspaces/{id}/messages/user [get]
582func (c *controllerV1) handleGetWorkspaceAllUserMessages(w http.ResponseWriter, r *http.Request) {
583 id := r.PathValue("id")
584 messages, err := c.backend.ListAllUserMessages(r.Context(), id)
585 if err != nil {
586 c.handleError(w, r, err)
587 return
588 }
589 jsonEncode(w, messagesToProto(messages))
590}
591
592// handleGetWorkspaceSessionFileTrackerFiles lists files read in a session.
593//
594// @Summary List tracked files for session
595// @Tags filetracker
596// @Produce json
597// @Param id path string true "Workspace ID"
598// @Param sid path string true "Session ID"
599// @Success 200 {array} string
600// @Failure 404 {object} proto.Error
601// @Failure 500 {object} proto.Error
602// @Router /workspaces/{id}/sessions/{sid}/filetracker/files [get]
603func (c *controllerV1) handleGetWorkspaceSessionFileTrackerFiles(w http.ResponseWriter, r *http.Request) {
604 id := r.PathValue("id")
605 sid := r.PathValue("sid")
606 files, err := c.backend.FileTrackerListReadFiles(r.Context(), id, sid)
607 if err != nil {
608 c.handleError(w, r, err)
609 return
610 }
611 jsonEncode(w, files)
612}
613
614// handlePostWorkspaceFileTrackerRead records a file read event.
615//
616// @Summary Record file read
617// @Tags filetracker
618// @Accept json
619// @Param id path string true "Workspace ID"
620// @Param request body proto.FileTrackerReadRequest true "File tracker read request"
621// @Success 200
622// @Failure 400 {object} proto.Error
623// @Failure 404 {object} proto.Error
624// @Failure 500 {object} proto.Error
625// @Router /workspaces/{id}/filetracker/read [post]
626func (c *controllerV1) handlePostWorkspaceFileTrackerRead(w http.ResponseWriter, r *http.Request) {
627 id := r.PathValue("id")
628
629 var req proto.FileTrackerReadRequest
630 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
631 c.server.logError(r, "Failed to decode request", "error", err)
632 jsonError(w, http.StatusBadRequest, "failed to decode request")
633 return
634 }
635
636 if err := c.backend.FileTrackerRecordRead(r.Context(), id, req.SessionID, req.Path); err != nil {
637 c.handleError(w, r, err)
638 return
639 }
640 w.WriteHeader(http.StatusOK)
641}
642
643// handleGetWorkspaceFileTrackerLastRead returns the last read time for a file.
644//
645// @Summary Get last read time for file
646// @Tags filetracker
647// @Produce json
648// @Param id path string true "Workspace ID"
649// @Param session_id query string false "Session ID"
650// @Param path query string true "File path"
651// @Success 200 {object} object
652// @Failure 404 {object} proto.Error
653// @Failure 500 {object} proto.Error
654// @Router /workspaces/{id}/filetracker/lastread [get]
655func (c *controllerV1) handleGetWorkspaceFileTrackerLastRead(w http.ResponseWriter, r *http.Request) {
656 id := r.PathValue("id")
657 sid := r.URL.Query().Get("session_id")
658 path := r.URL.Query().Get("path")
659
660 t, err := c.backend.FileTrackerLastReadTime(r.Context(), id, sid, path)
661 if err != nil {
662 c.handleError(w, r, err)
663 return
664 }
665 jsonEncode(w, t)
666}
667
668// handlePostWorkspaceLSPStart starts an LSP server for a path.
669//
670// @Summary Start LSP server
671// @Tags lsp
672// @Accept json
673// @Param id path string true "Workspace ID"
674// @Param request body proto.LSPStartRequest true "LSP start request"
675// @Success 200
676// @Failure 400 {object} proto.Error
677// @Failure 404 {object} proto.Error
678// @Failure 500 {object} proto.Error
679// @Router /workspaces/{id}/lsps/start [post]
680func (c *controllerV1) handlePostWorkspaceLSPStart(w http.ResponseWriter, r *http.Request) {
681 id := r.PathValue("id")
682
683 var req proto.LSPStartRequest
684 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
685 c.server.logError(r, "Failed to decode request", "error", err)
686 jsonError(w, http.StatusBadRequest, "failed to decode request")
687 return
688 }
689
690 if err := c.backend.LSPStart(r.Context(), id, req.Path); err != nil {
691 c.handleError(w, r, err)
692 return
693 }
694 w.WriteHeader(http.StatusOK)
695}
696
697// handlePostWorkspaceLSPStopAll stops all LSP servers.
698//
699// @Summary Stop all LSP servers
700// @Tags lsp
701// @Param id path string true "Workspace ID"
702// @Success 200
703// @Failure 404 {object} proto.Error
704// @Failure 500 {object} proto.Error
705// @Router /workspaces/{id}/lsps/stop [post]
706func (c *controllerV1) handlePostWorkspaceLSPStopAll(w http.ResponseWriter, r *http.Request) {
707 id := r.PathValue("id")
708 if err := c.backend.LSPStopAll(r.Context(), id); err != nil {
709 c.handleError(w, r, err)
710 return
711 }
712 w.WriteHeader(http.StatusOK)
713}
714
715// handleGetWorkspaceAgent returns agent info for a workspace.
716//
717// @Summary Get agent info
718// @Tags agent
719// @Produce json
720// @Param id path string true "Workspace ID"
721// @Success 200 {object} proto.AgentInfo
722// @Failure 404 {object} proto.Error
723// @Failure 500 {object} proto.Error
724// @Router /workspaces/{id}/agent [get]
725func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
726 id := r.PathValue("id")
727 info, err := c.backend.GetAgentInfo(id)
728 if err != nil {
729 c.handleError(w, r, err)
730 return
731 }
732 jsonEncode(w, info)
733}
734
735// handlePostWorkspaceAgent sends a message to the agent.
736//
737// @Summary Send message to agent
738// @Tags agent
739// @Accept json
740// @Param id path string true "Workspace ID"
741// @Param request body proto.AgentMessage true "Agent message"
742// @Success 202
743// @Failure 400 {object} proto.Error
744// @Failure 404 {object} proto.Error
745// @Failure 409 {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.StatusAccepted)
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.
1037//
1038// Runtime cancellation of an agent run no longer reaches here for the
1039// agent-prompt path: SendMessage is fire-and-forget (the handler returns
1040// 202 before the run starts) and Backend.runAgent swallows
1041// context.Canceled, surfacing the FinishReasonCanceled marker to SSE
1042// subscribers instead. The remaining callers pass synchronous backend
1043// errors, so context.Canceled gets no special case and would fall through
1044// to the default 500 like any other unexpected error.
1045func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err error) {
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 case errors.Is(err, backend.ErrWorkspaceClosing):
1065 status = http.StatusConflict
1066 }
1067 c.server.logError(r, err.Error())
1068 jsonError(w, status, err.Error())
1069}
1070
1071func jsonEncode(w http.ResponseWriter, v any) {
1072 w.Header().Set("Content-Type", "application/json")
1073 _ = json.NewEncoder(w).Encode(v)
1074}
1075
1076func jsonError(w http.ResponseWriter, status int, message string) {
1077 w.Header().Set("Content-Type", "application/json")
1078 w.WriteHeader(status)
1079 _ = json.NewEncoder(w).Encode(proto.Error{Message: message})
1080}