proto.go

   1package server
   2
   3import (
   4	"encoding/json"
   5	"errors"
   6	"fmt"
   7	"net/http"
   8
   9	"github.com/charmbracelet/crush/internal/backend"
  10	"github.com/charmbracelet/crush/internal/proto"
  11	"github.com/charmbracelet/crush/internal/session"
  12	"github.com/google/uuid"
  13)
  14
  15type controllerV1 struct {
  16	backend *backend.Backend
  17	server  *Server
  18}
  19
  20// handleGetHealth checks server health.
  21//
  22//	@Summary		Health check
  23//	@Tags			system
  24//	@Success		200
  25//	@Router			/health [get]
  26func (c *controllerV1) handleGetHealth(w http.ResponseWriter, _ *http.Request) {
  27	w.WriteHeader(http.StatusOK)
  28}
  29
  30// handleGetVersion returns server version information.
  31//
  32//	@Summary		Get server version
  33//	@Tags			system
  34//	@Produce		json
  35//	@Success		200	{object}	proto.VersionInfo
  36//	@Router			/version [get]
  37func (c *controllerV1) handleGetVersion(w http.ResponseWriter, _ *http.Request) {
  38	jsonEncode(w, c.backend.VersionInfo())
  39}
  40
  41// handlePostControl sends a control command to the server.
  42//
  43//	@Summary		Send server control command
  44//	@Tags			system
  45//	@Accept			json
  46//	@Param			request	body	proto.ServerControl	true	"Control command (e.g. shutdown)"
  47//	@Success		200
  48//	@Failure		400	{object}	proto.Error
  49//	@Router			/control [post]
  50func (c *controllerV1) handlePostControl(w http.ResponseWriter, r *http.Request) {
  51	var req proto.ServerControl
  52	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  53		c.server.logError(r, "Failed to decode request", "error", err)
  54		jsonError(w, http.StatusBadRequest, "failed to decode request")
  55		return
  56	}
  57
  58	switch req.Command {
  59	case "shutdown":
  60		c.backend.Shutdown()
  61	default:
  62		c.server.logError(r, "Unknown command", "command", req.Command)
  63		jsonError(w, http.StatusBadRequest, "unknown command")
  64		return
  65	}
  66}
  67
  68// handleGetConfig returns global server configuration.
  69//
  70//	@Summary		Get server config
  71//	@Tags			system
  72//	@Produce		json
  73//	@Success		200	{object}	object
  74//	@Router			/config [get]
  75func (c *controllerV1) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
  76	jsonEncode(w, c.backend.Config())
  77}
  78
  79// handleGetWorkspaces lists all workspaces.
  80//
  81//	@Summary		List workspaces
  82//	@Tags			workspaces
  83//	@Produce		json
  84//	@Success		200	{array}		proto.Workspace
  85//	@Router			/workspaces [get]
  86func (c *controllerV1) handleGetWorkspaces(w http.ResponseWriter, _ *http.Request) {
  87	jsonEncode(w, c.backend.ListWorkspaces())
  88}
  89
  90// handleGetWorkspace returns a single workspace by ID.
  91//
  92//	@Summary		Get workspace
  93//	@Tags			workspaces
  94//	@Produce		json
  95//	@Param			id	path		string	true	"Workspace ID"
  96//	@Success		200	{object}	proto.Workspace
  97//	@Failure		404	{object}	proto.Error
  98//	@Failure		500	{object}	proto.Error
  99//	@Router			/workspaces/{id} [get]
 100func (c *controllerV1) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
 101	id := r.PathValue("id")
 102	ws, err := c.backend.GetWorkspaceProto(id)
 103	if err != nil {
 104		c.handleError(w, r, err)
 105		return
 106	}
 107	jsonEncode(w, ws)
 108}
 109
 110// handlePostWorkspaces creates a new workspace.
 111//
 112//	@Summary		Create workspace
 113//	@Tags			workspaces
 114//	@Accept			json
 115//	@Produce		json
 116//	@Param			request	body		proto.Workspace	true	"Workspace creation params"
 117//	@Success		200		{object}	proto.Workspace
 118//	@Failure		400		{object}	proto.Error
 119//	@Failure		500		{object}	proto.Error
 120//	@Router			/workspaces [post]
 121func (c *controllerV1) handlePostWorkspaces(w http.ResponseWriter, r *http.Request) {
 122	var args proto.Workspace
 123	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
 124		c.server.logError(r, "Failed to decode request", "error", err)
 125		jsonError(w, http.StatusBadRequest, "failed to decode request")
 126		return
 127	}
 128
 129	_, result, err := c.backend.CreateWorkspace(args)
 130	if err != nil {
 131		c.handleError(w, r, err)
 132		return
 133	}
 134	jsonEncode(w, result)
 135}
 136
 137// requireClientID reads the client_id query parameter and validates it
 138// as a UUID. On failure it writes a 400 and returns false.
 139func (c *controllerV1) requireClientID(w http.ResponseWriter, r *http.Request) (string, bool) {
 140	cid := r.URL.Query().Get("client_id")
 141	if cid == "" {
 142		c.server.logError(r, "Missing client_id query parameter")
 143		jsonError(w, http.StatusBadRequest, "client_id is required")
 144		return "", false
 145	}
 146	if _, err := uuid.Parse(cid); err != nil {
 147		c.server.logError(r, "Invalid client_id", "error", err)
 148		jsonError(w, http.StatusBadRequest, "client_id is not a valid UUID")
 149		return "", false
 150	}
 151	return cid, true
 152}
 153
 154// handleDeleteWorkspaces deletes a workspace.
 155//
 156//	@Summary		Delete workspace
 157//	@Tags			workspaces
 158//	@Param			id	path	string	true	"Workspace ID"
 159//	@Success		200
 160//	@Failure		404	{object}	proto.Error
 161//	@Router			/workspaces/{id} [delete]
 162func (c *controllerV1) handleDeleteWorkspaces(w http.ResponseWriter, r *http.Request) {
 163	id := r.PathValue("id")
 164	clientID, ok := c.requireClientID(w, r)
 165	if !ok {
 166		return
 167	}
 168	if err := c.backend.DeleteWorkspace(id, clientID); err != nil {
 169		c.handleError(w, r, err)
 170		return
 171	}
 172}
 173
 174// handleGetWorkspaceConfig returns workspace configuration.
 175//
 176//	@Summary		Get workspace config
 177//	@Tags			workspaces
 178//	@Produce		json
 179//	@Param			id	path		string	true	"Workspace ID"
 180//	@Success		200	{object}	object
 181//	@Failure		404	{object}	proto.Error
 182//	@Failure		500	{object}	proto.Error
 183//	@Router			/workspaces/{id}/config [get]
 184func (c *controllerV1) handleGetWorkspaceConfig(w http.ResponseWriter, r *http.Request) {
 185	id := r.PathValue("id")
 186	cfg, err := c.backend.GetWorkspaceConfig(id)
 187	if err != nil {
 188		c.handleError(w, r, err)
 189		return
 190	}
 191	jsonEncode(w, cfg)
 192}
 193
 194// handleGetWorkspaceProviders lists available providers for a workspace.
 195//
 196//	@Summary		Get workspace providers
 197//	@Tags			workspaces
 198//	@Produce		json
 199//	@Param			id	path		string	true	"Workspace ID"
 200//	@Success		200	{object}	object
 201//	@Failure		404	{object}	proto.Error
 202//	@Failure		500	{object}	proto.Error
 203//	@Router			/workspaces/{id}/providers [get]
 204func (c *controllerV1) handleGetWorkspaceProviders(w http.ResponseWriter, r *http.Request) {
 205	id := r.PathValue("id")
 206	providers, err := c.backend.GetWorkspaceProviders(id)
 207	if err != nil {
 208		c.handleError(w, r, err)
 209		return
 210	}
 211	jsonEncode(w, providers)
 212}
 213
 214// handleGetWorkspaceEvents streams workspace events as Server-Sent Events.
 215//
 216//	@Summary		Stream workspace events (SSE)
 217//	@Tags			workspaces
 218//	@Produce		text/event-stream
 219//	@Param			id	path	string	true	"Workspace ID"
 220//	@Success		200
 221//	@Failure		404	{object}	proto.Error
 222//	@Failure		500	{object}	proto.Error
 223//	@Router			/workspaces/{id}/events [get]
 224func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.Request) {
 225	flusher := http.NewResponseController(w)
 226	id := r.PathValue("id")
 227	clientID, ok := c.requireClientID(w, r)
 228	if !ok {
 229		return
 230	}
 231	if err := c.backend.AttachClient(id, clientID); err != nil {
 232		c.handleError(w, r, err)
 233		return
 234	}
 235	defer c.backend.DetachClient(id, clientID)
 236	events, err := c.backend.SubscribeEvents(r.Context(), id)
 237	if err != nil {
 238		c.handleError(w, r, err)
 239		return
 240	}
 241
 242	w.Header().Set("Content-Type", "text/event-stream")
 243	w.Header().Set("Cache-Control", "no-cache")
 244	w.Header().Set("Connection", "keep-alive")
 245
 246	for {
 247		select {
 248		case <-r.Context().Done():
 249			c.server.logDebug(r, "Stopping event stream")
 250			return
 251		case ev, ok := <-events:
 252			if !ok {
 253				return
 254			}
 255			wrapped := wrapEvent(ev.Payload)
 256			if wrapped == nil {
 257				continue
 258			}
 259			data, err := json.Marshal(wrapped)
 260			if err != nil {
 261				c.server.logError(r, "Failed to marshal event", "error", err)
 262				continue
 263			}
 264
 265			fmt.Fprintf(w, "data: %s\n\n", data)
 266			flusher.Flush()
 267		}
 268	}
 269}
 270
 271// handleGetWorkspaceLSPs lists LSP clients for a workspace.
 272//
 273//	@Summary		List LSP clients
 274//	@Tags			lsp
 275//	@Produce		json
 276//	@Param			id	path		string							true	"Workspace ID"
 277//	@Success		200	{object}	map[string]proto.LSPClientInfo
 278//	@Failure		404	{object}	proto.Error
 279//	@Failure		500	{object}	proto.Error
 280//	@Router			/workspaces/{id}/lsps [get]
 281func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Request) {
 282	id := r.PathValue("id")
 283	states, err := c.backend.GetLSPStates(id)
 284	if err != nil {
 285		c.handleError(w, r, err)
 286		return
 287	}
 288	result := make(map[string]proto.LSPClientInfo, len(states))
 289	for k, v := range states {
 290		result[k] = proto.LSPClientInfo{
 291			Name:            v.Name,
 292			State:           v.State,
 293			Error:           v.Error,
 294			DiagnosticCount: v.DiagnosticCount,
 295			ConnectedAt:     v.ConnectedAt,
 296		}
 297	}
 298	jsonEncode(w, result)
 299}
 300
 301// handleGetWorkspaceLSPDiagnostics returns diagnostics for an LSP client.
 302//
 303//	@Summary		Get LSP diagnostics
 304//	@Tags			lsp
 305//	@Produce		json
 306//	@Param			id	path		string	true	"Workspace ID"
 307//	@Param			lsp	path		string	true	"LSP client name"
 308//	@Success		200	{object}	object
 309//	@Failure		404	{object}	proto.Error
 310//	@Failure		500	{object}	proto.Error
 311//	@Router			/workspaces/{id}/lsps/{lsp}/diagnostics [get]
 312func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) {
 313	id := r.PathValue("id")
 314	lspName := r.PathValue("lsp")
 315	diagnostics, err := c.backend.GetLSPDiagnostics(id, lspName)
 316	if err != nil {
 317		c.handleError(w, r, err)
 318		return
 319	}
 320	jsonEncode(w, diagnostics)
 321}
 322
 323// handleGetWorkspaceSessions lists sessions for a workspace.
 324//
 325//	@Summary		List sessions
 326//	@Tags			sessions
 327//	@Produce		json
 328//	@Param			id	path		string			true	"Workspace ID"
 329//	@Success		200	{array}		proto.Session
 330//	@Failure		404	{object}	proto.Error
 331//	@Failure		500	{object}	proto.Error
 332//	@Router			/workspaces/{id}/sessions [get]
 333func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
 334	id := r.PathValue("id")
 335	sessions, err := c.backend.ListSessions(r.Context(), id)
 336	if err != nil {
 337		c.handleError(w, r, err)
 338		return
 339	}
 340	result := make([]proto.Session, len(sessions))
 341	for i, s := range sessions {
 342		result[i] = sessionToProto(s)
 343	}
 344	jsonEncode(w, result)
 345}
 346
 347// handlePostWorkspaceSessions creates a new session in a workspace.
 348//
 349//	@Summary		Create session
 350//	@Tags			sessions
 351//	@Accept			json
 352//	@Produce		json
 353//	@Param			id		path		string			true	"Workspace ID"
 354//	@Param			request	body		proto.Session	true	"Session creation params (title)"
 355//	@Success		200		{object}	proto.Session
 356//	@Failure		400		{object}	proto.Error
 357//	@Failure		404		{object}	proto.Error
 358//	@Failure		500		{object}	proto.Error
 359//	@Router			/workspaces/{id}/sessions [post]
 360func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
 361	id := r.PathValue("id")
 362
 363	var args session.Session
 364	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
 365		c.server.logError(r, "Failed to decode request", "error", err)
 366		jsonError(w, http.StatusBadRequest, "failed to decode request")
 367		return
 368	}
 369
 370	sess, err := c.backend.CreateSession(r.Context(), id, args.Title)
 371	if err != nil {
 372		c.handleError(w, r, err)
 373		return
 374	}
 375	jsonEncode(w, sessionToProto(sess))
 376}
 377
 378// handleGetWorkspaceSession returns a single session.
 379//
 380//	@Summary		Get session
 381//	@Tags			sessions
 382//	@Produce		json
 383//	@Param			id	path		string	true	"Workspace ID"
 384//	@Param			sid	path		string	true	"Session ID"
 385//	@Success		200	{object}	proto.Session
 386//	@Failure		404	{object}	proto.Error
 387//	@Failure		500	{object}	proto.Error
 388//	@Router			/workspaces/{id}/sessions/{sid} [get]
 389func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.Request) {
 390	id := r.PathValue("id")
 391	sid := r.PathValue("sid")
 392	sess, err := c.backend.GetSession(r.Context(), id, sid)
 393	if err != nil {
 394		c.handleError(w, r, err)
 395		return
 396	}
 397	jsonEncode(w, sessionToProto(sess))
 398}
 399
 400// handleGetWorkspaceSessionHistory returns the history for a session.
 401//
 402//	@Summary		Get session history
 403//	@Tags			sessions
 404//	@Produce		json
 405//	@Param			id	path		string		true	"Workspace ID"
 406//	@Param			sid	path		string		true	"Session ID"
 407//	@Success		200	{array}		proto.File
 408//	@Failure		404	{object}	proto.Error
 409//	@Failure		500	{object}	proto.Error
 410//	@Router			/workspaces/{id}/sessions/{sid}/history [get]
 411func (c *controllerV1) handleGetWorkspaceSessionHistory(w http.ResponseWriter, r *http.Request) {
 412	id := r.PathValue("id")
 413	sid := r.PathValue("sid")
 414	history, err := c.backend.ListSessionHistory(r.Context(), id, sid)
 415	if err != nil {
 416		c.handleError(w, r, err)
 417		return
 418	}
 419	jsonEncode(w, history)
 420}
 421
 422// handleGetWorkspaceSessionMessages returns all messages for a session.
 423//
 424//	@Summary		Get session messages
 425//	@Tags			sessions
 426//	@Produce		json
 427//	@Param			id	path		string			true	"Workspace ID"
 428//	@Param			sid	path		string			true	"Session ID"
 429//	@Success		200	{array}		proto.Message
 430//	@Failure		404	{object}	proto.Error
 431//	@Failure		500	{object}	proto.Error
 432//	@Router			/workspaces/{id}/sessions/{sid}/messages [get]
 433func (c *controllerV1) handleGetWorkspaceSessionMessages(w http.ResponseWriter, r *http.Request) {
 434	id := r.PathValue("id")
 435	sid := r.PathValue("sid")
 436	messages, err := c.backend.ListSessionMessages(r.Context(), id, sid)
 437	if err != nil {
 438		c.handleError(w, r, err)
 439		return
 440	}
 441	jsonEncode(w, messagesToProto(messages))
 442}
 443
 444// handlePutWorkspaceSession updates a session.
 445//
 446//	@Summary		Update session
 447//	@Tags			sessions
 448//	@Accept			json
 449//	@Produce		json
 450//	@Param			id		path		string			true	"Workspace ID"
 451//	@Param			sid		path		string			true	"Session ID"
 452//	@Param			request	body		proto.Session	true	"Updated session"
 453//	@Success		200		{object}	proto.Session
 454//	@Failure		400		{object}	proto.Error
 455//	@Failure		404		{object}	proto.Error
 456//	@Failure		500		{object}	proto.Error
 457//	@Router			/workspaces/{id}/sessions/{sid} [put]
 458func (c *controllerV1) handlePutWorkspaceSession(w http.ResponseWriter, r *http.Request) {
 459	id := r.PathValue("id")
 460
 461	var sess session.Session
 462	if err := json.NewDecoder(r.Body).Decode(&sess); err != nil {
 463		c.server.logError(r, "Failed to decode request", "error", err)
 464		jsonError(w, http.StatusBadRequest, "failed to decode request")
 465		return
 466	}
 467
 468	saved, err := c.backend.SaveSession(r.Context(), id, sess)
 469	if err != nil {
 470		c.handleError(w, r, err)
 471		return
 472	}
 473	jsonEncode(w, sessionToProto(saved))
 474}
 475
 476// handleDeleteWorkspaceSession deletes a session.
 477//
 478//	@Summary		Delete session
 479//	@Tags			sessions
 480//	@Param			id	path	string	true	"Workspace ID"
 481//	@Param			sid	path	string	true	"Session ID"
 482//	@Success		200
 483//	@Failure		404	{object}	proto.Error
 484//	@Failure		500	{object}	proto.Error
 485//	@Router			/workspaces/{id}/sessions/{sid} [delete]
 486func (c *controllerV1) handleDeleteWorkspaceSession(w http.ResponseWriter, r *http.Request) {
 487	id := r.PathValue("id")
 488	sid := r.PathValue("sid")
 489	if err := c.backend.DeleteSession(r.Context(), id, sid); err != nil {
 490		c.handleError(w, r, err)
 491		return
 492	}
 493	w.WriteHeader(http.StatusOK)
 494}
 495
 496// handleGetWorkspaceSessionUserMessages returns user messages for a session.
 497//
 498//	@Summary		Get user messages for session
 499//	@Tags			sessions
 500//	@Produce		json
 501//	@Param			id	path		string			true	"Workspace ID"
 502//	@Param			sid	path		string			true	"Session ID"
 503//	@Success		200	{array}		proto.Message
 504//	@Failure		404	{object}	proto.Error
 505//	@Failure		500	{object}	proto.Error
 506//	@Router			/workspaces/{id}/sessions/{sid}/messages/user [get]
 507func (c *controllerV1) handleGetWorkspaceSessionUserMessages(w http.ResponseWriter, r *http.Request) {
 508	id := r.PathValue("id")
 509	sid := r.PathValue("sid")
 510	messages, err := c.backend.ListUserMessages(r.Context(), id, sid)
 511	if err != nil {
 512		c.handleError(w, r, err)
 513		return
 514	}
 515	jsonEncode(w, messagesToProto(messages))
 516}
 517
 518// handleGetWorkspaceAllUserMessages returns all user messages across sessions.
 519//
 520//	@Summary		Get all user messages for workspace
 521//	@Tags			workspaces
 522//	@Produce		json
 523//	@Param			id	path		string			true	"Workspace ID"
 524//	@Success		200	{array}		proto.Message
 525//	@Failure		404	{object}	proto.Error
 526//	@Failure		500	{object}	proto.Error
 527//	@Router			/workspaces/{id}/messages/user [get]
 528func (c *controllerV1) handleGetWorkspaceAllUserMessages(w http.ResponseWriter, r *http.Request) {
 529	id := r.PathValue("id")
 530	messages, err := c.backend.ListAllUserMessages(r.Context(), id)
 531	if err != nil {
 532		c.handleError(w, r, err)
 533		return
 534	}
 535	jsonEncode(w, messagesToProto(messages))
 536}
 537
 538// handleGetWorkspaceSessionFileTrackerFiles lists files read in a session.
 539//
 540//	@Summary		List tracked files for session
 541//	@Tags			filetracker
 542//	@Produce		json
 543//	@Param			id	path		string		true	"Workspace ID"
 544//	@Param			sid	path		string		true	"Session ID"
 545//	@Success		200	{array}		string
 546//	@Failure		404	{object}	proto.Error
 547//	@Failure		500	{object}	proto.Error
 548//	@Router			/workspaces/{id}/sessions/{sid}/filetracker/files [get]
 549func (c *controllerV1) handleGetWorkspaceSessionFileTrackerFiles(w http.ResponseWriter, r *http.Request) {
 550	id := r.PathValue("id")
 551	sid := r.PathValue("sid")
 552	files, err := c.backend.FileTrackerListReadFiles(r.Context(), id, sid)
 553	if err != nil {
 554		c.handleError(w, r, err)
 555		return
 556	}
 557	jsonEncode(w, files)
 558}
 559
 560// handlePostWorkspaceFileTrackerRead records a file read event.
 561//
 562//	@Summary		Record file read
 563//	@Tags			filetracker
 564//	@Accept			json
 565//	@Param			id		path	string							true	"Workspace ID"
 566//	@Param			request	body	proto.FileTrackerReadRequest	true	"File tracker read request"
 567//	@Success		200
 568//	@Failure		400	{object}	proto.Error
 569//	@Failure		404	{object}	proto.Error
 570//	@Failure		500	{object}	proto.Error
 571//	@Router			/workspaces/{id}/filetracker/read [post]
 572func (c *controllerV1) handlePostWorkspaceFileTrackerRead(w http.ResponseWriter, r *http.Request) {
 573	id := r.PathValue("id")
 574
 575	var req proto.FileTrackerReadRequest
 576	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 577		c.server.logError(r, "Failed to decode request", "error", err)
 578		jsonError(w, http.StatusBadRequest, "failed to decode request")
 579		return
 580	}
 581
 582	if err := c.backend.FileTrackerRecordRead(r.Context(), id, req.SessionID, req.Path); err != nil {
 583		c.handleError(w, r, err)
 584		return
 585	}
 586	w.WriteHeader(http.StatusOK)
 587}
 588
 589// handleGetWorkspaceFileTrackerLastRead returns the last read time for a file.
 590//
 591//	@Summary		Get last read time for file
 592//	@Tags			filetracker
 593//	@Produce		json
 594//	@Param			id			path		string	true	"Workspace ID"
 595//	@Param			session_id	query		string	false	"Session ID"
 596//	@Param			path		query		string	true	"File path"
 597//	@Success		200			{object}	object
 598//	@Failure		404			{object}	proto.Error
 599//	@Failure		500			{object}	proto.Error
 600//	@Router			/workspaces/{id}/filetracker/lastread [get]
 601func (c *controllerV1) handleGetWorkspaceFileTrackerLastRead(w http.ResponseWriter, r *http.Request) {
 602	id := r.PathValue("id")
 603	sid := r.URL.Query().Get("session_id")
 604	path := r.URL.Query().Get("path")
 605
 606	t, err := c.backend.FileTrackerLastReadTime(r.Context(), id, sid, path)
 607	if err != nil {
 608		c.handleError(w, r, err)
 609		return
 610	}
 611	jsonEncode(w, t)
 612}
 613
 614// handlePostWorkspaceLSPStart starts an LSP server for a path.
 615//
 616//	@Summary		Start LSP server
 617//	@Tags			lsp
 618//	@Accept			json
 619//	@Param			id		path	string					true	"Workspace ID"
 620//	@Param			request	body	proto.LSPStartRequest	true	"LSP start request"
 621//	@Success		200
 622//	@Failure		400	{object}	proto.Error
 623//	@Failure		404	{object}	proto.Error
 624//	@Failure		500	{object}	proto.Error
 625//	@Router			/workspaces/{id}/lsps/start [post]
 626func (c *controllerV1) handlePostWorkspaceLSPStart(w http.ResponseWriter, r *http.Request) {
 627	id := r.PathValue("id")
 628
 629	var req proto.LSPStartRequest
 630	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 631		c.server.logError(r, "Failed to decode request", "error", err)
 632		jsonError(w, http.StatusBadRequest, "failed to decode request")
 633		return
 634	}
 635
 636	if err := c.backend.LSPStart(r.Context(), id, req.Path); err != nil {
 637		c.handleError(w, r, err)
 638		return
 639	}
 640	w.WriteHeader(http.StatusOK)
 641}
 642
 643// handlePostWorkspaceLSPStopAll stops all LSP servers.
 644//
 645//	@Summary		Stop all LSP servers
 646//	@Tags			lsp
 647//	@Param			id	path	string	true	"Workspace ID"
 648//	@Success		200
 649//	@Failure		404	{object}	proto.Error
 650//	@Failure		500	{object}	proto.Error
 651//	@Router			/workspaces/{id}/lsps/stop [post]
 652func (c *controllerV1) handlePostWorkspaceLSPStopAll(w http.ResponseWriter, r *http.Request) {
 653	id := r.PathValue("id")
 654	if err := c.backend.LSPStopAll(r.Context(), id); err != nil {
 655		c.handleError(w, r, err)
 656		return
 657	}
 658	w.WriteHeader(http.StatusOK)
 659}
 660
 661// handleGetWorkspaceAgent returns agent info for a workspace.
 662//
 663//	@Summary		Get agent info
 664//	@Tags			agent
 665//	@Produce		json
 666//	@Param			id	path		string			true	"Workspace ID"
 667//	@Success		200	{object}	proto.AgentInfo
 668//	@Failure		404	{object}	proto.Error
 669//	@Failure		500	{object}	proto.Error
 670//	@Router			/workspaces/{id}/agent [get]
 671func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
 672	id := r.PathValue("id")
 673	info, err := c.backend.GetAgentInfo(id)
 674	if err != nil {
 675		c.handleError(w, r, err)
 676		return
 677	}
 678	jsonEncode(w, info)
 679}
 680
 681// handlePostWorkspaceAgent sends a message to the agent.
 682//
 683//	@Summary		Send message to agent
 684//	@Tags			agent
 685//	@Accept			json
 686//	@Param			id		path	string				true	"Workspace ID"
 687//	@Param			request	body	proto.AgentMessage	true	"Agent message"
 688//	@Success		200
 689//	@Failure		400	{object}	proto.Error
 690//	@Failure		404	{object}	proto.Error
 691//	@Failure		500	{object}	proto.Error
 692//	@Router			/workspaces/{id}/agent [post]
 693func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
 694	id := r.PathValue("id")
 695
 696	var msg proto.AgentMessage
 697	if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
 698		c.server.logError(r, "Failed to decode request", "error", err)
 699		jsonError(w, http.StatusBadRequest, "failed to decode request")
 700		return
 701	}
 702
 703	if err := c.backend.SendMessage(r.Context(), id, msg); err != nil {
 704		c.handleError(w, r, err)
 705		return
 706	}
 707	w.WriteHeader(http.StatusOK)
 708}
 709
 710// handlePostWorkspaceAgentInit initializes the agent for a workspace.
 711//
 712//	@Summary		Initialize agent
 713//	@Tags			agent
 714//	@Param			id	path	string	true	"Workspace ID"
 715//	@Success		200
 716//	@Failure		404	{object}	proto.Error
 717//	@Failure		500	{object}	proto.Error
 718//	@Router			/workspaces/{id}/agent/init [post]
 719func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
 720	id := r.PathValue("id")
 721	if err := c.backend.InitAgent(r.Context(), id); err != nil {
 722		c.handleError(w, r, err)
 723		return
 724	}
 725	w.WriteHeader(http.StatusOK)
 726}
 727
 728// handlePostWorkspaceAgentUpdate updates the agent for a workspace.
 729//
 730//	@Summary		Update agent
 731//	@Tags			agent
 732//	@Param			id	path	string	true	"Workspace ID"
 733//	@Success		200
 734//	@Failure		404	{object}	proto.Error
 735//	@Failure		500	{object}	proto.Error
 736//	@Router			/workspaces/{id}/agent/update [post]
 737func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) {
 738	id := r.PathValue("id")
 739	if err := c.backend.UpdateAgent(r.Context(), id); err != nil {
 740		c.handleError(w, r, err)
 741		return
 742	}
 743	w.WriteHeader(http.StatusOK)
 744}
 745
 746// handleGetWorkspaceAgentSession returns a specific agent session.
 747//
 748//	@Summary		Get agent session
 749//	@Tags			agent
 750//	@Produce		json
 751//	@Param			id	path		string				true	"Workspace ID"
 752//	@Param			sid	path		string				true	"Session ID"
 753//	@Success		200	{object}	proto.AgentSession
 754//	@Failure		404	{object}	proto.Error
 755//	@Failure		500	{object}	proto.Error
 756//	@Router			/workspaces/{id}/agent/sessions/{sid} [get]
 757func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) {
 758	id := r.PathValue("id")
 759	sid := r.PathValue("sid")
 760	agentSession, err := c.backend.GetAgentSession(r.Context(), id, sid)
 761	if err != nil {
 762		c.handleError(w, r, err)
 763		return
 764	}
 765	jsonEncode(w, agentSession)
 766}
 767
 768// handlePostWorkspaceAgentSessionCancel cancels a running agent session.
 769//
 770//	@Summary		Cancel agent session
 771//	@Tags			agent
 772//	@Param			id	path	string	true	"Workspace ID"
 773//	@Param			sid	path	string	true	"Session ID"
 774//	@Success		200
 775//	@Failure		404	{object}	proto.Error
 776//	@Failure		500	{object}	proto.Error
 777//	@Router			/workspaces/{id}/agent/sessions/{sid}/cancel [post]
 778func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) {
 779	id := r.PathValue("id")
 780	sid := r.PathValue("sid")
 781	if err := c.backend.CancelSession(id, sid); err != nil {
 782		c.handleError(w, r, err)
 783		return
 784	}
 785	w.WriteHeader(http.StatusOK)
 786}
 787
 788// handleGetWorkspaceAgentSessionPromptQueued returns whether a queued prompt exists.
 789//
 790//	@Summary		Get queued prompt status
 791//	@Tags			agent
 792//	@Produce		json
 793//	@Param			id	path		string	true	"Workspace ID"
 794//	@Param			sid	path		string	true	"Session ID"
 795//	@Success		200	{object}	object
 796//	@Failure		404	{object}	proto.Error
 797//	@Failure		500	{object}	proto.Error
 798//	@Router			/workspaces/{id}/agent/sessions/{sid}/prompts/queued [get]
 799func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) {
 800	id := r.PathValue("id")
 801	sid := r.PathValue("sid")
 802	queued, err := c.backend.QueuedPrompts(id, sid)
 803	if err != nil {
 804		c.handleError(w, r, err)
 805		return
 806	}
 807	jsonEncode(w, queued)
 808}
 809
 810// handlePostWorkspaceAgentSessionPromptClear clears the prompt queue for a session.
 811//
 812//	@Summary		Clear prompt queue
 813//	@Tags			agent
 814//	@Param			id	path	string	true	"Workspace ID"
 815//	@Param			sid	path	string	true	"Session ID"
 816//	@Success		200
 817//	@Failure		404	{object}	proto.Error
 818//	@Failure		500	{object}	proto.Error
 819//	@Router			/workspaces/{id}/agent/sessions/{sid}/prompts/clear [post]
 820func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) {
 821	id := r.PathValue("id")
 822	sid := r.PathValue("sid")
 823	if err := c.backend.ClearQueue(id, sid); err != nil {
 824		c.handleError(w, r, err)
 825		return
 826	}
 827	w.WriteHeader(http.StatusOK)
 828}
 829
 830// handlePostWorkspaceAgentSessionSummarize summarizes a session.
 831//
 832//	@Summary		Summarize 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}/summarize [post]
 840func (c *controllerV1) handlePostWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
 841	id := r.PathValue("id")
 842	sid := r.PathValue("sid")
 843	if err := c.backend.SummarizeSession(r.Context(), id, sid); err != nil {
 844		c.handleError(w, r, err)
 845		return
 846	}
 847	w.WriteHeader(http.StatusOK)
 848}
 849
 850// handleGetWorkspaceAgentSessionPromptList returns the list of queued prompts.
 851//
 852//	@Summary		List queued prompts
 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	{array}		string
 858//	@Failure		404	{object}	proto.Error
 859//	@Failure		500	{object}	proto.Error
 860//	@Router			/workspaces/{id}/agent/sessions/{sid}/prompts/list [get]
 861func (c *controllerV1) handleGetWorkspaceAgentSessionPromptList(w http.ResponseWriter, r *http.Request) {
 862	id := r.PathValue("id")
 863	sid := r.PathValue("sid")
 864	prompts, err := c.backend.QueuedPromptsList(id, sid)
 865	if err != nil {
 866		c.handleError(w, r, err)
 867		return
 868	}
 869	jsonEncode(w, prompts)
 870}
 871
 872// handleGetWorkspaceAgentDefaultSmallModel returns the default small model for a provider.
 873//
 874//	@Summary		Get default small model
 875//	@Tags			agent
 876//	@Produce		json
 877//	@Param			id			path		string	true	"Workspace ID"
 878//	@Param			provider_id	query		string	false	"Provider ID"
 879//	@Success		200			{object}	object
 880//	@Failure		404			{object}	proto.Error
 881//	@Failure		500			{object}	proto.Error
 882//	@Router			/workspaces/{id}/agent/default-small-model [get]
 883func (c *controllerV1) handleGetWorkspaceAgentDefaultSmallModel(w http.ResponseWriter, r *http.Request) {
 884	id := r.PathValue("id")
 885	providerID := r.URL.Query().Get("provider_id")
 886	model, err := c.backend.GetDefaultSmallModel(id, providerID)
 887	if err != nil {
 888		c.handleError(w, r, err)
 889		return
 890	}
 891	jsonEncode(w, model)
 892}
 893
 894// handlePostWorkspacePermissionsGrant grants a permission request.
 895//
 896//	@Summary		Grant permission
 897//	@Tags			permissions
 898//	@Accept			json
 899//	@Param			id		path	string				true	"Workspace ID"
 900//	@Param			request	body	proto.PermissionGrant	true	"Permission grant"
 901//	@Success		200	{object}	proto.PermissionGrantResponse
 902//	@Failure		400	{object}	proto.Error
 903//	@Failure		404	{object}	proto.Error
 904//	@Failure		500	{object}	proto.Error
 905//	@Router			/workspaces/{id}/permissions/grant [post]
 906func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) {
 907	id := r.PathValue("id")
 908
 909	var req proto.PermissionGrant
 910	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 911		c.server.logError(r, "Failed to decode request", "error", err)
 912		jsonError(w, http.StatusBadRequest, "failed to decode request")
 913		return
 914	}
 915
 916	resolved, err := c.backend.GrantPermission(id, req)
 917	if err != nil {
 918		c.handleError(w, r, err)
 919		return
 920	}
 921	jsonEncode(w, proto.PermissionGrantResponse{Resolved: resolved})
 922}
 923
 924// handlePostWorkspacePermissionsSkip sets whether to skip permission prompts.
 925//
 926//	@Summary		Set skip permissions
 927//	@Tags			permissions
 928//	@Accept			json
 929//	@Param			id		path	string						true	"Workspace ID"
 930//	@Param			request	body	proto.PermissionSkipRequest	true	"Permission skip request"
 931//	@Success		200
 932//	@Failure		400	{object}	proto.Error
 933//	@Failure		404	{object}	proto.Error
 934//	@Failure		500	{object}	proto.Error
 935//	@Router			/workspaces/{id}/permissions/skip [post]
 936func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
 937	id := r.PathValue("id")
 938
 939	var req proto.PermissionSkipRequest
 940	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 941		c.server.logError(r, "Failed to decode request", "error", err)
 942		jsonError(w, http.StatusBadRequest, "failed to decode request")
 943		return
 944	}
 945
 946	if err := c.backend.SetPermissionsSkip(id, req.Skip); err != nil {
 947		c.handleError(w, r, err)
 948		return
 949	}
 950}
 951
 952// handleGetWorkspacePermissionsSkip returns whether permission prompts are skipped.
 953//
 954//	@Summary		Get skip permissions status
 955//	@Tags			permissions
 956//	@Produce		json
 957//	@Param			id	path		string						true	"Workspace ID"
 958//	@Success		200	{object}	proto.PermissionSkipRequest
 959//	@Failure		404	{object}	proto.Error
 960//	@Failure		500	{object}	proto.Error
 961//	@Router			/workspaces/{id}/permissions/skip [get]
 962func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
 963	id := r.PathValue("id")
 964	skip, err := c.backend.GetPermissionsSkip(id)
 965	if err != nil {
 966		c.handleError(w, r, err)
 967		return
 968	}
 969	jsonEncode(w, proto.PermissionSkipRequest{Skip: skip})
 970}
 971
 972// handleError maps backend errors to HTTP status codes and writes the
 973// JSON error response.
 974func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err error) {
 975	status := http.StatusInternalServerError
 976	switch {
 977	case errors.Is(err, backend.ErrWorkspaceNotFound):
 978		status = http.StatusNotFound
 979	case errors.Is(err, backend.ErrLSPClientNotFound):
 980		status = http.StatusNotFound
 981	case errors.Is(err, backend.ErrAgentNotInitialized):
 982		status = http.StatusBadRequest
 983	case errors.Is(err, backend.ErrPathRequired):
 984		status = http.StatusBadRequest
 985	case errors.Is(err, backend.ErrInvalidPermissionAction):
 986		status = http.StatusBadRequest
 987	case errors.Is(err, backend.ErrUnknownCommand):
 988		status = http.StatusBadRequest
 989	case errors.Is(err, backend.ErrInvalidClientID):
 990		status = http.StatusBadRequest
 991	}
 992	c.server.logError(r, err.Error())
 993	jsonError(w, status, err.Error())
 994}
 995
 996func jsonEncode(w http.ResponseWriter, v any) {
 997	w.Header().Set("Content-Type", "application/json")
 998	_ = json.NewEncoder(w).Encode(v)
 999}
1000
1001func jsonError(w http.ResponseWriter, status int, message string) {
1002	w.Header().Set("Content-Type", "application/json")
1003	w.WriteHeader(status)
1004	_ = json.NewEncoder(w).Encode(proto.Error{Message: message})
1005}