proto.go

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