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