feat: opencode через HTTP API — пул serve-серверов вместо spawn/NDJSON
Runner теперь ходит к постоянным serve по HTTP API (v1.17+, /api):
- клиент Client (create/send/wait/abort/messages/verdict)
- Pool: по одному serve на каталог, ленивый подъём, root-сервер в worktree,
выделение портов, ReleaseTask при завершении задачи
- Run: CreateSession('ratatoskr-<агент>') -> Send -> поллинг Verdict из
text-частей assistant-сообщений; idle/hard таймауты дают RC=-1
- вердикт извлекается из последнего assistant text-парта (плоский text)
- тесты: unit на фейковом HTTP-сервере; e2e эмулирует serve через httptest,
агент определяется по title сессии
This commit is contained in:
180
internal/opencode/client.go
Normal file
180
internal/opencode/client.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client — HTTP-взаимодействие с одним opencode serve (режим API).
|
||||
//
|
||||
// Ходит по HTTP API opencode serve (v1.17+, префикс /api):
|
||||
// - POST /api/session создать сессию → {data:{id}}
|
||||
// - POST /api/session/:id/prompt отправить промпт {prompt:{text}}
|
||||
// - POST /api/session/:id/wait дождаться завершения ответа (блок)
|
||||
// - GET /api/session/:id/message?order=desc → {data:[{...}]} история ответов
|
||||
// - POST /api/session/:id/interrupt прервать выполняющийся ответ
|
||||
//
|
||||
// Вердикт собирается из последнего assistant-сообщения: его content[] → текст
|
||||
// тех частей, где type == "text". (В API-режиме текст в part.text — плоско,
|
||||
// в отличие от NDJSON run, где он был вложен в part.part.text.)
|
||||
type Client struct {
|
||||
BaseURL string // http://host:port (без завершающего слеша)
|
||||
Password string // basic auth (username "opencode")
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// ClientErr — классы ошибок клиента.
|
||||
type ClientErr struct {
|
||||
Op string // "connect" | "create" | "prompt" | "wait" | "messages" | "verdict"
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ClientErr) Error() string { return fmt.Sprintf("opencode api %s: %v", e.Op, e.Err) }
|
||||
func (e *ClientErr) Unwrap() error { return e.Err }
|
||||
|
||||
func (c *Client) defaults() {
|
||||
if c.http == nil {
|
||||
c.http = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
}
|
||||
|
||||
// do выполняет запрос и возвращает тело ответа при 2xx. Иначе — ClientErr.
|
||||
// op — метка операции (create/prompt/wait/messages/abort) для класса ошибки.
|
||||
func (c *Client) do(ctx context.Context, method, path, op string, body []byte) ([]byte, error) {
|
||||
c.defaults()
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
rd = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rd)
|
||||
if err != nil {
|
||||
return nil, &ClientErr{Op: "connect", Err: err}
|
||||
}
|
||||
if c.Password != "" {
|
||||
req.SetBasicAuth("opencode", c.Password)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ClientErr{Op: "connect", Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &ClientErr{Op: "connect", Err: err}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return nil, &ClientErr{Op: op, Err: fmt.Errorf("status %d: %s", resp.StatusCode, truncateStr(string(b), 300))}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// CreateSession создаёт новую сессию и возвращает её id.
|
||||
func (c *Client) CreateSession(ctx context.Context, title string) (string, error) {
|
||||
body := map[string]string{}
|
||||
if title != "" {
|
||||
body["title"] = title
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
raw, err := c.do(ctx, http.MethodPost, "/api/session", "create", b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", &ClientErr{Op: "create", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||
}
|
||||
if out.Data.ID == "" {
|
||||
return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")}
|
||||
}
|
||||
return out.Data.ID, nil
|
||||
}
|
||||
|
||||
// Prompt отправляет промпт в сессию и ждёт завершения ответа (блокирующий wait).
|
||||
func (c *Client) Prompt(ctx context.Context, sessionID, prompt string) error {
|
||||
if err := c.Send(ctx, sessionID, prompt); err != nil {
|
||||
return err
|
||||
}
|
||||
// POST /wait блокирует, пока ответ сессии не завершится.
|
||||
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/wait", "wait", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Send отправляет промпт в сессию (не ждёт ответа — выполнение идёт в фоне).
|
||||
// Используется Runner'ом для асинхронного ожидания через поллинг сообщений.
|
||||
func (c *Client) Send(ctx context.Context, sessionID, prompt string) error {
|
||||
payload := map[string]any{"prompt": map[string]string{"text": prompt}}
|
||||
b, _ := json.Marshal(payload)
|
||||
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/prompt", "prompt", b)
|
||||
return err
|
||||
}
|
||||
|
||||
// Abort прерывает выполняющийся ответ сессии.
|
||||
func (c *Client) Abort(ctx context.Context, sessionID string) error {
|
||||
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/interrupt", "abort", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// sessionMessage — минимальная структура сообщения из GET /api/session/:id/message.
|
||||
type sessionMessage struct {
|
||||
Type string `json:"type"` // "assistant" | "user" | ...
|
||||
Content []struct {
|
||||
Type string `json:"type"` // "text" | "reasoning" | "tool"
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
|
||||
// Verdict возвращает текст последнего assistant-сообщения сессии (вердикт).
|
||||
// Ошибка — класса ErrVerdict (ClientErr{Op:"verdict"}), если нет готового
|
||||
// assistant-сообщения или в нём нет text-частей.
|
||||
func (c *Client) Verdict(ctx context.Context, sessionID string) (string, error) {
|
||||
// order=desc — самые свежие сообщения первыми; идём по ним в поисках
|
||||
// первого незаконченного assistant-ответа.
|
||||
raw, err := c.do(ctx, http.MethodGet, "/api/session/"+sessionID+"/message?order=desc&limit=50", "messages", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
Data []sessionMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", &ClientErr{Op: "verdict", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||
}
|
||||
var b bytes.Buffer
|
||||
for _, m := range out.Data {
|
||||
if m.Type != "assistant" {
|
||||
continue
|
||||
}
|
||||
b.Reset()
|
||||
for _, p := range m.Content {
|
||||
if p.Type == "text" && p.Text != "" {
|
||||
if b.Len() > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
return stripFence(b.String()), nil
|
||||
}
|
||||
}
|
||||
return "", &ClientErr{Op: "verdict", Err: fmt.Errorf("нет assistant-сообщения с text-частью")}
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user