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