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		200
 743//	@Failure		400	{object}	proto.Error
 744//	@Failure		404	{object}	proto.Error
 745//	@Failure		500	{object}	proto.Error
 746//	@Router			/workspaces/{id}/agent [post]
 747func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
 748	id := r.PathValue("id")
 749
 750	var msg proto.AgentMessage
 751	if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
 752		c.server.logError(r, "Failed to decode request", "error", err)
 753		jsonError(w, http.StatusBadRequest, "failed to decode request")
 754		return
 755	}
 756
 757	if err := c.backend.SendMessage(r.Context(), id, msg); err != nil {
 758		c.handleError(w, r, err)
 759		return
 760	}
 761	w.WriteHeader(http.StatusOK)
 762}
 763
 764// handlePostWorkspaceAgentInit initializes the agent for a workspace.
 765//
 766//	@Summary		Initialize agent
 767//	@Tags			agent
 768//	@Param			id	path	string	true	"Workspace ID"
 769//	@Success		200
 770//	@Failure		404	{object}	proto.Error
 771//	@Failure		500	{object}	proto.Error
 772//	@Router			/workspaces/{id}/agent/init [post]
 773func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
 774	id := r.PathValue("id")
 775	if err := c.backend.InitAgent(r.Context(), id); err != nil {
 776		c.handleError(w, r, err)
 777		return
 778	}
 779	w.WriteHeader(http.StatusOK)
 780}
 781
 782// handlePostWorkspaceAgentUpdate updates the agent for a workspace.
 783//
 784//	@Summary		Update agent
 785//	@Tags			agent
 786//	@Param			id	path	string	true	"Workspace ID"
 787//	@Success		200
 788//	@Failure		404	{object}	proto.Error
 789//	@Failure		500	{object}	proto.Error
 790//	@Router			/workspaces/{id}/agent/update [post]
 791func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) {
 792	id := r.PathValue("id")
 793	if err := c.backend.UpdateAgent(r.Context(), id); err != nil {
 794		c.handleError(w, r, err)
 795		return
 796	}
 797	w.WriteHeader(http.StatusOK)
 798}
 799
 800// handleGetWorkspaceAgentSession returns a specific agent session.
 801//
 802//	@Summary		Get agent session
 803//	@Tags			agent
 804//	@Produce		json
 805//	@Param			id	path		string				true	"Workspace ID"
 806//	@Param			sid	path		string				true	"Session ID"
 807//	@Success		200	{object}	proto.AgentSession
 808//	@Failure		404	{object}	proto.Error
 809//	@Failure		500	{object}	proto.Error
 810//	@Router			/workspaces/{id}/agent/sessions/{sid} [get]
 811func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) {
 812	id := r.PathValue("id")
 813	sid := r.PathValue("sid")
 814	agentSession, err := c.backend.GetAgentSession(r.Context(), id, sid)
 815	if err != nil {
 816		c.handleError(w, r, err)
 817		return
 818	}
 819	jsonEncode(w, agentSession)
 820}
 821
 822// handlePostWorkspaceAgentSessionCancel cancels a running agent session.
 823//
 824//	@Summary		Cancel agent session
 825//	@Tags			agent
 826//	@Param			id	path	string	true	"Workspace ID"
 827//	@Param			sid	path	string	true	"Session ID"
 828//	@Success		200
 829//	@Failure		404	{object}	proto.Error
 830//	@Failure		500	{object}	proto.Error
 831//	@Router			/workspaces/{id}/agent/sessions/{sid}/cancel [post]
 832func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) {
 833	id := r.PathValue("id")
 834	sid := r.PathValue("sid")
 835	if err := c.backend.CancelSession(id, sid); err != nil {
 836		c.handleError(w, r, err)
 837		return
 838	}
 839	w.WriteHeader(http.StatusOK)
 840}
 841
 842// handleGetWorkspaceAgentSessionPromptQueued returns whether a queued prompt exists.
 843//
 844//	@Summary		Get queued prompt status
 845//	@Tags			agent
 846//	@Produce		json
 847//	@Param			id	path		string	true	"Workspace ID"
 848//	@Param			sid	path		string	true	"Session ID"
 849//	@Success		200	{object}	object
 850//	@Failure		404	{object}	proto.Error
 851//	@Failure		500	{object}	proto.Error
 852//	@Router			/workspaces/{id}/agent/sessions/{sid}/prompts/queued [get]
 853func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) {
 854	id := r.PathValue("id")
 855	sid := r.PathValue("sid")
 856	queued, err := c.backend.QueuedPrompts(id, sid)
 857	if err != nil {
 858		c.handleError(w, r, err)
 859		return
 860	}
 861	jsonEncode(w, queued)
 862}
 863
 864// handlePostWorkspaceAgentSessionPromptClear clears the prompt queue for a session.
 865//
 866//	@Summary		Clear prompt queue
 867//	@Tags			agent
 868//	@Param			id	path	string	true	"Workspace ID"
 869//	@Param			sid	path	string	true	"Session ID"
 870//	@Success		200
 871//	@Failure		404	{object}	proto.Error
 872//	@Failure		500	{object}	proto.Error
 873//	@Router			/workspaces/{id}/agent/sessions/{sid}/prompts/clear [post]
 874func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) {
 875	id := r.PathValue("id")
 876	sid := r.PathValue("sid")
 877	if err := c.backend.ClearQueue(id, sid); err != nil {
 878		c.handleError(w, r, err)
 879		return
 880	}
 881	w.WriteHeader(http.StatusOK)
 882}
 883
 884// handlePostWorkspaceAgentSessionSummarize summarizes a session.
 885//
 886//	@Summary		Summarize session
 887//	@Tags			agent
 888//	@Param			id	path	string	true	"Workspace ID"
 889//	@Param			sid	path	string	true	"Session ID"
 890//	@Success		200
 891//	@Failure		404	{object}	proto.Error
 892//	@Failure		500	{object}	proto.Error
 893//	@Router			/workspaces/{id}/agent/sessions/{sid}/summarize [post]
 894func (c *controllerV1) handlePostWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
 895	id := r.PathValue("id")
 896	sid := r.PathValue("sid")
 897	if err := c.backend.SummarizeSession(r.Context(), id, sid); err != nil {
 898		c.handleError(w, r, err)
 899		return
 900	}
 901	w.WriteHeader(http.StatusOK)
 902}
 903
 904// handleGetWorkspaceAgentSessionPromptList returns the list of queued prompts.
 905//
 906//	@Summary		List queued prompts
 907//	@Tags			agent
 908//	@Produce		json
 909//	@Param			id	path		string		true	"Workspace ID"
 910//	@Param			sid	path		string		true	"Session ID"
 911//	@Success		200	{array}		string
 912//	@Failure		404	{object}	proto.Error
 913//	@Failure		500	{object}	proto.Error
 914//	@Router			/workspaces/{id}/agent/sessions/{sid}/prompts/list [get]
 915func (c *controllerV1) handleGetWorkspaceAgentSessionPromptList(w http.ResponseWriter, r *http.Request) {
 916	id := r.PathValue("id")
 917	sid := r.PathValue("sid")
 918	prompts, err := c.backend.QueuedPromptsList(id, sid)
 919	if err != nil {
 920		c.handleError(w, r, err)
 921		return
 922	}
 923	jsonEncode(w, prompts)
 924}
 925
 926// handleGetWorkspaceAgentDefaultSmallModel returns the default small model for a provider.
 927//
 928//	@Summary		Get default small model
 929//	@Tags			agent
 930//	@Produce		json
 931//	@Param			id			path		string	true	"Workspace ID"
 932//	@Param			provider_id	query		string	false	"Provider ID"
 933//	@Success		200			{object}	object
 934//	@Failure		404			{object}	proto.Error
 935//	@Failure		500			{object}	proto.Error
 936//	@Router			/workspaces/{id}/agent/default-small-model [get]
 937func (c *controllerV1) handleGetWorkspaceAgentDefaultSmallModel(w http.ResponseWriter, r *http.Request) {
 938	id := r.PathValue("id")
 939	providerID := r.URL.Query().Get("provider_id")
 940	model, err := c.backend.GetDefaultSmallModel(id, providerID)
 941	if err != nil {
 942		c.handleError(w, r, err)
 943		return
 944	}
 945	jsonEncode(w, model)
 946}
 947
 948// handlePostWorkspacePermissionsGrant grants a permission request.
 949//
 950//	@Summary		Grant permission
 951//	@Tags			permissions
 952//	@Accept			json
 953//	@Param			id		path	string				true	"Workspace ID"
 954//	@Param			request	body	proto.PermissionGrant	true	"Permission grant"
 955//	@Success		200	{object}	proto.PermissionGrantResponse
 956//	@Failure		400	{object}	proto.Error
 957//	@Failure		404	{object}	proto.Error
 958//	@Failure		500	{object}	proto.Error
 959//	@Router			/workspaces/{id}/permissions/grant [post]
 960func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) {
 961	id := r.PathValue("id")
 962
 963	var req proto.PermissionGrant
 964	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 965		c.server.logError(r, "Failed to decode request", "error", err)
 966		jsonError(w, http.StatusBadRequest, "failed to decode request")
 967		return
 968	}
 969
 970	resolved, err := c.backend.GrantPermission(id, req)
 971	if err != nil {
 972		c.handleError(w, r, err)
 973		return
 974	}
 975	jsonEncode(w, proto.PermissionGrantResponse{Resolved: resolved})
 976}
 977
 978// handlePostWorkspacePermissionsSkip sets whether to skip permission prompts.
 979//
 980//	@Summary		Set skip permissions
 981//	@Tags			permissions
 982//	@Accept			json
 983//	@Param			id		path	string						true	"Workspace ID"
 984//	@Param			request	body	proto.PermissionSkipRequest	true	"Permission skip request"
 985//	@Success		200
 986//	@Failure		400	{object}	proto.Error
 987//	@Failure		404	{object}	proto.Error
 988//	@Failure		500	{object}	proto.Error
 989//	@Router			/workspaces/{id}/permissions/skip [post]
 990func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
 991	id := r.PathValue("id")
 992
 993	var req proto.PermissionSkipRequest
 994	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 995		c.server.logError(r, "Failed to decode request", "error", err)
 996		jsonError(w, http.StatusBadRequest, "failed to decode request")
 997		return
 998	}
 999
1000	if err := c.backend.SetPermissionsSkip(id, req.Skip); err != nil {
1001		c.handleError(w, r, err)
1002		return
1003	}
1004}
1005
1006// handleGetWorkspacePermissionsSkip returns whether permission prompts are skipped.
1007//
1008//	@Summary		Get skip permissions status
1009//	@Tags			permissions
1010//	@Produce		json
1011//	@Param			id	path		string						true	"Workspace ID"
1012//	@Success		200	{object}	proto.PermissionSkipRequest
1013//	@Failure		404	{object}	proto.Error
1014//	@Failure		500	{object}	proto.Error
1015//	@Router			/workspaces/{id}/permissions/skip [get]
1016func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
1017	id := r.PathValue("id")
1018	skip, err := c.backend.GetPermissionsSkip(id)
1019	if err != nil {
1020		c.handleError(w, r, err)
1021		return
1022	}
1023	jsonEncode(w, proto.PermissionSkipRequest{Skip: skip})
1024}
1025
1026// handleError maps backend errors to HTTP status codes and writes the
1027// JSON error response.
1028func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err error) {
1029	status := http.StatusInternalServerError
1030	switch {
1031	case errors.Is(err, backend.ErrWorkspaceNotFound):
1032		status = http.StatusNotFound
1033	case errors.Is(err, backend.ErrLSPClientNotFound):
1034		status = http.StatusNotFound
1035	case errors.Is(err, backend.ErrAgentNotInitialized):
1036		status = http.StatusBadRequest
1037	case errors.Is(err, backend.ErrPathRequired):
1038		status = http.StatusBadRequest
1039	case errors.Is(err, backend.ErrInvalidPermissionAction):
1040		status = http.StatusBadRequest
1041	case errors.Is(err, backend.ErrUnknownCommand):
1042		status = http.StatusBadRequest
1043	case errors.Is(err, backend.ErrInvalidClientID):
1044		status = http.StatusBadRequest
1045	case errors.Is(err, backend.ErrClientNotAttached):
1046		status = http.StatusNotFound
1047	}
1048	c.server.logError(r, err.Error())
1049	jsonError(w, status, err.Error())
1050}
1051
1052func jsonEncode(w http.ResponseWriter, v any) {
1053	w.Header().Set("Content-Type", "application/json")
1054	_ = json.NewEncoder(w).Encode(v)
1055}
1056
1057func jsonError(w http.ResponseWriter, status int, message string) {
1058	w.Header().Set("Content-Type", "application/json")
1059	w.WriteHeader(status)
1060	_ = json.NewEncoder(w).Encode(proto.Error{Message: message})
1061}