proto.go

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