288 lines
11 KiB
Go
288 lines
11 KiB
Go
package opencode
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// Server — супервайзер постоянного opencode serve (режим --attach).
|
||
//
|
||
// Вариант A интеграции: один headless-сервер живёт долго (тёплые модели и MCP),
|
||
// а Runner ходит к нему через `opencode run --attach <url> --dir cwd ...`.
|
||
//
|
||
// Два режима владения процессом:
|
||
// - URL == "": супервайзер сам spawn'ит `opencode serve`, следит через
|
||
// /global/health, рестартует при падении, гасит при Close.
|
||
// - URL != "": внешний сервер — супервайзер только проверяет доступность и
|
||
// отдаёт URL, процессом не владеет.
|
||
type Server struct {
|
||
Bin string // путь к opencode (по умолчанию "opencode")
|
||
DBPath string // рабочая БД сервера (передам env, если задана)
|
||
|
||
Host string // hostname для прослушивания
|
||
Port int // порт сервера
|
||
Password string // basic auth (если непустой — сервер защищён)
|
||
Dir string // каталог, в котором запускается serve (project сервера)
|
||
|
||
// ConfigPath — путь к глобальному конфигу opencode (opencode.jsonc).
|
||
// Передаётся serve через env OPENCODE_CONFIG, чтобы гарантированно
|
||
// подхватить модель/провайдеров независимо от резолва глобального пути.
|
||
// Пусто — serve резолвит конфиг сам (см. defaults).
|
||
ConfigPath string
|
||
|
||
// URL задаёт внешний сервер. Пусто — супервайзер владеет процессом.
|
||
URL string
|
||
PollInterval time.Duration // как часто проверять /global/health
|
||
|
||
// Заменяемые для тестов:
|
||
Stdout io.Writer
|
||
|
||
mu sync.Mutex
|
||
proc *exec.Cmd
|
||
done chan struct{} // закрывается reaper'ом при выходе процесса
|
||
closed bool
|
||
}
|
||
|
||
func (s *Server) defaults() {
|
||
if s.Bin == "" {
|
||
s.Bin = "opencode"
|
||
}
|
||
if s.Host == "" {
|
||
s.Host = "127.0.0.1"
|
||
}
|
||
if s.Port == 0 {
|
||
s.Port = 4096
|
||
}
|
||
if s.PollInterval == 0 {
|
||
s.PollInterval = 5 * time.Second
|
||
}
|
||
if s.Stdout == nil {
|
||
s.Stdout = os.Stderr
|
||
}
|
||
if s.ConfigPath == "" {
|
||
s.ConfigPath = defaultOpenCodeConfigPath()
|
||
}
|
||
}
|
||
|
||
// defaultOpenCodeConfigPath возвращает путь к глобальному конфигу opencode,
|
||
// совпадающий с тем, что opencode загружает по умолчанию: ~/.config/opencode/
|
||
// (в т.ч. на Windows — см. Global.Path.config в исходниках opencode).
|
||
func defaultOpenCodeConfigPath() string {
|
||
home, _ := os.UserHomeDir()
|
||
if home == "" {
|
||
home = "."
|
||
}
|
||
return filepath.Join(home, ".config", "opencode", "opencode.jsonc")
|
||
}
|
||
|
||
// baseURL собирает полный адрес сервера (http://host:port).
|
||
func (s *Server) baseURL() string {
|
||
s.defaults()
|
||
return fmt.Sprintf("http://%s:%d", s.Host, s.Port)
|
||
}
|
||
|
||
// Addr возвращает URL, по которому Runner должен ходить через --attach.
|
||
func (s *Server) Addr() string {
|
||
if s.URL != "" {
|
||
return s.URL
|
||
}
|
||
return s.baseURL()
|
||
}
|
||
|
||
// Start запускает сервер (внешний — просто проверку) в фоне.
|
||
// Возвращает ошибку, если процесс не удалось поднять или первый healthcheck
|
||
// не прошёл (serve доступен, но ещё «тёплый»).
|
||
func (s *Server) Start(ctx context.Context) error {
|
||
s.defaults()
|
||
if s.URL != "" {
|
||
// внешний сервер — не владеем процессом, только ждём доступность
|
||
return s.waitHealthy(ctx, s.URL)
|
||
}
|
||
addr := s.baseURL()
|
||
s.mu.Lock()
|
||
if s.closed {
|
||
s.mu.Unlock()
|
||
return fmt.Errorf("opencode serve: server closed")
|
||
}
|
||
cmd := s.serveCmd(ctx)
|
||
cmd.Stdout = s.Stdout
|
||
cmd.Stderr = s.Stdout
|
||
done := make(chan struct{})
|
||
s.proc = cmd
|
||
s.done = done
|
||
s.mu.Unlock()
|
||
|
||
log.Printf("opencode serve: spawn dir=%s HOME=%q USERPROFILE=%q OPENCODE_CONFIG=%q (serve наследует окружение родителя)", cmd.Dir, os.Getenv("HOME"), os.Getenv("USERPROFILE"), s.ConfigPath)
|
||
|
||
if err := cmd.Start(); err != nil {
|
||
return fmt.Errorf("opencode serve: start %v: %w", s.Bin, err)
|
||
}
|
||
// reaper: ждём выход процесса и закрываем done — единственный владелец
|
||
// Wait (Close его не трогает, Run поллит ProcessState).
|
||
go func() {
|
||
_ = cmd.Wait()
|
||
close(done)
|
||
}()
|
||
return s.waitHealthy(ctx, addr)
|
||
}
|
||
|
||
// serveCmd собирает команду запуска сервера.
|
||
func (s *Server) serveCmd(ctx context.Context) *exec.Cmd {
|
||
args := []string{"serve", "--hostname", s.Host, "--port", fmt.Sprintf("%d", s.Port)}
|
||
cmd := exec.CommandContext(ctx, s.Bin, args...)
|
||
cmd.Dir = s.Dir // project сервера — каталог, который обслуживает этот serve
|
||
if cmd.Dir == "" {
|
||
cmd.Dir = "."
|
||
}
|
||
// Своя process-group: чтобы killGroup (по pgid) убивал только сервер и его
|
||
// дочерние процессы, а не чужой процесс (например, тест-реннер).
|
||
setpgid(cmd)
|
||
// Env не задаём: serve наследует окружение родителя и сам резолвит свой
|
||
// глобальный/локальный конфиг (модель/провайдеры, агентов, БД) как обычно.
|
||
return cmd
|
||
}
|
||
|
||
// MinVersion — минимальная версия opencode, с которой работает интеграция.
|
||
// v2 HTTP API (префикс /api/*) присутствует в сборках dev / >=1.18.18.
|
||
// Более старые бинари отвечают на /global/health и НЕ подходят.
|
||
const MinVersion = "1.18.18"
|
||
|
||
// waitHealthy опрашивает /api/health сервера до первого успеха или Connect.
|
||
// Возвращает nil, как только сервер ответил {healthy:true} (или 200/401 — сервер
|
||
// жив, но может требовать авторизации). При неудаче — ошибка с подсказкой про
|
||
// минимальную версию opencode (класс O1: старый бинарь не знает v2-путей).
|
||
func (s *Server) waitHealthy(ctx context.Context, addr string) error {
|
||
deadline := time.Now().Add(60 * time.Second)
|
||
poll := s.PollInterval
|
||
for {
|
||
if healthy := s.health(ctx, addr); healthy {
|
||
return nil
|
||
}
|
||
if time.Now().After(deadline) {
|
||
return fmt.Errorf("opencode serve %s: не стал доступным (v2 healthcheck). Нужен opencode >= %s (v2 HTTP API /api/*), а не старый бинарь", addr, MinVersion)
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-time.After(poll):
|
||
}
|
||
}
|
||
}
|
||
|
||
// healthGET делает GET на адрес и возвращает true, если сервер ответил.
|
||
// 401 (basic auth требуется) тоже считается «жив» — сервер доступен.
|
||
func (s *Server) healthGET(ctx context.Context, addr string) bool {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr+"/api/health", nil)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
if s.Password != "" {
|
||
req.SetBasicAuth("opencode", s.Password)
|
||
}
|
||
client := &http.Client{Timeout: 2 * time.Second}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
defer resp.Body.Close()
|
||
_, _ = io.Copy(io.Discard, resp.Body)
|
||
// 200/403/401 — сервер жив (остальное считаем недоступным)
|
||
return resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden
|
||
}
|
||
|
||
// Run следит за сервером, пока ctx не отменён.
|
||
//
|
||
// В режиме владения (URL=="") перезапускает owned-процесс, если тот вышел
|
||
// (cmd.ProcessState указывает на завершение). Внешний сервер (URL!="") просто
|
||
// поллится на доступность и логирует сбои — процессом не владеем.
|
||
func (s *Server) Run(ctx context.Context) {
|
||
s.defaults()
|
||
addr := s.Addr()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
s.Close()
|
||
return
|
||
case <-time.After(s.PollInterval):
|
||
}
|
||
s.mu.Lock()
|
||
proc := s.proc
|
||
closed := s.closed
|
||
s.mu.Unlock()
|
||
if closed {
|
||
return
|
||
}
|
||
if s.URL != "" {
|
||
// внешний сервер упал — не наша работа перезапускать, но логируем
|
||
if !s.health(ctx, addr) {
|
||
log.Printf("opencode serve: внешний сервер %s недоступен", addr)
|
||
}
|
||
continue
|
||
}
|
||
// наш процесс: перезапускаем, если он вышел. Признак выхода —
|
||
// cmd.ProcessState != nil: его выставляет reaper-горутина (cmd.Wait)
|
||
// только когда процесс завершился любым способом (exit, сигнал, OOM).
|
||
// .Exited() использовать нельзя — для SIGKILL он false.
|
||
exited := proc == nil || proc.ProcessState != nil
|
||
if !exited {
|
||
continue
|
||
}
|
||
log.Printf("opencode serve: процесс упал — перезапускаю")
|
||
s.mu.Lock()
|
||
cmd := s.serveCmd(ctx)
|
||
cmd.Stdout = s.Stdout
|
||
cmd.Stderr = s.Stdout
|
||
done := make(chan struct{})
|
||
s.proc = cmd
|
||
s.done = done
|
||
s.mu.Unlock()
|
||
if err := cmd.Start(); err != nil {
|
||
log.Printf("opencode serve: перезапуск не удался: %v", err)
|
||
continue
|
||
}
|
||
go func() {
|
||
_ = cmd.Wait()
|
||
close(done)
|
||
}()
|
||
}
|
||
}
|
||
|
||
// health оборачивает healthGET, игнорируя нерелевантные ошибки.
|
||
func (s *Server) health(ctx context.Context, addr string) bool {
|
||
hctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||
defer cancel()
|
||
return s.healthGET(hctx, addr)
|
||
}
|
||
|
||
// Close гасит процесс, которым владеет супервайзер. Идемпотентен.
|
||
// Wait НЕ вызываем — reaper-горутина единственный владелец Wait; Close лишь
|
||
// убивает процесс и ждёт, когда reaper закроет канал done.
|
||
func (s *Server) Close() {
|
||
s.mu.Lock()
|
||
if s.closed {
|
||
s.mu.Unlock()
|
||
return
|
||
}
|
||
s.closed = true
|
||
proc := s.proc
|
||
done := s.done
|
||
s.mu.Unlock()
|
||
|
||
if proc != nil && proc.Process != nil {
|
||
killGroup(proc)
|
||
}
|
||
if done != nil {
|
||
select {
|
||
case <-done:
|
||
case <-time.After(5 * time.Second):
|
||
}
|
||
}
|
||
} |