@@ -0,0 +1,656 @@
1+package server
2+
3+import (
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+
23+type controllerV1 struct {
24+ *Server
25+}
26+
27+func (c *controllerV1) handleGetHealth(w http.ResponseWriter, _ *http.Request) {
28+ w.WriteHeader(http.StatusOK)
29+}
30+
31+func (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+
40+func (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+
63+func (c *controllerV1) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
64+ jsonEncode(w, c.cfg)
65+}
66+
67+func (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+
82+func (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+
108+func (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+
121+func (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+
135+func (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+
149+func (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+
166+func (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+
182+func (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+
213+func (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+
244+func (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+
264+func (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+
280+func (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+
296+func (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+
316+func (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+
336+func (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+
356+func (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+
382+func (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+
401+func (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+
442+func (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+
461+func (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+
474+func (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+
487+func (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+
525+func (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+
537+func (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+
546+func (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+
565+func (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+
632+func 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+
647+func jsonEncode(w http.ResponseWriter, v any) {
648+ w.Header().Set("Content-Type", "application/json")
649+ _ = json.NewEncoder(w).Encode(v)
650+}
651+
652+func 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+}