Files
ratatoskr-go/internal/opencode/server.go
ki.sagidullin c9ab753c1e
Some checks failed
CI / test (push) Failing after 2m13s
CI / build-and-package (amd64, linux) (push) Failing after 1m3s
CI / build-and-package (amd64, windows) (push) Successful in 29s
fix(opencode): x-opencode-directory, Content-Length, warmup /api/model; serve без явного Env
2026-08-24 08:38:59 +05:00

288 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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):
}
}
}