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