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// handlePostWorkspaceCurrentSession records the calling client's
 155// current session selection for the workspace. An empty session_id
 156// clears the entry (e.g. the client is on the landing screen).
 157//
 158//	@Summary		Set current session for a client
 159//	@Tags			workspaces
 160//	@Accept			json
 161//	@Produce		json
 162//	@Param			id			path	string					true	"Workspace ID"
 163//	@Param			client_id	query	string					true	"Client ID (UUID)"
 164//	@Param			request		body	proto.CurrentSession	true	"Current session selection"
 165//	@Success		200
 166//	@Failure		400	{object}	proto.Error
 167//	@Failure		404	{object}	proto.Error
 168//	@Router			/workspaces/{id}/current-session [post]
 169func (c *controllerV1) handlePostWorkspaceCurrentSession(w http.ResponseWriter, r *http.Request) {
 170	id := r.PathValue("id")
 171	clientID, ok := c.requireClientID(w, r)
 172	if !ok {
 173		return
 174	}
 175	var req proto.CurrentSession
 176	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 177		c.server.logError(r, "Failed to decode request", "error", err)
 178		jsonError(w, http.StatusBadRequest, "failed to decode request")
 179		return
 180	}
 181	if err := c.backend.SetCurrentSession(id, clientID, req.SessionID); err != nil {
 182		c.handleError(w, r, err)
 183		return
 184	}
 185}
 186
 187// handleDeleteWorkspaces deletes a workspace.
 188//
 189//	@Summary		Delete workspace
 190//	@Tags			workspaces
 191//	@Param			id	path	string	true	"Workspace ID"
 192//	@Success		200
 193//	@Failure		404	{object}	proto.Error
 194//	@Router			/workspaces/{id} [delete]
 195func (c *controllerV1) handleDeleteWorkspaces(w http.ResponseWriter, r *http.Request) {
 196	id := r.PathValue("id")
 197	clientID, ok := c.requireClientID(w, r)
 198	if !ok {
 199		return
 200	}
 201	if err := c.backend.DeleteWorkspace(id, clientID); err != nil {
 202		c.handleError(w, r, err)
 203		return
 204	}
 205}
 206
 207// handleGetWorkspaceConfig returns workspace configuration.
 208//
 209//	@Summary		Get workspace config
 210//	@Tags			workspaces
 211//	@Produce		json
 212//	@Param			id	path		string	true	"Workspace ID"
 213//	@Success		200	{object}	object
 214//	@Failure		404	{object}	proto.Error
 215//	@Failure		500	{object}	proto.Error
 216//	@Router			/workspaces/{id}/config [get]
 217func (c *controllerV1) handleGetWorkspaceConfig(w http.ResponseWriter, r *http.Request) {
 218	id := r.PathValue("id")
 219	cfg, err := c.backend.GetWorkspaceConfig(id)
 220	if err != nil {
 221		c.handleError(w, r, err)
 222		return
 223	}
 224	jsonEncode(w, cfg)
 225}
 226
 227// handleGetWorkspaceProviders lists available providers for a workspace.
 228//
 229//	@Summary		Get workspace providers
 230//	@Tags			workspaces
 231//	@Produce		json
 232//	@Param			id	path		string	true	"Workspace ID"
 233//	@Success		200	{object}	object
 234//	@Failure		404	{object}	proto.Error
 235//	@Failure		500	{object}	proto.Error
 236//	@Router			/workspaces/{id}/providers [get]
 237func (c *controllerV1) handleGetWorkspaceProviders(w http.ResponseWriter, r *http.Request) {
 238	id := r.PathValue("id")
 239	providers, err := c.backend.GetWorkspaceProviders(id)
 240	if err != nil {
 241		c.handleError(w, r, err)
 242		return
 243	}
 244	jsonEncode(w, providers)
 245}
 246
 247// handleGetWorkspaceEvents streams workspace events as Server-Sent Events.
 248//
 249//	@Summary		Stream workspace events (SSE)
 250//	@Tags			workspaces
 251//	@Produce		text/event-stream
 252//	@Param			id	path	string	true	"Workspace ID"
 253//	@Success		200
 254//	@Failure		404	{object}	proto.Error
 255//	@Failure		500	{object}	proto.Error
 256//	@Router			/workspaces/{id}/events [get]
 257func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.Request) {
 258	flusher := http.NewResponseController(w)
 259	id := r.PathValue("id")
 260	clientID, ok := c.requireClientID(w, r)
 261	if !ok {
 262		return
 263	}
 264	if err := c.backend.AttachClient(id, clientID); err != nil {
 265		c.handleError(w, r, err)
 266		return
 267	}
 268	defer c.backend.DetachClient(id, clientID)
 269	events, err := c.backend.SubscribeEvents(r.Context(), id)
 270	if err != nil {
 271		c.handleError(w, r, err)
 272		return
 273	}
 274
 275	w.Header().Set("Content-Type", "text/event-stream")
 276	w.Header().Set("Cache-Control", "no-cache")
 277	w.Header().Set("Connection", "keep-alive")
 278	// Flush headers immediately so clients see the 200 response
 279	// before any events arrive. Without this, a quiet workspace
 280	// keeps the client's SubscribeEvents call blocked on the
 281	// initial RoundTrip.
 282	w.WriteHeader(http.StatusOK)
 283	flusher.Flush()
 284
 285	for {
 286		select {
 287		case <-r.Context().Done():
 288			c.server.logDebug(r, "Stopping event stream")
 289			return
 290		case ev, ok := <-events:
 291			if !ok {
 292				return
 293			}
 294			wrapped := wrapEvent(ev.Payload)
 295			if wrapped == nil {
 296				continue
 297			}
 298			data, err := json.Marshal(wrapped)
 299			if err != nil {
 300				c.server.logError(r, "Failed to marshal event", "error", err)
 301				continue
 302			}
 303
 304			fmt.Fprintf(w, "data: %s\n\n", data)
 305			flusher.Flush()
 306		}
 307	}
 308}
 309
 310// handleGetWorkspaceLSPs lists LSP clients for a workspace.
 311//
 312//	@Summary		List LSP clients
 313//	@Tags			lsp
 314//	@Produce		json
 315//	@Param			id	path		string							true	"Workspace ID"
 316//	@Success		200	{object}	map[string]proto.LSPClientInfo
 317//	@Failure		404	{object}	proto.Error
 318//	@Failure		500	{object}	proto.Error
 319//	@Router			/workspaces/{id}/lsps [get]
 320func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Request) {
 321	id := r.PathValue("id")
 322	states, err := c.backend.GetLSPStates(id)
 323	if err != nil {
 324		c.handleError(w, r, err)
 325		return
 326	}
 327	result := make(map[string]proto.LSPClientInfo, len(states))
 328	for k, v := range states {
 329		result[k] = proto.LSPClientInfo{
 330			Name:            v.Name,
 331			State:           v.State,
 332			Error:           v.Error,
 333			DiagnosticCount: v.DiagnosticCount,
 334			ConnectedAt:     v.ConnectedAt,
 335		}
 336	}
 337	jsonEncode(w, result)
 338}
 339
 340// handleGetWorkspaceLSPDiagnostics returns diagnostics for an LSP client.
 341//
 342//	@Summary		Get LSP diagnostics
 343//	@Tags			lsp
 344//	@Produce		json
 345//	@Param			id	path		string	true	"Workspace ID"
 346//	@Param			lsp	path		string	true	"LSP client name"
 347//	@Success		200	{object}	object
 348//	@Failure		404	{object}	proto.Error
 349//	@Failure		500	{object}	proto.Error
 350//	@Router			/workspaces/{id}/lsps/{lsp}/diagnostics [get]
 351func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) {
 352	id := r.PathValue("id")
 353	lspName := r.PathValue("lsp")
 354	diagnostics, err := c.backend.GetLSPDiagnostics(id, lspName)
 355	if err != nil {
 356		c.handleError(w, r, err)
 357		return
 358	}
 359	jsonEncode(w, diagnostics)
 360}
 361
 362// handleGetWorkspaceSessions lists sessions for a workspace.
 363//
 364//	@Summary		List sessions
 365//	@Tags			sessions
 366//	@Produce		json
 367//	@Param			id	path		string			true	"Workspace ID"
 368//	@Success		200	{array}		proto.Session
 369//	@Failure		404	{object}	proto.Error
 370//	@Failure		500	{object}	proto.Error
 371//	@Router			/workspaces/{id}/sessions [get]
 372func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
 373	id := r.PathValue("id")
 374	sessions, err := c.backend.ListSessions(r.Context(), id)
 375	if err != nil {
 376		c.handleError(w, r, err)
 377		return
 378	}
 379	ws, _ := c.backend.GetWorkspace(id)
 380	result := make([]proto.Session, len(sessions))
 381	for i, s := range sessions {
 382		result[i] = sessionToProto(s)
 383		result[i].IsBusy = isSessionBusy(ws, s.ID)
 384	}
 385	jsonEncode(w, result)
 386}
 387
 388// handlePostWorkspaceSessions creates a new session in a workspace.
 389//
 390//	@Summary		Create session
 391//	@Tags			sessions
 392//	@Accept			json
 393//	@Produce		json
 394//	@Param			id		path		string			true	"Workspace ID"
 395//	@Param			request	body		proto.Session	true	"Session creation params (title)"
 396//	@Success		200		{object}	proto.Session
 397//	@Failure		400		{object}	proto.Error
 398//	@Failure		404		{object}	proto.Error
 399//	@Failure		500		{object}	proto.Error
 400//	@Router			/workspaces/{id}/sessions [post]
 401func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
 402	id := r.PathValue("id")
 403
 404	var args session.Session
 405	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
 406		c.server.logError(r, "Failed to decode request", "error", err)
 407		jsonError(w, http.StatusBadRequest, "failed to decode request")
 408		return
 409	}
 410
 411	sess, err := c.backend.CreateSession(r.Context(), id, args.Title)
 412	if err != nil {
 413		c.handleError(w, r, err)
 414		return
 415	}
 416	ws, _ := c.backend.GetWorkspace(id)
 417	out := sessionToProto(sess)
 418	out.IsBusy = isSessionBusy(ws, sess.ID)
 419	jsonEncode(w, out)
 420}
 421
 422// handleGetWorkspaceSession returns a single session.
 423//
 424//	@Summary		Get session
 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	{object}	proto.Session
 430//	@Failure		404	{object}	proto.Error
 431//	@Failure		500	{object}	proto.Error
 432//	@Router			/workspaces/{id}/sessions/{sid} [get]
 433func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.Request) {
 434	id := r.PathValue("id")
 435	sid := r.PathValue("sid")
 436	sess, err := c.backend.GetSession(r.Context(), id, sid)
 437	if err != nil {
 438		c.handleError(w, r, err)
 439		return
 440	}
 441	ws, _ := c.backend.GetWorkspace(id)
 442	out := sessionToProto(sess)
 443	out.IsBusy = isSessionBusy(ws, sess.ID)
 444	jsonEncode(w, out)
 445}
 446
 447// handleGetWorkspaceSessionHistory returns the history for a session.
 448//
 449//	@Summary		Get session history
 450//	@Tags			sessions
 451//	@Produce		json
 452//	@Param			id	path		string		true	"Workspace ID"
 453//	@Param			sid	path		string		true	"Session ID"
 454//	@Success		200	{array}		proto.File
 455//	@Failure		404	{object}	proto.Error
 456//	@Failure		500	{object}	proto.Error
 457//	@Router			/workspaces/{id}/sessions/{sid}/history [get]
 458func (c *controllerV1) handleGetWorkspaceSessionHistory(w http.ResponseWriter, r *http.Request) {
 459	id := r.PathValue("id")
 460	sid := r.PathValue("sid")
 461	history, err := c.backend.ListSessionHistory(r.Context(), id, sid)
 462	if err != nil {
 463		c.handleError(w, r, err)
 464		return
 465	}
 466	jsonEncode(w, history)
 467}
 468
 469// handleGetWorkspaceSessionMessages returns all messages for a session.
 470//
 471//	@Summary		Get session messages
 472//	@Tags			sessions
 473//	@Produce		json
 474//	@Param			id	path		string			true	"Workspace ID"
 475//	@Param			sid	path		string			true	"Session ID"
 476//	@Success		200	{array}		proto.Message
 477//	@Failure		404	{object}	proto.Error
 478//	@Failure		500	{object}	proto.Error
 479//	@Router			/workspaces/{id}/sessions/{sid}/messages [get]
 480func (c *controllerV1) handleGetWorkspaceSessionMessages(w http.ResponseWriter, r *http.Request) {
 481	id := r.PathValue("id")
 482	sid := r.PathValue("sid")
 483	messages, err := c.backend.ListSessionMessages(r.Context(), id, sid)
 484	if err != nil {
 485		c.handleError(w, r, err)
 486		return
 487	}
 488	jsonEncode(w, messagesToProto(messages))
 489}
 490
 491// handlePutWorkspaceSession updates a session.
 492//
 493//	@Summary		Update session
 494//	@Tags			sessions
 495//	@Accept			json
 496//	@Produce		json
 497//	@Param			id		path		string			true	"Workspace ID"
 498//	@Param			sid		path		string			true	"Session ID"
 499//	@Param			request	body		proto.Session	true	"Updated session"
 500//	@Success		200		{object}	proto.Session
 501//	@Failure		400		{object}	proto.Error
 502//	@Failure		404		{object}	proto.Error
 503//	@Failure		500		{object}	proto.Error
 504//	@Router			/workspaces/{id}/sessions/{sid} [put]
 505func (c *controllerV1) handlePutWorkspaceSession(w http.ResponseWriter, r *http.Request) {
 506	id := r.PathValue("id")
 507
 508	var sess session.Session
 509	if err := json.NewDecoder(r.Body).Decode(&sess); err != nil {
 510		c.server.logError(r, "Failed to decode request", "error", err)
 511		jsonError(w, http.StatusBadRequest, "failed to decode request")
 512		return
 513	}
 514
 515	saved, err := c.backend.SaveSession(r.Context(), id, sess)
 516	if err != nil {
 517		c.handleError(w, r, err)
 518		return
 519	}
 520	ws, _ := c.backend.GetWorkspace(id)
 521	out := sessionToProto(saved)
 522	out.IsBusy = isSessionBusy(ws, saved.ID)
 523	jsonEncode(w, out)
 524}
 525
 526// handleDeleteWorkspaceSession deletes a session.
 527//
 528//	@Summary		Delete session
 529//	@Tags			sessions
 530//	@Param			id	path	string	true	"Workspace ID"
 531//	@Param			sid	path	string	true	"Session ID"
 532//	@Success		200
 533//	@Failure		404	{object}	proto.Error
 534//	@Failure		500	{object}	proto.Error
 535//	@Router			/workspaces/{id}/sessions/{sid} [delete]
 536func (c *controllerV1) handleDeleteWorkspaceSession(w http.ResponseWriter, r *http.Request) {
 537	id := r.PathValue("id")
 538	sid := r.PathValue("sid")
 539	if err := c.backend.DeleteSession(r.Context(), id, sid); err != nil {
 540		c.handleError(w, r, err)
 541		return
 542	}
 543	w.WriteHeader(http.StatusOK)
 544}
 545
 546// handleGetWorkspaceSessionUserMessages returns user messages for a session.
 547//
 548//	@Summary		Get user messages for session
 549//	@Tags			sessions
 550//	@Produce		json
 551//	@Param			id	path		string			true	"Workspace ID"
 552//	@Param			sid	path		string			true	"Session ID"
 553//	@Success		200	{array}		proto.Message
 554//	@Failure		404	{object}	proto.Error
 555//	@Failure		500	{object}	proto.Error
 556//	@Router			/workspaces/{id}/sessions/{sid}/messages/user [get]
 557func (c *controllerV1) handleGetWorkspaceSessionUserMessages(w http.ResponseWriter, r *http.Request) {
 558	id := r.PathValue("id")
 559	sid := r.PathValue("sid")
 560	messages, err := c.backend.ListUserMessages(r.Context(), id, sid)
 561	if err != nil {
 562		c.handleError(w, r, err)
 563		return
 564	}
 565	jsonEncode(w, messagesToProto(messages))
 566}
 567
 568// handleGetWorkspaceAllUserMessages returns all user messages across sessions.
 569//
 570//	@Summary		Get all user messages for workspace
 571//	@Tags			workspaces
 572//	@Produce		json
 573//	@Param			id	path		string			true	"Workspace ID"
 574//	@Success		200	{array}		proto.Message
 575//	@Failure		404	{object}	proto.Error
 576//	@Failure		500	{object}	proto.Error
 577//	@Router			/workspaces/{id}/messages/user [get]
 578func (c *controllerV1) handleGetWorkspaceAllUserMessages(w http.ResponseWriter, r *http.Request) {
 579	id := r.PathValue("id")
 580	messages, err := c.backend.ListAllUserMessages(r.Context(), id)
 581	if err != nil {
 582		c.handleError(w, r, err)
 583		return
 584	}
 585	jsonEncode(w, messagesToProto(messages))
 586}
 587
 588// handleGetWorkspaceSessionFileTrackerFiles lists files read in a session.
 589//
 590//	@Summary		List tracked files for session
 591//	@Tags			filetracker
 592//	@Produce		json
 593//	@Param			id	path		string		true	"Workspace ID"
 594//	@Param			sid	path		string		true	"Session ID"
 595//	@Success		200	{array}		string
 596//	@Failure		404	{object}	proto.Error
 597//	@Failure		500	{object}	proto.Error
 598//	@Router			/workspaces/{id}/sessions/{sid}/filetracker/files [get]
 599func (c *controllerV1) handleGetWorkspaceSessionFileTrackerFiles(w http.ResponseWriter, r *http.Request) {
 600	id := r.PathValue("id")
 601	sid := r.PathValue("sid")
 602	files, err := c.backend.FileTrackerListReadFiles(r.Context(), id, sid)
 603	if err != nil {
 604		c.handleError(w, r, err)
 605		return
 606	}
 607	jsonEncode(w, files)
 608}
 609
 610// handlePostWorkspaceFileTrackerRead records a file read event.
 611//
 612//	@Summary		Record file read
 613//	@Tags			filetracker
 614//	@Accept			json
 615//	@Param			id		path	string							true	"Workspace ID"
 616//	@Param			request	body	proto.FileTrackerReadRequest	true	"File tracker read request"
 617//	@Success		200
 618//	@Failure		400	{object}	proto.Error
 619//	@Failure		404	{object}	proto.Error
 620//	@Failure		500	{object}	proto.Error
 621//	@Router			/workspaces/{id}/filetracker/read [post]
 622func (c *controllerV1) handlePostWorkspaceFileTrackerRead(w http.ResponseWriter, r *http.Request) {
 623	id := r.PathValue("id")
 624
 625	var req proto.FileTrackerReadRequest
 626	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 627		c.server.logError(r, "Failed to decode request", "error", err)
 628		jsonError(w, http.StatusBadRequest, "failed to decode request")
 629		return
 630	}
 631
 632	if err := c.backend.FileTrackerRecordRead(r.Context(), id, req.SessionID, req.Path); err != nil {
 633		c.handleError(w, r, err)
 634		return
 635	}
 636	w.WriteHeader(http.StatusOK)
 637}
 638
 639// handleGetWorkspaceFileTrackerLastRead returns the last read time for a file.
 640//
 641//	@Summary		Get last read time for file
 642//	@Tags			filetracker
 643//	@Produce		json
 644//	@Param			id			path		string	true	"Workspace ID"
 645//	@Param			session_id	query		string	false	"Session ID"
 646//	@Param			path		query		string	true	"File path"
 647//	@Success		200			{object}	object
 648//	@Failure		404			{object}	proto.Error
 649//	@Failure		500			{object}	proto.Error
 650//	@Router			/workspaces/{id}/filetracker/lastread [get]
 651func (c *controllerV1) handleGetWorkspaceFileTrackerLastRead(w http.ResponseWriter, r *http.Request) {
 652	id := r.PathValue("id")
 653	sid := r.URL.Query().Get("session_id")
 654	path := r.URL.Query().Get("path")
 655
 656	t, err := c.backend.FileTrackerLastReadTime(r.Context(), id, sid, path)
 657	if err != nil {
 658		c.handleError(w, r, err)
 659		return
 660	}
 661	jsonEncode(w, t)
 662}
 663
 664// handlePostWorkspaceLSPStart starts an LSP server for a path.
 665//
 666//	@Summary		Start LSP server
 667//	@Tags			lsp
 668//	@Accept			json
 669//	@Param			id		path	string					true	"Workspace ID"
 670//	@Param			request	body	proto.LSPStartRequest	true	"LSP start request"
 671//	@Success		200
 672//	@Failure		400	{object}	proto.Error
 673//	@Failure		404	{object}	proto.Error
 674//	@Failure		500	{object}	proto.Error
 675//	@Router			/workspaces/{id}/lsps/start [post]
 676func (c *controllerV1) handlePostWorkspaceLSPStart(w http.ResponseWriter, r *http.Request) {
 677	id := r.PathValue("id")
 678
 679	var req proto.LSPStartRequest
 680	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 681		c.server.logError(r, "Failed to decode request", "error", err)
 682		jsonError(w, http.StatusBadRequest, "failed to decode request")
 683		return
 684	}
 685
 686	if err := c.backend.LSPStart(r.Context(), id, req.Path); err != nil {
 687		c.handleError(w, r, err)
 688		return
 689	}
 690	w.WriteHeader(http.StatusOK)
 691}
 692
 693// handlePostWorkspaceLSPStopAll stops all LSP servers.
 694//
 695//	@Summary		Stop all LSP servers
 696//	@Tags			lsp
 697//	@Param			id	path	string	true	"Workspace ID"
 698//	@Success		200
 699//	@Failure		404	{object}	proto.Error
 700//	@Failure		500	{object}	proto.Error
 701//	@Router			/workspaces/{id}/lsps/stop [post]
 702func (c *controllerV1) handlePostWorkspaceLSPStopAll(w http.ResponseWriter, r *http.Request) {
 703	id := r.PathValue("id")
 704	if err := c.backend.LSPStopAll(r.Context(), id); err != nil {
 705		c.handleError(w, r, err)
 706		return
 707	}
 708	w.WriteHeader(http.StatusOK)
 709}
 710
 711// handleGetWorkspaceAgent returns agent info for a workspace.
 712//
 713//	@Summary		Get agent info
 714//	@Tags			agent
 715//	@Produce		json
 716//	@Param			id	path		string			true	"Workspace ID"
 717//	@Success		200	{object}	proto.AgentInfo
 718//	@Failure		404	{object}	proto.Error
 719//	@Failure		500	{object}	proto.Error
 720//	@Router			/workspaces/{id}/agent [get]
 721func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
 722	id := r.PathValue("id")
 723	info, err := c.backend.GetAgentInfo(id)
 724	if err != nil {
 725		c.handleError(w, r, err)
 726		return
 727	}
 728	jsonEncode(w, info)
 729}
 730
 731// handlePostWorkspaceAgent sends a message to the agent.
 732//
 733//	@Summary		Send message to agent
 734//	@Tags			agent
 735//	@Accept			json
 736//	@Param			id		path	string				true	"Workspace ID"
 737//	@Param			request	body	proto.AgentMessage	true	"Agent message"
 738//	@Success		200
 739//	@Failure		400	{object}	proto.Error
 740//	@Failure		404	{object}	proto.Error
 741//	@Failure		500	{object}	proto.Error
 742//	@Router			/workspaces/{id}/agent [post]
 743func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
 744	id := r.PathValue("id")
 745
 746	var msg proto.AgentMessage
 747	if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
 748		c.server.logError(r, "Failed to decode request", "error", err)
 749		jsonError(w, http.StatusBadRequest, "failed to decode request")
 750		return
 751	}
 752
 753	if err := c.backend.SendMessage(r.Context(), id, msg); err != nil {
 754		c.handleError(w, r, err)
 755		return
 756	}
 757	w.WriteHeader(http.StatusOK)
 758}
 759
 760// handlePostWorkspaceAgentInit initializes the agent for a workspace.
 761//
 762//	@Summary		Initialize agent
 763//	@Tags			agent
 764//	@Param			id	path	string	true	"Workspace ID"
 765//	@Success		200
 766//	@Failure		404	{object}	proto.Error
 767//	@Failure		500	{object}	proto.Error
 768//	@Router			/workspaces/{id}/agent/init [post]
 769func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
 770	id := r.PathValue("id")
 771	if err := c.backend.InitAgent(r.Context(), id); err != nil {
 772		c.handleError(w, r, err)
 773		return
 774	}
 775	w.WriteHeader(http.StatusOK)
 776}
 777
 778// handlePostWorkspaceAgentUpdate updates the agent for a workspace.
 779//
 780//	@Summary		Update agent
 781//	@Tags			agent
 782//	@Param			id	path	string	true	"Workspace ID"
 783//	@Success		200
 784//	@Failure		404	{object}	proto.Error
 785//	@Failure		500	{object}	proto.Error
 786//	@Router			/workspaces/{id}/agent/update [post]
 787func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) {
 788	id := r.PathValue("id")
 789	if err := c.backend.UpdateAgent(r.Context(), id); err != nil {
 790		c.handleError(w, r, err)
 791		return
 792	}
 793	w.WriteHeader(http.StatusOK)
 794}
 795
 796// handleGetWorkspaceAgentSession returns a specific agent session.
 797//
 798//	@Summary		Get agent session
 799//	@Tags			agent
 800//	@Produce		json
 801//	@Param			id	path		string				true	"Workspace ID"
 802//	@Param			sid	path		string				true	"Session ID"
 803//	@Success		200	{object}	proto.AgentSession
 804//	@Failure		404	{object}	proto.Error
 805//	@Failure		500	{object}	proto.Error
 806//	@Router			/workspaces/{id}/agent/sessions/{sid} [get]
 807func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) {
 808	id := r.PathValue("id")
 809	sid := r.PathValue("sid")
 810	agentSession, err := c.backend.GetAgentSession(r.Context(), id, sid)
 811	if err != nil {
 812		c.handleError(w, r, err)
 813		return
 814	}
 815	jsonEncode(w, agentSession)
 816}
 817
 818// handlePostWorkspaceAgentSessionCancel cancels a running agent session.
 819//
 820//	@Summary		Cancel agent session
 821//	@Tags			agent
 822//	@Param			id	path	string	true	"Workspace ID"
 823//	@Param			sid	path	string	true	"Session ID"
 824//	@Success		200
 825//	@Failure		404	{object}	proto.Error
 826//	@Failure		500	{object}	proto.Error
 827//	@Router			/workspaces/{id}/agent/sessions/{sid}/cancel [post]
 828func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) {
 829	id := r.PathValue("id")
 830	sid := r.PathValue("sid")
 831	if err := c.backend.CancelSession(id, sid); err != nil {
 832		c.handleError(w, r, err)
 833		return
 834	}
 835	w.WriteHeader(http.StatusOK)
 836}
 837
 838// handleGetWorkspaceAgentSessionPromptQueued returns whether a queued prompt exists.
 839//
 840//	@Summary		Get queued prompt status
 841//	@Tags			agent
 842//	@Produce		json
 843//	@Param			id	path		string	true	"Workspace ID"
 844//	@Param			sid	path		string	true	"Session ID"
 845//	@Success		200	{object}	object
 846//	@Failure		404	{object}	proto.Error
 847//	@Failure		500	{object}	proto.Error
 848//	@Router			/workspaces/{id}/agent/sessions/{sid}/prompts/queued [get]
 849func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) {
 850	id := r.PathValue("id")
 851	sid := r.PathValue("sid")
 852	queued, err := c.backend.QueuedPrompts(id, sid)
 853	if err != nil {
 854		c.handleError(w, r, err)
 855		return
 856	}
 857	jsonEncode(w, queued)
 858}
 859
 860// handlePostWorkspaceAgentSessionPromptClear clears the prompt queue for a session.
 861//
 862//	@Summary		Clear prompt queue
 863//	@Tags			agent
 864//	@Param			id	path	string	true	"Workspace ID"
 865//	@Param			sid	path	string	true	"Session ID"
 866//	@Success		200
 867//	@Failure		404	{object}	proto.Error
 868//	@Failure		500	{object}	proto.Error
 869//	@Router			/workspaces/{id}/agent/sessions/{sid}/prompts/clear [post]
 870func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) {
 871	id := r.PathValue("id")
 872	sid := r.PathValue("sid")
 873	if err := c.backend.ClearQueue(id, sid); err != nil {
 874		c.handleError(w, r, err)
 875		return
 876	}
 877	w.WriteHeader(http.StatusOK)
 878}
 879
 880// handlePostWorkspaceAgentSessionSummarize summarizes a session.
 881//
 882//	@Summary		Summarize session
 883//	@Tags			agent
 884//	@Param			id	path	string	true	"Workspace ID"
 885//	@Param			sid	path	string	true	"Session ID"
 886//	@Success		200
 887//	@Failure		404	{object}	proto.Error
 888//	@Failure		500	{object}	proto.Error
 889//	@Router			/workspaces/{id}/agent/sessions/{sid}/summarize [post]
 890func (c *controllerV1) handlePostWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
 891	id := r.PathValue("id")
 892	sid := r.PathValue("sid")
 893	if err := c.backend.SummarizeSession(r.Context(), id, sid); err != nil {
 894		c.handleError(w, r, err)
 895		return
 896	}
 897	w.WriteHeader(http.StatusOK)
 898}
 899
 900// handleGetWorkspaceAgentSessionPromptList returns the list of queued prompts.
 901//
 902//	@Summary		List queued prompts
 903//	@Tags			agent
 904//	@Produce		json
 905//	@Param			id	path		string		true	"Workspace ID"
 906//	@Param			sid	path		string		true	"Session ID"
 907//	@Success		200	{array}		string
 908//	@Failure		404	{object}	proto.Error
 909//	@Failure		500	{object}	proto.Error
 910//	@Router			/workspaces/{id}/agent/sessions/{sid}/prompts/list [get]
 911func (c *controllerV1) handleGetWorkspaceAgentSessionPromptList(w http.ResponseWriter, r *http.Request) {
 912	id := r.PathValue("id")
 913	sid := r.PathValue("sid")
 914	prompts, err := c.backend.QueuedPromptsList(id, sid)
 915	if err != nil {
 916		c.handleError(w, r, err)
 917		return
 918	}
 919	jsonEncode(w, prompts)
 920}
 921
 922// handleGetWorkspaceAgentDefaultSmallModel returns the default small model for a provider.
 923//
 924//	@Summary		Get default small model
 925//	@Tags			agent
 926//	@Produce		json
 927//	@Param			id			path		string	true	"Workspace ID"
 928//	@Param			provider_id	query		string	false	"Provider ID"
 929//	@Success		200			{object}	object
 930//	@Failure		404			{object}	proto.Error
 931//	@Failure		500			{object}	proto.Error
 932//	@Router			/workspaces/{id}/agent/default-small-model [get]
 933func (c *controllerV1) handleGetWorkspaceAgentDefaultSmallModel(w http.ResponseWriter, r *http.Request) {
 934	id := r.PathValue("id")
 935	providerID := r.URL.Query().Get("provider_id")
 936	model, err := c.backend.GetDefaultSmallModel(id, providerID)
 937	if err != nil {
 938		c.handleError(w, r, err)
 939		return
 940	}
 941	jsonEncode(w, model)
 942}
 943
 944// handlePostWorkspacePermissionsGrant grants a permission request.
 945//
 946//	@Summary		Grant permission
 947//	@Tags			permissions
 948//	@Accept			json
 949//	@Param			id		path	string				true	"Workspace ID"
 950//	@Param			request	body	proto.PermissionGrant	true	"Permission grant"
 951//	@Success		200	{object}	proto.PermissionGrantResponse
 952//	@Failure		400	{object}	proto.Error
 953//	@Failure		404	{object}	proto.Error
 954//	@Failure		500	{object}	proto.Error
 955//	@Router			/workspaces/{id}/permissions/grant [post]
 956func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) {
 957	id := r.PathValue("id")
 958
 959	var req proto.PermissionGrant
 960	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 961		c.server.logError(r, "Failed to decode request", "error", err)
 962		jsonError(w, http.StatusBadRequest, "failed to decode request")
 963		return
 964	}
 965
 966	resolved, err := c.backend.GrantPermission(id, req)
 967	if err != nil {
 968		c.handleError(w, r, err)
 969		return
 970	}
 971	jsonEncode(w, proto.PermissionGrantResponse{Resolved: resolved})
 972}
 973
 974// handlePostWorkspacePermissionsSkip sets whether to skip permission prompts.
 975//
 976//	@Summary		Set skip permissions
 977//	@Tags			permissions
 978//	@Accept			json
 979//	@Param			id		path	string						true	"Workspace ID"
 980//	@Param			request	body	proto.PermissionSkipRequest	true	"Permission skip request"
 981//	@Success		200
 982//	@Failure		400	{object}	proto.Error
 983//	@Failure		404	{object}	proto.Error
 984//	@Failure		500	{object}	proto.Error
 985//	@Router			/workspaces/{id}/permissions/skip [post]
 986func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
 987	id := r.PathValue("id")
 988
 989	var req proto.PermissionSkipRequest
 990	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 991		c.server.logError(r, "Failed to decode request", "error", err)
 992		jsonError(w, http.StatusBadRequest, "failed to decode request")
 993		return
 994	}
 995
 996	if err := c.backend.SetPermissionsSkip(id, req.Skip); err != nil {
 997		c.handleError(w, r, err)
 998		return
 999	}
1000}
1001
1002// handleGetWorkspacePermissionsSkip returns whether permission prompts are skipped.
1003//
1004//	@Summary		Get skip permissions status
1005//	@Tags			permissions
1006//	@Produce		json
1007//	@Param			id	path		string						true	"Workspace ID"
1008//	@Success		200	{object}	proto.PermissionSkipRequest
1009//	@Failure		404	{object}	proto.Error
1010//	@Failure		500	{object}	proto.Error
1011//	@Router			/workspaces/{id}/permissions/skip [get]
1012func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
1013	id := r.PathValue("id")
1014	skip, err := c.backend.GetPermissionsSkip(id)
1015	if err != nil {
1016		c.handleError(w, r, err)
1017		return
1018	}
1019	jsonEncode(w, proto.PermissionSkipRequest{Skip: skip})
1020}
1021
1022// handleError maps backend errors to HTTP status codes and writes the
1023// JSON error response.
1024func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err error) {
1025	status := http.StatusInternalServerError
1026	switch {
1027	case errors.Is(err, backend.ErrWorkspaceNotFound):
1028		status = http.StatusNotFound
1029	case errors.Is(err, backend.ErrLSPClientNotFound):
1030		status = http.StatusNotFound
1031	case errors.Is(err, backend.ErrAgentNotInitialized):
1032		status = http.StatusBadRequest
1033	case errors.Is(err, backend.ErrPathRequired):
1034		status = http.StatusBadRequest
1035	case errors.Is(err, backend.ErrInvalidPermissionAction):
1036		status = http.StatusBadRequest
1037	case errors.Is(err, backend.ErrUnknownCommand):
1038		status = http.StatusBadRequest
1039	case errors.Is(err, backend.ErrInvalidClientID):
1040		status = http.StatusBadRequest
1041	case errors.Is(err, backend.ErrClientNotAttached):
1042		status = http.StatusNotFound
1043	}
1044	c.server.logError(r, err.Error())
1045	jsonError(w, status, err.Error())
1046}
1047
1048func jsonEncode(w http.ResponseWriter, v any) {
1049	w.Header().Set("Content-Type", "application/json")
1050	_ = json.NewEncoder(w).Encode(v)
1051}
1052
1053func jsonError(w http.ResponseWriter, status int, message string) {
1054	w.Header().Set("Content-Type", "application/json")
1055	w.WriteHeader(status)
1056	_ = json.NewEncoder(w).Encode(proto.Error{Message: message})
1057}