ide/runtime.go

269 lines
6.8 KiB
Go
Raw Normal View History

package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"sync"
"time"
"forge.lthn.ai/core/api"
"forge.lthn.ai/core/api/pkg/provider"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-scm/manifest"
"forge.lthn.ai/core/go-scm/marketplace"
"github.com/gin-gonic/gin"
)
// RuntimeProvider represents a running provider process with its proxy.
type RuntimeProvider struct {
Dir string
Manifest *manifest.Manifest
Port int
Cmd *exec.Cmd
}
// RuntimeManager discovers, starts, and stops runtime provider processes.
// Each provider runs as a separate binary on 127.0.0.1, reverse-proxied
// through the IDE's Gin router via ProxyProvider.
type RuntimeManager struct {
engine *api.Engine
providers []*RuntimeProvider
mu sync.Mutex
}
// NewRuntimeManager creates a RuntimeManager.
func NewRuntimeManager(engine *api.Engine) *RuntimeManager {
return &RuntimeManager{
engine: engine,
}
}
// defaultProvidersDir returns ~/.core/providers/.
func defaultProvidersDir() string {
home, err := os.UserHomeDir()
if err != nil {
home = os.TempDir()
}
return filepath.Join(home, ".core", "providers")
}
// StartAll discovers providers in ~/.core/providers/ and starts each one.
// Providers that fail to start are logged and skipped — they do not prevent
// other providers from starting.
func (rm *RuntimeManager) StartAll(ctx context.Context) error {
rm.mu.Lock()
defer rm.mu.Unlock()
dir := defaultProvidersDir()
discovered, err := marketplace.DiscoverProviders(dir)
if err != nil {
return coreerr.E("runtime.StartAll", "discover providers", err)
}
if len(discovered) == 0 {
log.Println("runtime: no providers found in", dir)
return nil
}
log.Printf("runtime: discovered %d provider(s) in %s", len(discovered), dir)
for _, dp := range discovered {
rp, err := rm.startProvider(ctx, dp)
if err != nil {
log.Printf("runtime: failed to start %s: %v", dp.Manifest.Code, err)
continue
}
rm.providers = append(rm.providers, rp)
log.Printf("runtime: started %s on port %d", dp.Manifest.Code, rp.Port)
}
return nil
}
// startProvider starts a single provider binary and registers its proxy.
func (rm *RuntimeManager) startProvider(ctx context.Context, dp marketplace.DiscoveredProvider) (*RuntimeProvider, error) {
m := dp.Manifest
// Assign a free port.
port, err := findFreePort()
if err != nil {
return nil, coreerr.E("runtime.startProvider", "find free port", err)
}
// Resolve binary path.
binaryPath := m.Binary
if !filepath.IsAbs(binaryPath) {
binaryPath = filepath.Join(dp.Dir, binaryPath)
}
// Build command args.
args := make([]string, len(m.Args))
copy(args, m.Args)
args = append(args, "--namespace", m.Namespace, "--port", strconv.Itoa(port))
// Start the process.
cmd := exec.CommandContext(ctx, binaryPath, args...)
cmd.Dir = dp.Dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return nil, coreerr.E("runtime.startProvider", fmt.Sprintf("start binary %s", binaryPath), err)
}
// Wait for health check.
healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", port)
if err := waitForHealth(healthURL, 10*time.Second); err != nil {
// Kill the process if health check fails.
_ = cmd.Process.Kill()
return nil, coreerr.E("runtime.startProvider", fmt.Sprintf("health check failed for %s", m.Code), err)
}
// Register proxy provider.
cfg := provider.ProxyConfig{
Name: m.Code,
BasePath: m.Namespace,
Upstream: fmt.Sprintf("http://127.0.0.1:%d", port),
}
if m.Element != nil {
cfg.Element = provider.ElementSpec{
Tag: m.Element.Tag,
Source: m.Element.Source,
}
}
if m.Spec != "" {
cfg.SpecFile = filepath.Join(dp.Dir, m.Spec)
}
proxy := provider.NewProxy(cfg)
rm.engine.Register(proxy)
// Serve JS assets if the provider has an element source.
if m.Element != nil && m.Element.Source != "" {
assetsDir := filepath.Join(dp.Dir, "assets")
if _, err := os.Stat(assetsDir); err == nil {
// Assets are served at /assets/{code}/
rm.engine.Register(&staticAssetGroup{
name: m.Code + "-assets",
basePath: "/assets/" + m.Code,
dir: assetsDir,
})
}
}
rp := &RuntimeProvider{
Dir: dp.Dir,
Manifest: m,
Port: port,
Cmd: cmd,
}
return rp, nil
}
// StopAll terminates all running provider processes.
func (rm *RuntimeManager) StopAll() {
rm.mu.Lock()
defer rm.mu.Unlock()
for _, rp := range rm.providers {
if rp.Cmd != nil && rp.Cmd.Process != nil {
log.Printf("runtime: stopping %s (pid %d)", rp.Manifest.Code, rp.Cmd.Process.Pid)
_ = rp.Cmd.Process.Signal(os.Interrupt)
// Give the process 5 seconds to exit gracefully.
done := make(chan error, 1)
go func() { done <- rp.Cmd.Wait() }()
select {
case <-done:
// Exited cleanly.
case <-time.After(5 * time.Second):
_ = rp.Cmd.Process.Kill()
}
}
}
rm.providers = nil
}
// List returns a copy of all running provider info.
func (rm *RuntimeManager) List() []RuntimeProviderInfo {
rm.mu.Lock()
defer rm.mu.Unlock()
infos := make([]RuntimeProviderInfo, 0, len(rm.providers))
for _, rp := range rm.providers {
infos = append(infos, RuntimeProviderInfo{
Code: rp.Manifest.Code,
Name: rp.Manifest.Name,
Version: rp.Manifest.Version,
Namespace: rp.Manifest.Namespace,
Port: rp.Port,
Dir: rp.Dir,
})
}
return infos
}
// RuntimeProviderInfo is a serialisable summary of a running provider.
type RuntimeProviderInfo struct {
Code string `json:"code"`
Name string `json:"name"`
Version string `json:"version"`
Namespace string `json:"namespace"`
Port int `json:"port"`
Dir string `json:"dir"`
}
// findFreePort asks the OS for an available TCP port on 127.0.0.1.
func findFreePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}
// waitForHealth polls a health URL until it returns 200 or the timeout expires.
func waitForHealth(url string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
client := &http.Client{Timeout: 2 * time.Second}
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
time.Sleep(100 * time.Millisecond)
}
return coreerr.E("runtime.waitForHealth", fmt.Sprintf("timed out after %s: %s", timeout, url), nil)
}
// staticAssetGroup is a simple RouteGroup that serves static files.
// Used to serve provider JS assets.
type staticAssetGroup struct {
name string
basePath string
dir string
}
func (s *staticAssetGroup) Name() string { return s.name }
func (s *staticAssetGroup) BasePath() string { return s.basePath }
func (s *staticAssetGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.Static("/", s.dir)
}