Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions cmd/sgai/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ func isEditorAvailable(command string) bool {
type Server struct {
mu sync.Mutex
sessions map[string]*session
ideSessions map[string]*ideSession
browserSessions map[string]*browserSession
everStartedDirs map[string]bool
pinnedDirs map[string]bool
pinnedConfigDir string
Expand All @@ -161,6 +163,11 @@ type Server struct {
editorName string
editor editorOpener
shutdownCtx context.Context
ideRuntime ideRuntime
ideNow func() time.Time
ideAccessTTL time.Duration
ideIdleTimeout time.Duration
browserSessionTTL time.Duration

signals *signalBroker

Expand All @@ -179,6 +186,12 @@ type Server struct {
workspaceListCache *ttlCache[string, apiWorkspaceListResponse]
workspacePageFlight singleflight[string, apiWorkspaceFullState]
workspacePageCache *ttlCache[string, apiWorkspaceFullState]
stateFlight singleflight[string, apiFactoryState]
stateCache *ttlCache[string, apiFactoryState]
ideStatusFlight singleflight[string, ideRuntimeStatus]
ideStatusCache *ttlCache[string, ideRuntimeStatus]
ideStartFlight singleflight[string, ideSessionStartResult]
stateGeneration uint64

goalTitleComposer func(workspacePath string, goalContent []byte) (string, error)
goalTitleReadFile func(path string) ([]byte, error)
Expand Down Expand Up @@ -208,13 +221,20 @@ func NewServer(rootDir string, paths serverPaths, editorConfig string) *Server {
return &Server{
mu: sync.Mutex{},
sessions: make(map[string]*session),
ideSessions: make(map[string]*ideSession),
browserSessions: make(map[string]*browserSession),
everStartedDirs: make(map[string]bool),
pinnedDirs: make(map[string]bool),
pinnedConfigDir: paths.pinnedConfigDir,
externalDirs: make(map[string]bool),
externalConfigDir: paths.externalConfigDir,
adhocStates: make(map[string]*adhocPromptState),
shutdownCtx: context.Background(),
ideRuntime: newDockerIDERuntime(),
ideNow: time.Now,
ideAccessTTL: defaultIDEAccessTTL,
ideIdleTimeout: defaultIDEIdleTimeout,
browserSessionTTL: defaultBrowserSessionTTL,
signals: newSignalBroker(),
rootDir: absRootDir,
editorAvailable: editorAvail,
Expand All @@ -226,13 +246,19 @@ func NewServer(rootDir string, paths serverPaths, editorConfig string) *Server {
svgCache: newTTLCache[string, string](10 * time.Second),
workspaceListCache: newTTLCache[string, apiWorkspaceListResponse](3 * time.Second),
workspacePageCache: newTTLCache[string, apiWorkspaceFullState](3 * time.Second),
stateCache: newTTLCache[string, apiFactoryState](30 * time.Second),
ideStatusCache: newTTLCache[string, ideRuntimeStatus](defaultIDEStatusTTL),
promptActionRunner: nil,
scriptActionRunner: nil,
workspaceScanFlight: singleflight[string, []workspaceGroup]{mu: sync.Mutex{}, calls: nil},
classifyFlight: singleflight[string, workspaceKind]{mu: sync.Mutex{}, calls: nil},
svgFlight: singleflight[string, string]{mu: sync.Mutex{}, calls: nil},
workspaceListFlight: singleflight[string, apiWorkspaceListResponse]{mu: sync.Mutex{}, calls: nil},
workspacePageFlight: singleflight[string, apiWorkspaceFullState]{mu: sync.Mutex{}, calls: nil},
stateFlight: singleflight[string, apiFactoryState]{mu: sync.Mutex{}, calls: nil},
ideStatusFlight: singleflight[string, ideRuntimeStatus]{mu: sync.Mutex{}, calls: nil},
ideStartFlight: singleflight[string, ideSessionStartResult]{mu: sync.Mutex{}, calls: nil},
stateGeneration: 0,
goalTitleComposer: defaultGoalTitleComposer,
goalTitleReadFile: os.ReadFile,
goalTitleRepairMu: sync.Mutex{},
Expand Down Expand Up @@ -725,6 +751,7 @@ func cmdServe(args []string) {

mux := http.NewServeMux()
srv.registerAPIRoutes(mux)
srv.registerIDERoutes(mux)
externalMCPHandler, errExternalMCP := buildExternalMCPHandler(srv)
if errExternalMCP != nil {
log.Println("failed to build external MCP handler:", errExternalMCP)
Expand Down
66 changes: 65 additions & 1 deletion cmd/sgai/serve_api.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -149,6 +150,7 @@ func (s *signalSubscriber) nextPendingEvent() (signalEvent, bool) {
}

func (s *Server) registerAPIRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/state", s.handleAPIState)
mux.HandleFunc("GET /api/v1/signal", s.handleSignalStream)
mux.HandleFunc("GET /api/v1/workspaces", s.handleAPIWorkspaceList)
mux.HandleFunc("GET /api/v1/workspaces/{name}/state", s.handleAPIWorkspaceState)
Expand Down Expand Up @@ -178,6 +180,8 @@ func (s *Server) registerAPIRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/workspaces/{name}/workflow.svg", s.handleAPIWorkflowSVG)
mux.HandleFunc("POST /api/v1/workspaces/{name}/steer", s.handleAPISteer)
mux.HandleFunc("POST /api/v1/workspaces/{name}/pin", s.handleAPITogglePin)
mux.HandleFunc("GET /api/v1/workspaces/{id}/ide", s.handleAPIIDEStatus)
mux.HandleFunc("POST /api/v1/workspaces/{id}/ide/access", s.handleAPIIDEAccess)
mux.HandleFunc("POST /api/v1/workspaces/{name}/open-editor", s.handleAPIOpenEditor)
mux.HandleFunc("GET /api/v1/models", s.handleAPIListModels)

Expand Down Expand Up @@ -228,6 +232,10 @@ func (s *Server) handleSignalStream(w http.ResponseWriter, r *http.Request) {
}
}

type apiFactoryState struct {
Workspaces []apiWorkspaceFullState `json:"workspaces"`
}

type apiWorkspaceListResponse struct {
Workspaces []apiWorkspaceListEntry `json:"workspaces"`
}
Expand Down Expand Up @@ -308,6 +316,7 @@ type apiWorkspaceFullState struct {
Actions []apiActionEntry `json:"actions,omitempty"`
ActionConfigError string `json:"actionConfigError,omitempty"`
RepositoryAction apiRepositoryAction `json:"repositoryAction"`
IDE apiWorkspaceIDEState `json:"ide"`
}

func (s *Server) handleAPIWorkspaceList(w http.ResponseWriter, _ *http.Request) {
Expand Down Expand Up @@ -349,6 +358,33 @@ func (s *Server) resolveWorkspacePageFromPath(w http.ResponseWriter, r *http.Req
return workspacePath, true
}

func (s *Server) handleAPIState(w http.ResponseWriter, r *http.Request) {
if ok := s.ensureBrowserSession(w, r); !ok {
return
}
if cached, ok := s.stateCache.get("state"); ok {
writeJSON(w, cached)
return
}
factoryState, _ := s.stateFlight.do("state", func() (apiFactoryState, error) {
if cached, ok := s.stateCache.get("state"); ok {
return cached, nil
}
s.mu.Lock()
genBefore := s.stateGeneration
s.mu.Unlock()
result := s.buildFullFactoryState()
s.mu.Lock()
genAfter := s.stateGeneration
s.mu.Unlock()
if genBefore == genAfter {
s.stateCache.set("state", result)
}
return result, nil
})
writeJSON(w, factoryState)
}

func (s *Server) loadWorkspaceListResponse() apiWorkspaceListResponse {
if cached, ok := s.workspaceListCache.get("workspaces"); ok {
return cached
Expand Down Expand Up @@ -403,6 +439,25 @@ func (s *Server) loadWorkspaceState(dir string) state.Workflow {
return s.workspaceCoordinator(dir).State()
}

func (s *Server) buildFullFactoryState() apiFactoryState {
groups, errScan := s.scanWorkspaceGroups()
if errScan != nil {
return apiFactoryState{Workspaces: nil}
}

allWorkspaces := workspaceInfos(groups)
workspaces := make([]apiWorkspaceFullState, len(allWorkspaces))
var wg sync.WaitGroup
for i, ws := range allWorkspaces {
wg.Go(func() {
workspaces[i] = s.buildWorkspaceFullState(ws, groups)
})
}
wg.Wait()

return apiFactoryState{Workspaces: workspaces}
}

func (s *Server) buildWorkspaceListResponse() apiWorkspaceListResponse {
groups, errScan := s.scanWorkspaceGroups()
if errScan != nil {
Expand Down Expand Up @@ -615,6 +670,7 @@ func (s *Server) buildWorkspaceFullState(ws workspaceInfo, groups []workspaceGro
Actions: actionState.Actions,
ActionConfigError: actionState.ConfigError,
RepositoryAction: repositoryAction.api(ws.DirName),
IDE: s.buildWorkspaceIDEState(context.Background(), ws.Directory),
}

if ws.IsRoot {
Expand Down Expand Up @@ -705,7 +761,7 @@ func (s *Server) spaMiddleware(mux *http.ServeMux) http.Handler {
}

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isAPIRoute(r.URL.Path) {
if isBackendPassthroughRoute(r.URL.Path) {
mux.ServeHTTP(w, r)
return
}
Expand All @@ -720,6 +776,10 @@ func (s *Server) spaMiddleware(mux *http.ServeMux) http.Handler {
return
}

if ok := s.ensureBrowserSession(w, r); !ok {
return
}

serveReactIndex(w, webappFS)
})
}
Expand All @@ -728,6 +788,10 @@ func isAPIRoute(urlPath string) bool {
return strings.HasPrefix(urlPath, "/api/") || strings.HasPrefix(urlPath, "/mcp/")
}

func isBackendPassthroughRoute(urlPath string) bool {
return isAPIRoute(urlPath) || isIDEProxyRoute(urlPath)
}

func isStaticAsset(urlPath string) bool {
ext := path.Ext(urlPath)
switch ext {
Expand Down
Loading