proto.go

  1package server
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"log/slog"
  8	"net/http"
  9	"os"
 10	"path/filepath"
 11	"runtime"
 12
 13	"github.com/charmbracelet/crush/internal/app"
 14	"github.com/charmbracelet/crush/internal/config"
 15	"github.com/charmbracelet/crush/internal/db"
 16	"github.com/charmbracelet/crush/internal/permission"
 17	"github.com/charmbracelet/crush/internal/proto"
 18	"github.com/charmbracelet/crush/internal/session"
 19	"github.com/charmbracelet/crush/internal/ui/util"
 20	"github.com/charmbracelet/crush/internal/version"
 21	"github.com/google/uuid"
 22)
 23
 24type controllerV1 struct {
 25	*Server
 26}
 27
 28func (c *controllerV1) handleGetHealth(w http.ResponseWriter, _ *http.Request) {
 29	w.WriteHeader(http.StatusOK)
 30}
 31
 32func (c *controllerV1) handleGetVersion(w http.ResponseWriter, _ *http.Request) {
 33	jsonEncode(w, proto.VersionInfo{
 34		Version:   version.Version,
 35		Commit:    version.Commit,
 36		GoVersion: runtime.Version(),
 37		Platform:  fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
 38	})
 39}
 40
 41func (c *controllerV1) handlePostControl(w http.ResponseWriter, r *http.Request) {
 42	var req proto.ServerControl
 43	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 44		c.logError(r, "Failed to decode request", "error", err)
 45		jsonError(w, http.StatusBadRequest, "failed to decode request")
 46		return
 47	}
 48
 49	switch req.Command {
 50	case "shutdown":
 51		go func() {
 52			slog.Info("Shutting down server...")
 53			if err := c.Shutdown(context.Background()); err != nil {
 54				slog.Error("Failed to shutdown server", "error", err)
 55			}
 56		}()
 57	default:
 58		c.logError(r, "Unknown command", "command", req.Command)
 59		jsonError(w, http.StatusBadRequest, "unknown command")
 60		return
 61	}
 62}
 63
 64func (c *controllerV1) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
 65	jsonEncode(w, c.cfg)
 66}
 67
 68func (c *controllerV1) handleGetWorkspaces(w http.ResponseWriter, _ *http.Request) {
 69	workspaces := []proto.Workspace{}
 70	for _, ws := range c.workspaces.Seq2() {
 71		workspaces = append(workspaces, proto.Workspace{
 72			ID:      ws.id,
 73			Path:    ws.path,
 74			YOLO:    ws.cfg.Permissions != nil && ws.cfg.Permissions.SkipRequests,
 75			DataDir: ws.cfg.Options.DataDirectory,
 76			Debug:   ws.cfg.Options.Debug,
 77			Config:  ws.cfg,
 78		})
 79	}
 80	jsonEncode(w, workspaces)
 81}
 82
 83func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) {
 84	id := r.PathValue("id")
 85	ws, ok := c.workspaces.Get(id)
 86	if !ok {
 87		c.logError(r, "Workspace not found", "id", id)
 88		jsonError(w, http.StatusNotFound, "workspace not found")
 89		return
 90	}
 91
 92	lspName := r.PathValue("lsp")
 93	var found bool
 94	for name, client := range ws.LSPManager.Clients().Seq2() {
 95		if name == lspName {
 96			diagnostics := client.GetDiagnostics()
 97			jsonEncode(w, diagnostics)
 98			found = true
 99			break
100		}
101	}
102
103	if !found {
104		c.logError(r, "LSP client not found", "id", id, "lsp", lspName)
105		jsonError(w, http.StatusNotFound, "LSP client not found")
106	}
107}
108
109func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Request) {
110	id := r.PathValue("id")
111	_, ok := c.workspaces.Get(id)
112	if !ok {
113		c.logError(r, "Workspace not found", "id", id)
114		jsonError(w, http.StatusNotFound, "workspace not found")
115		return
116	}
117
118	lspClients := app.GetLSPStates()
119	jsonEncode(w, lspClients)
120}
121
122func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) {
123	id := r.PathValue("id")
124	ws, ok := c.workspaces.Get(id)
125	if !ok {
126		c.logError(r, "Workspace not found", "id", id)
127		jsonError(w, http.StatusNotFound, "workspace not found")
128		return
129	}
130
131	sid := r.PathValue("sid")
132	queued := ws.App.AgentCoordinator.QueuedPrompts(sid)
133	jsonEncode(w, queued)
134}
135
136func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) {
137	id := r.PathValue("id")
138	ws, ok := c.workspaces.Get(id)
139	if !ok {
140		c.logError(r, "Workspace not found", "id", id)
141		jsonError(w, http.StatusNotFound, "workspace not found")
142		return
143	}
144
145	sid := r.PathValue("sid")
146	ws.App.AgentCoordinator.ClearQueue(sid)
147	w.WriteHeader(http.StatusOK)
148}
149
150func (c *controllerV1) handleGetWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
151	id := r.PathValue("id")
152	ws, ok := c.workspaces.Get(id)
153	if !ok {
154		c.logError(r, "Workspace not found", "id", id)
155		jsonError(w, http.StatusNotFound, "workspace not found")
156		return
157	}
158
159	sid := r.PathValue("sid")
160	if err := ws.App.AgentCoordinator.Summarize(r.Context(), sid); err != nil {
161		c.logError(r, "Failed to summarize session", "error", err, "id", id, "sid", sid)
162		jsonError(w, http.StatusInternalServerError, "failed to summarize session")
163		return
164	}
165}
166
167func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) {
168	id := r.PathValue("id")
169	ws, ok := c.workspaces.Get(id)
170	if !ok {
171		c.logError(r, "Workspace not found", "id", id)
172		jsonError(w, http.StatusNotFound, "workspace not found")
173		return
174	}
175
176	sid := r.PathValue("sid")
177	if ws.App.AgentCoordinator != nil {
178		ws.App.AgentCoordinator.Cancel(sid)
179	}
180	w.WriteHeader(http.StatusOK)
181}
182
183func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) {
184	id := r.PathValue("id")
185	ws, ok := c.workspaces.Get(id)
186	if !ok {
187		c.logError(r, "Workspace not found", "id", id)
188		jsonError(w, http.StatusNotFound, "workspace not found")
189		return
190	}
191
192	sid := r.PathValue("sid")
193	se, err := ws.App.Sessions.Get(r.Context(), sid)
194	if err != nil {
195		c.logError(r, "Failed to get session", "error", err, "id", id, "sid", sid)
196		jsonError(w, http.StatusInternalServerError, "failed to get session")
197		return
198	}
199
200	var isSessionBusy bool
201	if ws.App.AgentCoordinator != nil {
202		isSessionBusy = ws.App.AgentCoordinator.IsSessionBusy(sid)
203	}
204
205	jsonEncode(w, proto.AgentSession{
206		Session: proto.Session{
207			ID:    se.ID,
208			Title: se.Title,
209		},
210		IsBusy: isSessionBusy,
211	})
212}
213
214func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
215	id := r.PathValue("id")
216	ws, ok := c.workspaces.Get(id)
217	if !ok {
218		c.logError(r, "Workspace not found", "id", id)
219		jsonError(w, http.StatusNotFound, "workspace not found")
220		return
221	}
222
223	w.Header().Set("Accept", "application/json")
224
225	var msg proto.AgentMessage
226	if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
227		c.logError(r, "Failed to decode request", "error", err)
228		jsonError(w, http.StatusBadRequest, "failed to decode request")
229		return
230	}
231
232	if ws.App.AgentCoordinator == nil {
233		c.logError(r, "Agent coordinator not initialized", "id", id)
234		jsonError(w, http.StatusBadRequest, "agent coordinator not initialized")
235		return
236	}
237
238	if _, err := ws.App.AgentCoordinator.Run(c.ctx, msg.SessionID, msg.Prompt); err != nil {
239		c.logError(r, "Failed to enqueue message", "error", err, "id", id, "sid", msg.SessionID)
240		jsonError(w, http.StatusInternalServerError, "failed to enqueue message")
241		return
242	}
243}
244
245func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
246	id := r.PathValue("id")
247	ws, ok := c.workspaces.Get(id)
248	if !ok {
249		c.logError(r, "Workspace not found", "id", id)
250		jsonError(w, http.StatusNotFound, "workspace not found")
251		return
252	}
253
254	var agentInfo proto.AgentInfo
255	if ws.App.AgentCoordinator != nil {
256		m := ws.App.AgentCoordinator.Model()
257		agentInfo = proto.AgentInfo{
258			Model:  m.CatwalkCfg,
259			IsBusy: ws.App.AgentCoordinator.IsBusy(),
260		}
261	}
262	jsonEncode(w, agentInfo)
263}
264
265func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) {
266	id := r.PathValue("id")
267	ws, ok := c.workspaces.Get(id)
268	if !ok {
269		c.logError(r, "Workspace not found", "id", id)
270		jsonError(w, http.StatusNotFound, "workspace not found")
271		return
272	}
273
274	if err := ws.App.UpdateAgentModel(r.Context()); err != nil {
275		c.logError(r, "Failed to update agent model", "error", err)
276		jsonError(w, http.StatusInternalServerError, "failed to update agent model")
277		return
278	}
279}
280
281func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
282	id := r.PathValue("id")
283	ws, ok := c.workspaces.Get(id)
284	if !ok {
285		c.logError(r, "Workspace not found", "id", id)
286		jsonError(w, http.StatusNotFound, "workspace not found")
287		return
288	}
289
290	if err := ws.App.InitCoderAgent(r.Context()); err != nil {
291		c.logError(r, "Failed to initialize coder agent", "error", err)
292		jsonError(w, http.StatusInternalServerError, "failed to initialize coder agent")
293		return
294	}
295}
296
297func (c *controllerV1) handleGetWorkspaceSessionHistory(w http.ResponseWriter, r *http.Request) {
298	id := r.PathValue("id")
299	ws, ok := c.workspaces.Get(id)
300	if !ok {
301		c.logError(r, "Workspace not found", "id", id)
302		jsonError(w, http.StatusNotFound, "workspace not found")
303		return
304	}
305
306	sid := r.PathValue("sid")
307	historyItems, err := ws.App.History.ListBySession(r.Context(), sid)
308	if err != nil {
309		c.logError(r, "Failed to list history", "error", err, "id", id, "sid", sid)
310		jsonError(w, http.StatusInternalServerError, "failed to list history")
311		return
312	}
313
314	jsonEncode(w, historyItems)
315}
316
317func (c *controllerV1) handleGetWorkspaceSessionMessages(w http.ResponseWriter, r *http.Request) {
318	id := r.PathValue("id")
319	ws, ok := c.workspaces.Get(id)
320	if !ok {
321		c.logError(r, "Workspace not found", "id", id)
322		jsonError(w, http.StatusNotFound, "workspace not found")
323		return
324	}
325
326	sid := r.PathValue("sid")
327	messages, err := ws.App.Messages.List(r.Context(), sid)
328	if err != nil {
329		c.logError(r, "Failed to list messages", "error", err, "id", id, "sid", sid)
330		jsonError(w, http.StatusInternalServerError, "failed to list messages")
331		return
332	}
333
334	jsonEncode(w, messages)
335}
336
337func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.Request) {
338	id := r.PathValue("id")
339	ws, ok := c.workspaces.Get(id)
340	if !ok {
341		c.logError(r, "Workspace not found", "id", id)
342		jsonError(w, http.StatusNotFound, "workspace not found")
343		return
344	}
345
346	sid := r.PathValue("sid")
347	sess, err := ws.App.Sessions.Get(r.Context(), sid)
348	if err != nil {
349		c.logError(r, "Failed to get session", "error", err, "id", id, "sid", sid)
350		jsonError(w, http.StatusInternalServerError, "failed to get session")
351		return
352	}
353
354	jsonEncode(w, sess)
355}
356
357func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
358	id := r.PathValue("id")
359	ws, ok := c.workspaces.Get(id)
360	if !ok {
361		c.logError(r, "Workspace not found", "id", id)
362		jsonError(w, http.StatusNotFound, "workspace not found")
363		return
364	}
365
366	var args session.Session
367	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
368		c.logError(r, "Failed to decode request", "error", err)
369		jsonError(w, http.StatusBadRequest, "failed to decode request")
370		return
371	}
372
373	sess, err := ws.App.Sessions.Create(r.Context(), args.Title)
374	if err != nil {
375		c.logError(r, "Failed to create session", "error", err, "id", id)
376		jsonError(w, http.StatusInternalServerError, "failed to create session")
377		return
378	}
379
380	jsonEncode(w, sess)
381}
382
383func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
384	id := r.PathValue("id")
385	ws, ok := c.workspaces.Get(id)
386	if !ok {
387		c.logError(r, "Workspace not found", "id", id)
388		jsonError(w, http.StatusNotFound, "workspace not found")
389		return
390	}
391
392	sessions, err := ws.App.Sessions.List(r.Context())
393	if err != nil {
394		c.logError(r, "Failed to list sessions", "error", err)
395		jsonError(w, http.StatusInternalServerError, "failed to list sessions")
396		return
397	}
398
399	jsonEncode(w, sessions)
400}
401
402func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) {
403	id := r.PathValue("id")
404	ws, ok := c.workspaces.Get(id)
405	if !ok {
406		c.logError(r, "Workspace not found", "id", id)
407		jsonError(w, http.StatusNotFound, "workspace not found")
408		return
409	}
410
411	var req proto.PermissionGrant
412	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
413		c.logError(r, "Failed to decode request", "error", err)
414		jsonError(w, http.StatusBadRequest, "failed to decode request")
415		return
416	}
417
418	perm := permission.PermissionRequest{
419		ID:          req.Permission.ID,
420		SessionID:   req.Permission.SessionID,
421		ToolCallID:  req.Permission.ToolCallID,
422		ToolName:    req.Permission.ToolName,
423		Description: req.Permission.Description,
424		Action:      req.Permission.Action,
425		Params:      req.Permission.Params,
426		Path:        req.Permission.Path,
427	}
428
429	switch req.Action {
430	case proto.PermissionAllow:
431		ws.App.Permissions.Grant(perm)
432	case proto.PermissionAllowForSession:
433		ws.App.Permissions.GrantPersistent(perm)
434	case proto.PermissionDeny:
435		ws.App.Permissions.Deny(perm)
436	default:
437		c.logError(r, "Invalid permission action", "action", req.Action)
438		jsonError(w, http.StatusBadRequest, "invalid permission action")
439		return
440	}
441}
442
443func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
444	id := r.PathValue("id")
445	ws, ok := c.workspaces.Get(id)
446	if !ok {
447		c.logError(r, "Workspace not found", "id", id)
448		jsonError(w, http.StatusNotFound, "workspace not found")
449		return
450	}
451
452	var req proto.PermissionSkipRequest
453	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
454		c.logError(r, "Failed to decode request", "error", err)
455		jsonError(w, http.StatusBadRequest, "failed to decode request")
456		return
457	}
458
459	ws.App.Permissions.SetSkipRequests(req.Skip)
460}
461
462func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
463	id := r.PathValue("id")
464	ws, ok := c.workspaces.Get(id)
465	if !ok {
466		c.logError(r, "Workspace not found", "id", id)
467		jsonError(w, http.StatusNotFound, "workspace not found")
468		return
469	}
470
471	skip := ws.App.Permissions.SkipRequests()
472	jsonEncode(w, proto.PermissionSkipRequest{Skip: skip})
473}
474
475func (c *controllerV1) handleGetWorkspaceProviders(w http.ResponseWriter, r *http.Request) {
476	id := r.PathValue("id")
477	ws, ok := c.workspaces.Get(id)
478	if !ok {
479		c.logError(r, "Workspace not found", "id", id)
480		jsonError(w, http.StatusNotFound, "workspace not found")
481		return
482	}
483
484	providers, _ := config.Providers(ws.cfg)
485	jsonEncode(w, providers)
486}
487
488func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.Request) {
489	flusher := http.NewResponseController(w)
490	id := r.PathValue("id")
491	ws, ok := c.workspaces.Get(id)
492	if !ok {
493		c.logError(r, "Workspace not found", "id", id)
494		jsonError(w, http.StatusNotFound, "workspace not found")
495		return
496	}
497
498	w.Header().Set("Content-Type", "text/event-stream")
499	w.Header().Set("Cache-Control", "no-cache")
500	w.Header().Set("Connection", "keep-alive")
501
502	events := ws.App.Events()
503
504	for {
505		select {
506		case <-r.Context().Done():
507			c.logDebug(r, "Stopping event stream")
508			return
509		case ev, ok := <-events:
510			if !ok {
511				return
512			}
513			c.logDebug(r, "Sending event", "event", fmt.Sprintf("%T %+v", ev, ev))
514			data, err := json.Marshal(ev)
515			if err != nil {
516				c.logError(r, "Failed to marshal event", "error", err)
517				continue
518			}
519
520			fmt.Fprintf(w, "data: %s\n\n", data)
521			flusher.Flush()
522		}
523	}
524}
525
526func (c *controllerV1) handleGetWorkspaceConfig(w http.ResponseWriter, r *http.Request) {
527	id := r.PathValue("id")
528	ws, ok := c.workspaces.Get(id)
529	if !ok {
530		c.logError(r, "Workspace not found", "id", id)
531		jsonError(w, http.StatusNotFound, "workspace not found")
532		return
533	}
534
535	jsonEncode(w, ws.cfg)
536}
537
538func (c *controllerV1) handleDeleteWorkspaces(w http.ResponseWriter, r *http.Request) {
539	id := r.PathValue("id")
540	ws, ok := c.workspaces.Get(id)
541	if ok {
542		ws.App.Shutdown()
543	}
544	c.workspaces.Del(id)
545
546	// When the last workspace is removed, shut down the server.
547	if c.workspaces.Len() == 0 {
548		slog.Info("Last workspace removed, shutting down server...")
549		go func() {
550			if err := c.Shutdown(context.Background()); err != nil {
551				slog.Error("Failed to shutdown server", "error", err)
552			}
553		}()
554	}
555}
556
557func (c *controllerV1) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
558	id := r.PathValue("id")
559	ws, ok := c.workspaces.Get(id)
560	if !ok {
561		c.logError(r, "Workspace not found", "id", id)
562		jsonError(w, http.StatusNotFound, "workspace not found")
563		return
564	}
565
566	jsonEncode(w, proto.Workspace{
567		ID:      ws.id,
568		Path:    ws.path,
569		YOLO:    ws.cfg.Permissions != nil && ws.cfg.Permissions.SkipRequests,
570		DataDir: ws.cfg.Options.DataDirectory,
571		Debug:   ws.cfg.Options.Debug,
572		Config:  ws.cfg,
573	})
574}
575
576func (c *controllerV1) handlePostWorkspaces(w http.ResponseWriter, r *http.Request) {
577	var args proto.Workspace
578	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
579		c.logError(r, "Failed to decode request", "error", err)
580		jsonError(w, http.StatusBadRequest, "failed to decode request")
581		return
582	}
583
584	if args.Path == "" {
585		c.logError(r, "Path is required")
586		jsonError(w, http.StatusBadRequest, "path is required")
587		return
588	}
589
590	id := uuid.New().String()
591	cfg, err := config.Init(args.Path, args.DataDir, args.Debug)
592	if err != nil {
593		c.logError(r, "Failed to initialize config", "error", err)
594		jsonError(w, http.StatusBadRequest, fmt.Sprintf("failed to initialize config: %v", err))
595		return
596	}
597
598	if cfg.Permissions == nil {
599		cfg.Permissions = &config.Permissions{}
600	}
601	cfg.Permissions.SkipRequests = args.YOLO
602
603	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
604		c.logError(r, "Failed to create data directory", "error", err)
605		jsonError(w, http.StatusInternalServerError, "failed to create data directory")
606		return
607	}
608
609	conn, err := db.Connect(c.ctx, cfg.Options.DataDirectory)
610	if err != nil {
611		c.logError(r, "Failed to connect to database", "error", err)
612		jsonError(w, http.StatusInternalServerError, "failed to connect to database")
613		return
614	}
615
616	appWorkspace, err := app.New(c.ctx, conn, cfg)
617	if err != nil {
618		slog.Error("Failed to create app workspace", "error", err)
619		jsonError(w, http.StatusInternalServerError, "failed to create app workspace")
620		return
621	}
622
623	ws := &Workspace{
624		App:  appWorkspace,
625		id:   id,
626		path: args.Path,
627		cfg:  cfg,
628		env:  args.Env,
629	}
630
631	c.workspaces.Set(id, ws)
632
633	if args.Version != "" && args.Version != version.Version {
634		slog.Warn("Client/server version mismatch",
635			"client", args.Version,
636			"server", version.Version,
637		)
638		appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf(
639			"Server version %q differs from client version %q. Consider restarting the server.",
640			version.Version, args.Version,
641		)))
642	}
643
644	jsonEncode(w, proto.Workspace{
645		ID:      id,
646		Path:    args.Path,
647		DataDir: cfg.Options.DataDirectory,
648		Debug:   cfg.Options.Debug,
649		YOLO:    cfg.Permissions.SkipRequests,
650		Config:  cfg,
651		Env:     args.Env,
652	})
653}
654
655func createDotCrushDir(dir string) error {
656	if err := os.MkdirAll(dir, 0o700); err != nil {
657		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
658	}
659
660	gitIgnorePath := filepath.Join(dir, ".gitignore")
661	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
662		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
663			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
664		}
665	}
666
667	return nil
668}
669
670func jsonEncode(w http.ResponseWriter, v any) {
671	w.Header().Set("Content-Type", "application/json")
672	_ = json.NewEncoder(w).Encode(v)
673}
674
675func jsonError(w http.ResponseWriter, status int, message string) {
676	w.Header().Set("Content-Type", "application/json")
677	w.WriteHeader(status)
678	_ = json.NewEncoder(w).Encode(proto.Error{Message: message})
679}