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