package handlers import ( "encoding/json" "net/http" "os" "github.com/gin-gonic/gin" "sproutclaw-web/internal/config" "sproutclaw-web/internal/db" "sproutclaw-web/internal/models" "sproutclaw-web/internal/rpc" "sproutclaw-web/internal/services" "sproutclaw-web/internal/utils" ) // RegisterSessions registers all session management routes. func RegisterSessions(r *gin.RouterGroup, cfg *config.Config, database *db.DB, piClient *rpc.Client) { r.GET("/sessions", handleListSessions(cfg, database)) r.POST("/new-session", handleNewSession(piClient)) r.POST("/sessions/history", handleSessionHistory(cfg)) r.POST("/sessions/delete", handleDeleteSession(cfg, database)) r.POST("/sessions/pin", handlePinSession(cfg, database)) r.POST("/sessions/load", handleLoadSession(cfg, piClient)) r.POST("/sessions/activate", handleActivateSession(cfg, piClient)) r.POST("/sessions/name", handleNameSession(cfg)) r.GET("/session-state", handleSessionState(piClient)) } func handleListSessions(cfg *config.Config, database *db.DB) gin.HandlerFunc { return func(c *gin.Context) { summaries, err := services.BuildSessionList(cfg.SessionsDir, database) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } if summaries == nil { summaries = []models.SessionSummary{} } c.JSON(http.StatusOK, models.SessionsResponse{Sessions: summaries}) } } func handleNewSession(piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { resp, err := piClient.SendCmd(models.RPCCommand{Type: "new_session"}) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } if !resp.Success { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error}) return } // merge sessionFile from a follow-up get_state out := map[string]any{} if len(resp.Data) > 0 { _ = jsonUnmarshalInto(resp.Data, &out) } if state, serr := piClient.SendCmd(models.RPCCommand{Type: "get_state"}); serr == nil && state.Success { var sd struct { SessionFile string `json:"sessionFile"` } if jsonUnmarshalInto(state.Data, &sd) == nil && sd.SessionFile != "" { out["sessionFile"] = sd.SessionFile } } c.JSON(http.StatusOK, out) } } func handleSessionHistory(cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { var req models.HistoryRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) { c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"}) return } lines, err := services.ReadSessionMessages(req.Path) if err != nil { c.JSON(http.StatusNotFound, models.ErrorResponse{Error: err.Error()}) return } c.JSON(http.StatusOK, gin.H{"messages": lines}) } } func handleDeleteSession(cfg *config.Config, database *db.DB) gin.HandlerFunc { return func(c *gin.Context) { var req models.DeleteSessionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) { c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"}) return } if err := os.Remove(req.Path); err != nil && !os.IsNotExist(err) { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } _ = database.SetSessionPinned(req.Path, false) c.JSON(http.StatusOK, models.OKResponse{OK: true}) } } func handlePinSession(cfg *config.Config, database *db.DB) gin.HandlerFunc { return func(c *gin.Context) { var req models.PinSessionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) { c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"}) return } if err := database.SetSessionPinned(req.Path, req.Pinned); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } c.JSON(http.StatusOK, models.OKResponse{OK: true}) } } func handleLoadSession(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { var req models.LoadSessionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) { c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"}) return } sw, err := piClient.SendCmd(models.RPCCommand{ Type: "switch_session", SessionPath: req.Path, CwdOverride: cfg.RepoRoot, }) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } if !sw.Success { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: sw.Error}) return } mr, err := piClient.SendCmd(models.RPCCommand{Type: "get_messages"}) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } if !mr.Success { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: mr.Error}) return } // mr.Data is { messages: [...] }; forward it plus a session summary summary, _ := services.ReadSessionSummaryByPath(req.Path) c.JSON(http.StatusOK, gin.H{"messages": extractMessages(mr.Data), "session": summary}) } } func handleActivateSession(cfg *config.Config, piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { var req models.ActivateSessionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) { c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"}) return } sw, err := piClient.SendCmd(models.RPCCommand{ Type: "switch_session", SessionPath: req.Path, CwdOverride: cfg.RepoRoot, }) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } if !sw.Success { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: sw.Error}) return } state, err := piClient.SendCmd(models.RPCCommand{Type: "get_state"}) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "state": jsonRaw(state.Data)}) } } func handleNameSession(cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { var req models.NameSessionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()}) return } if !utils.IsPathInsideRoot(cfg.SessionsDir, req.Path) { c.JSON(http.StatusForbidden, models.ErrorResponse{Error: "invalid path"}) return } if err := services.AppendSessionName(req.Path, req.Name); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } c.JSON(http.StatusOK, models.OKResponse{OK: true}) } } func handleSessionState(piClient *rpc.Client) gin.HandlerFunc { return func(c *gin.Context) { resp, err := piClient.SendCmd(models.RPCCommand{Type: "get_state"}) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()}) return } if !resp.Success { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: resp.Error}) return } c.JSON(http.StatusOK, jsonRaw(resp.Data)) } } // extractMessages pulls the "messages" array out of a get_messages data payload. func extractMessages(data json.RawMessage) any { if len(data) == 0 { return []any{} } var wrap struct { Messages json.RawMessage `json:"messages"` } if jsonUnmarshalInto(data, &wrap) == nil && len(wrap.Messages) > 0 { return jsonRaw(wrap.Messages) } return jsonRaw(data) } func jsonUnmarshalInto(raw json.RawMessage, v any) error { if len(raw) == 0 { return nil } return json.Unmarshal(raw, v) }