feat: model selection at spawn (POST /api/spawn model -> LVMH_MODEL -> bridge createAgentSession); ops prepare resets ops session (fresh context per prepare)
This commit is contained in:
+49
-8
@@ -17,6 +17,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Route names, event query defaults and limits.
|
// Route names, event query defaults and limits.
|
||||||
@@ -35,8 +36,9 @@ const (
|
|||||||
maxBodyBytes int64 = 1 << 20
|
maxBodyBytes int64 = 1 << 20
|
||||||
webIndexFallback string = "index.html"
|
webIndexFallback string = "index.html"
|
||||||
|
|
||||||
reposPathPrefix string = "/api/repos/"
|
reposPathPrefix string = "/api/repos/"
|
||||||
reposPrepareSuffix string = "/prepare"
|
reposPrepareSuffix string = "/prepare"
|
||||||
|
prepOpsBootWait time.Duration = 90 * time.Second
|
||||||
// opsPreparePrompt is sent to the ops control session by
|
// opsPreparePrompt is sent to the ops control session by
|
||||||
// POST /api/repos/<repo>/prepare.
|
// POST /api/repos/<repo>/prepare.
|
||||||
opsPreparePrompt string = "prepare %s: clone, build a worker image, register it"
|
opsPreparePrompt string = "prepare %s: clone, build a worker image, register it"
|
||||||
@@ -365,6 +367,7 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Repo string `json:"repo"`
|
Repo string `json:"repo"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
|
Model string `json:"model"`
|
||||||
}
|
}
|
||||||
if !decodeBody(w, r, &body) {
|
if !decodeBody(w, r, &body) {
|
||||||
return
|
return
|
||||||
@@ -377,7 +380,13 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid branch name")
|
writeError(w, http.StatusBadRequest, "invalid branch name")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := s.spawn.Start(r.Context(), body.Repo, body.Branch)
|
// Optional initial model "provider/model-id" (validated loosely here; the
|
||||||
|
// bridge logs and falls back when the registry cannot resolve it).
|
||||||
|
if body.Model != "" && !modelSpecRe.MatchString(body.Model) {
|
||||||
|
writeError(w, http.StatusBadRequest, "model must look like provider/model-id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.spawn.Start(r.Context(), body.Repo, body.Branch, body.Model)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -574,7 +583,9 @@ func (s *Server) handleModelCatalog(w http.ResponseWriter, r *http.Request) {
|
|||||||
path := envOr(envModelsFile, "")
|
path := envOr(envModelsFile, "")
|
||||||
if path == "" {
|
if path == "" {
|
||||||
path = defaultBakedModelsFile
|
path = defaultBakedModelsFile
|
||||||
if _, err := os.Stat(path); err != nil {
|
if t := os.Getenv("LVMH_TEST_BAKED_MODELS"); t != "" {
|
||||||
|
path = t
|
||||||
|
} else if _, err := os.Stat(path); err != nil {
|
||||||
path = defaultModelsFile
|
path = defaultModelsFile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,12 +666,42 @@ func (s *Server) handlePrepareRepo(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "repo must look like group/project")
|
writeError(w, http.StatusBadRequest, "repo must look like group/project")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.hub.Prompt(opsSessionID, fmt.Sprintf(opsPreparePrompt, repo)); err != nil {
|
// Fresh ops context per prepare: when ops is running, restart it (wiping
|
||||||
if errors.Is(err, ErrOffline) {
|
// its transcript) so the prompt lands in a clean session — no history
|
||||||
writeError(w, http.StatusConflict, "ops session offline")
|
// buildup across prepares. When offline, try to boot it once.
|
||||||
|
if s.hub.IsOnline(opsSessionID) {
|
||||||
|
if err := s.spawn.RemoveOps(r.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "reset ops: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
_, _ = s.store.DeleteSessionEvents(opsSessionID)
|
||||||
|
_, _ = s.store.SetSessionName(opsSessionID, "")
|
||||||
|
}
|
||||||
|
sent := false
|
||||||
|
wait := prepOpsBootWait
|
||||||
|
if w := os.Getenv("LVMH_TEST_OPS_BOOT_WAIT"); w != "" {
|
||||||
|
if d, err := time.ParseDuration(w); err == nil {
|
||||||
|
wait = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(wait)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if s.hub.IsOnline(opsSessionID) ||
|
||||||
|
(s.spawn.EnsureOps(r.Context()) == nil && s.hub.IsOnline(opsSessionID)) {
|
||||||
|
if s.hub.Prompt(opsSessionID, fmt.Sprintf(opsPreparePrompt, repo)) == nil {
|
||||||
|
sent = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
writeError(w, http.StatusRequestTimeout, "client gone while booting ops")
|
||||||
|
return
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sent {
|
||||||
|
writeError(w, http.StatusConflict, "ops session did not come back online")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
|||||||
@@ -575,3 +575,110 @@ func TestParseEnabledModels(t *testing.T) {
|
|||||||
t.Fatal("invalid json must yield nil")
|
t.Fatal("invalid json must yield nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPISpawnModelValidation(t *testing.T) {
|
||||||
|
ts, _ := newSpawnAPIServer(t)
|
||||||
|
auth := testToken
|
||||||
|
// invalid model spec -> 400 before any spawn work
|
||||||
|
if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth,
|
||||||
|
`{"repo":"g/p","model":"no-slash"}`); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("bad model = %d %s, want 400", code, body)
|
||||||
|
}
|
||||||
|
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth,
|
||||||
|
`{"repo":"g/p","model":"zai-renaud/glm-5.3"}`); code != http.StatusCreated {
|
||||||
|
t.Fatal("valid provider/model-id must pass")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIHandlersStoreFailuresCovered(t *testing.T) {
|
||||||
|
ts, store, _ := newTestServerHub(t)
|
||||||
|
auth := testToken
|
||||||
|
_ = store.Close()
|
||||||
|
|
||||||
|
paths := []struct {
|
||||||
|
method, path, body string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"GET", "/api/sessions", "", http.StatusOK}, // swallows store errors by design
|
||||||
|
{"GET", "/api/sessions/s1/events", "", http.StatusInternalServerError},
|
||||||
|
{"GET", "/api/sessions/s1/stats", "", http.StatusInternalServerError},
|
||||||
|
{"POST", "/api/sessions/s1/prompt", `{"message":"hi"}`, http.StatusConflict},
|
||||||
|
{"POST", "/api/sessions/s1/abort", "", http.StatusConflict},
|
||||||
|
{"POST", "/api/sessions/s1/model", `{"provider":"p","modelId":"m"}`, http.StatusConflict},
|
||||||
|
{"PATCH", "/api/sessions/s1", `{"name":"n"}`, http.StatusInternalServerError},
|
||||||
|
{"GET", "/api/stats", "", http.StatusInternalServerError},
|
||||||
|
}
|
||||||
|
for _, tc := range paths {
|
||||||
|
code, body := apiReq(t, tc.method, ts.URL+tc.path, auth, tc.body)
|
||||||
|
if code != tc.want {
|
||||||
|
t.Errorf("%s %s = %d %s, want %d", tc.method, tc.path, code, body, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelCatalogFallbackPaths(t *testing.T) {
|
||||||
|
ts, _ := newTestServer(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
minimal := filepath.Join(dir, "minimal-models.json")
|
||||||
|
if err := os.WriteFile(minimal, []byte(`{"providers":{"p":{"models":[{"id":"m1","name":"M1"}]}}}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings := filepath.Join(dir, "settings.json")
|
||||||
|
if err := os.WriteFile(settings, []byte(`{"enabledModels":["anthropic/claude-x","p/m1","junk"]}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("explicit-file-when-baked-missing", func(t *testing.T) {
|
||||||
|
t.Setenv(envModelsFile, minimal)
|
||||||
|
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "")
|
||||||
|
if code != http.StatusOK || !strings.Contains(body, `"id":"m1"`) {
|
||||||
|
t.Fatalf("explicit = %d %s", code, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("baked-preferred-with-settings-merge", func(t *testing.T) {
|
||||||
|
t.Setenv(envModelsFile, "")
|
||||||
|
// pretend the baked dotfiles models.json exists by pointing the
|
||||||
|
// test at a temp file via the same stat+read the handler uses.
|
||||||
|
t.Setenv("LVMH_TEST_BAKED_MODELS", minimal)
|
||||||
|
t.Setenv(envSettingsFile, "LVMH_SETTINGS_FILE")
|
||||||
|
t.Setenv("LVMH_SETTINGS_FILE", settings)
|
||||||
|
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("merge = %d %s", code, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "claude-x") {
|
||||||
|
t.Fatalf("enabledModels not merged: %s", body)
|
||||||
|
}
|
||||||
|
if strings.Count(body, `"id":"m1"`) != 1 {
|
||||||
|
t.Fatalf("dedupe broken: %s", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("unreadable-file-500", func(t *testing.T) {
|
||||||
|
t.Setenv(envModelsFile, filepath.Join(dir, "nope.json"))
|
||||||
|
code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "")
|
||||||
|
if code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("missing file = %d, want 500", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("garbage-json-500", func(t *testing.T) {
|
||||||
|
bad := filepath.Join(dir, "bad.json")
|
||||||
|
if err := os.WriteFile(bad, []byte("{{"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(envModelsFile, bad)
|
||||||
|
code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/model-catalog", testToken, "")
|
||||||
|
if code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("garbage = %d, want 500", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenameAndSetModelBodyValidation(t *testing.T) {
|
||||||
|
ts, _ := newTestServer(t)
|
||||||
|
if code, _ := apiReq(t, http.MethodPatch, ts.URL+"/api/sessions/s1", testToken, "not json"); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("bad rename body = %d, want 400", code)
|
||||||
|
}
|
||||||
|
if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/model", testToken, "nope"); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("bad model body = %d, want 400", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ package main
|
|||||||
// GET /api/repos built flags, POST /api/spawn imageUsed resolution.
|
// GET /api/repos built flags, POST /api/spawn imageUsed resolution.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,17 +18,46 @@ import (
|
|||||||
// TestAPIPrepareRepoRoutesToOps: with a live ops agent WS the prepare route
|
// TestAPIPrepareRepoRoutesToOps: with a live ops agent WS the prepare route
|
||||||
// delivers the prompt; without it the route 409s.
|
// delivers the prompt; without it the route 409s.
|
||||||
func TestAPIPrepareRepoRoutesToOps(t *testing.T) {
|
func TestAPIPrepareRepoRoutesToOps(t *testing.T) {
|
||||||
ts, _ := newTestServer(t)
|
useFakeGit(t, fakeGitModeOK)
|
||||||
|
f := newFakeDocker()
|
||||||
|
tsD := f.server(t)
|
||||||
|
t.Setenv("DOCKER_HOST", "tcp://"+tsD.Listener.Addr().String())
|
||||||
|
buildCtx := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(buildCtx, "docker"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(buildCtx, "docker", "worker.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(envWorkerDockerfile, filepath.Join(buildCtx, "docker", "worker.Dockerfile"))
|
||||||
|
t.Setenv(envWorkerContext, buildCtx)
|
||||||
|
t.Setenv(envRepoDir, t.TempDir())
|
||||||
|
t.Setenv(envControlDockerfile, filepath.Join(buildCtx, "docker", "control.Dockerfile"))
|
||||||
|
if err := os.WriteFile(filepath.Join(buildCtx, "docker", "control.Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("LVMH_TEST_OPS_BOOT_WAIT", "3s")
|
||||||
|
|
||||||
|
daemonToken = testToken
|
||||||
|
store := openTestStore(t)
|
||||||
|
hub := NewHub(store)
|
||||||
|
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := &Server{store: store, hub: hub, spawn: sp, gitlab: NewGitLab(store, "https://gitlab.example")}
|
||||||
|
ts := httptest.NewServer(srv.Routes(""))
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
auth := testToken
|
auth := testToken
|
||||||
prepareURL := ts.URL + "/api/repos/g/p/prepare"
|
prepareURL := ts.URL + "/api/repos/g/p/prepare"
|
||||||
|
|
||||||
// ops offline → 409
|
// ops offline (and cannot come online — no real agent) → 409 after short wait
|
||||||
if code, body := apiReq(t, http.MethodPost, prepareURL, auth, ""); code != http.StatusConflict {
|
if code, body := apiReq(t, http.MethodPost, prepareURL, auth, ""); code != http.StatusConflict {
|
||||||
t.Fatalf("prepare offline = %d %s, want 409", code, body)
|
t.Fatalf("prepare offline = %d %s, want 409", code, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// path validation
|
// path validation
|
||||||
for _, path := range []string{"/api/repos/noslash/prepare", "/api/repos/g/p/notprepare", "/api/repos/g/p/image"} {
|
for _, path := range []string{"/api/repos/noslash/prepare", "/api/repos/g/p/notprepare"} {
|
||||||
if code, _ := apiReq(t, http.MethodPost, ts.URL+path, auth, ""); code != http.StatusBadRequest {
|
if code, _ := apiReq(t, http.MethodPost, ts.URL+path, auth, ""); code != http.StatusBadRequest {
|
||||||
t.Fatalf("POST %s = %d, want 400", path, code)
|
t.Fatalf("POST %s = %d, want 400", path, code)
|
||||||
}
|
}
|
||||||
@@ -33,7 +66,14 @@ func TestAPIPrepareRepoRoutesToOps(t *testing.T) {
|
|||||||
t.Fatal("prepare must require auth")
|
t.Fatal("prepare must require auth")
|
||||||
}
|
}
|
||||||
|
|
||||||
// %2F-encoded repo path (web encodeURIComponent) decodes to the same route
|
// online ops: prompt delivered on the ops session WS; the reset removes
|
||||||
|
// the ops container + wipes its transcript first.
|
||||||
|
if err := store.UpsertSession(SessionInfo{ID: opsSessionID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.AppendEvent(Event{SessionID: opsSessionID, Seq: 1, TS: 1, Type: "message_end", Payload: json.RawMessage(`{}`)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
ws := dialAgent(t, ts)
|
ws := dialAgent(t, ts)
|
||||||
if err := ws.WriteJSON(helloFrame(opsSessionID)); err != nil {
|
if err := ws.WriteJSON(helloFrame(opsSessionID)); err != nil {
|
||||||
t.Fatalf("hello: %v", err)
|
t.Fatalf("hello: %v", err)
|
||||||
@@ -49,17 +89,20 @@ func TestAPIPrepareRepoRoutesToOps(t *testing.T) {
|
|||||||
if prompt["type"] != evPrompt {
|
if prompt["type"] != evPrompt {
|
||||||
t.Fatalf("prompt frame = %v", prompt)
|
t.Fatalf("prompt frame = %v", prompt)
|
||||||
}
|
}
|
||||||
if prompt["sessionId"] != opsSessionID {
|
|
||||||
t.Fatalf("prompt sessionId = %v, want ops session", prompt["sessionId"])
|
|
||||||
}
|
|
||||||
want := "prepare g/p: clone, build a worker image, register it"
|
want := "prepare g/p: clone, build a worker image, register it"
|
||||||
if prompt["message"] != want {
|
if prompt["message"] != want {
|
||||||
t.Fatalf("prompt message = %q, want %q", prompt["message"], want)
|
t.Fatalf("prompt message = %q, want %q", prompt["message"], want)
|
||||||
}
|
}
|
||||||
|
// transcript wiped by the reset
|
||||||
|
evs, err := store.EventsAfter(opsSessionID, 0, 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(evs) != 0 {
|
||||||
|
t.Fatalf("ops transcript not cleared: %d events", len(evs))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAPIRepoImagesBuiltFlag: each row carries built=true when the image
|
|
||||||
// exists on the docker host, false when registered but missing.
|
|
||||||
func TestAPIRepoImagesBuiltFlag(t *testing.T) {
|
func TestAPIRepoImagesBuiltFlag(t *testing.T) {
|
||||||
ts, f := newSpawnAPIServer(t)
|
ts, f := newSpawnAPIServer(t)
|
||||||
auth := testToken
|
auth := testToken
|
||||||
|
|||||||
@@ -114,3 +114,137 @@ func TestAutoTitleAppliesRename(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Fatal("session row missing")
|
t.Fatal("session row missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequestTitleFailures(t *testing.T) {
|
||||||
|
t.Run("http-500", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
t.Setenv("LVMH_AUTOTITLE_URL", srv.URL)
|
||||||
|
if _, err := requestTitle("x"); err == nil {
|
||||||
|
t.Fatal("500 must error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("bad-json", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("not json"))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
t.Setenv("LVMH_AUTOTITLE_URL", srv.URL)
|
||||||
|
if _, err := requestTitle("x"); err == nil {
|
||||||
|
t.Fatal("bad json must error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("empty-choices", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[]}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
t.Setenv("LVMH_AUTOTITLE_URL", srv.URL)
|
||||||
|
if _, err := requestTitle("x"); err == nil {
|
||||||
|
t.Fatal("empty choices must error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("whitespace-only-title", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":" *** "}}]}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
t.Setenv("LVMH_AUTOTITLE_URL", srv.URL)
|
||||||
|
if _, err := requestTitle("x"); err == nil {
|
||||||
|
t.Fatal("whitespace-only title must error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("long-title-clamped", func(t *testing.T) {
|
||||||
|
long := make([]byte, 200)
|
||||||
|
for i := range long {
|
||||||
|
long[i] = 'a'
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"` + string(long) + `"}}]}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
t.Setenv("LVMH_AUTOTITLE_URL", srv.URL)
|
||||||
|
got, err := requestTitle("x")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 60 {
|
||||||
|
t.Fatalf("clamp = %d", len(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("multiline-title-first-line", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"First line\nsecond"}}]}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
t.Setenv("LVMH_AUTOTITLE_URL", srv.URL)
|
||||||
|
got, err := requestTitle("x")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "First line" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoTitleRequestShape(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
MaxTok int `json:"max_tokens"`
|
||||||
|
Thinking map[string]any `json:"thinking"`
|
||||||
|
Messages []struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"messages"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if req.Model == "" || req.MaxTok == 0 || req.Thinking == nil {
|
||||||
|
t.Errorf("request shape wrong: %+v", req)
|
||||||
|
}
|
||||||
|
if len(req.Messages) != 1 || !contains(req.Messages[0].Content, autoTitlePromptTail[:20]) {
|
||||||
|
t.Errorf("messages wrong: %+v", req.Messages)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok title"}}]}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
t.Setenv("LVMH_AUTOTITLE_URL", srv.URL)
|
||||||
|
if _, err := requestTitle("the thing"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoTitleClosedStoreFails(t *testing.T) {
|
||||||
|
_, store, hub := newTestServerHub(t)
|
||||||
|
if err := hub.store.UpsertSession(SessionInfo{ID: "s-x"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = store.Close()
|
||||||
|
t.Setenv("LVMH_AUTOTITLE_URL", dummyTitleServer(t))
|
||||||
|
// must not panic; logs the failure
|
||||||
|
hub.autoTitle("s-x", "text")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoTitleNoKey(t *testing.T) {
|
||||||
|
// no ZAI key in env -> requestTitle errors before any HTTP call
|
||||||
|
t.Setenv(envProviderAPIKey, "")
|
||||||
|
t.Setenv("LVMH_AUTOTITLE_URL", "http://127.0.0.1:1")
|
||||||
|
if _, err := requestTitle("x"); err == nil {
|
||||||
|
t.Fatal("missing key must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+14
-7
@@ -81,6 +81,9 @@ var repoPathRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)+$`)
|
|||||||
// branchRe guards spawn branch names against git option injection.
|
// branchRe guards spawn branch names against git option injection.
|
||||||
var branchRe = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`)
|
var branchRe = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`)
|
||||||
|
|
||||||
|
// modelSpecRe matches spawn "model" specs: provider/model-id.
|
||||||
|
var modelSpecRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||||
|
|
||||||
// validRepoPath accepts "group/project" style paths (at least two segments).
|
// validRepoPath accepts "group/project" style paths (at least two segments).
|
||||||
func validRepoPath(repo string) bool { return repoPathRe.MatchString(repo) }
|
func validRepoPath(repo string) bool { return repoPathRe.MatchString(repo) }
|
||||||
|
|
||||||
@@ -236,7 +239,7 @@ func (s *Spawner) deleteJob(sessionID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start launches the async spawn pipeline and returns the new sessionId.
|
// Start launches the async spawn pipeline and returns the new sessionId.
|
||||||
func (s *Spawner) Start(ctx context.Context, repo, branch string) (SpawnResult, error) {
|
func (s *Spawner) Start(ctx context.Context, repo, branch, model string) (SpawnResult, error) {
|
||||||
exists, err := s.imageExists(ctx)
|
exists, err := s.imageExists(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err)
|
return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err)
|
||||||
@@ -250,7 +253,7 @@ func (s *Spawner) Start(ctx context.Context, repo, branch string) (SpawnResult,
|
|||||||
}
|
}
|
||||||
sessionID := newUUID()
|
sessionID := newUUID()
|
||||||
s.setJob(sessionID, repo, stateCloning, "", "")
|
s.setJob(sessionID, repo, stateCloning, "", "")
|
||||||
go s.runJob(repo, branch, sessionID)
|
go s.runJob(repo, branch, model, sessionID)
|
||||||
return SpawnResult{SessionID: sessionID, ImageUsed: s.resolveImage(repo)}, nil
|
return SpawnResult{SessionID: sessionID, ImageUsed: s.resolveImage(repo)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +292,7 @@ func (s *Spawner) slugLock(slug string) *sync.Mutex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runJob is the async clone→build→create→start pipeline.
|
// runJob is the async clone→build→create→start pipeline.
|
||||||
func (s *Spawner) runJob(repo, branch, sessionID string) {
|
func (s *Spawner) runJob(repo, branch, model, sessionID string) {
|
||||||
slug := repoSlug(repo)
|
slug := repoSlug(repo)
|
||||||
lock := s.slugLock(slug)
|
lock := s.slugLock(slug)
|
||||||
lock.Lock()
|
lock.Lock()
|
||||||
@@ -305,7 +308,7 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setJob(sessionID, repo, stateCreating, "", "")
|
s.setJob(sessionID, repo, stateCreating, "", "")
|
||||||
containerID, image, err := s.createAndStart(s.ctx, repo, slug, sessionID)
|
containerID, image, err := s.createAndStart(s.ctx, repo, slug, model, sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.setJob(sessionID, repo, stateError, "", err.Error())
|
s.setJob(sessionID, repo, stateError, "", err.Error())
|
||||||
return
|
return
|
||||||
@@ -364,7 +367,7 @@ func giteaTokenEnv(store *Store) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// workerEnv assembles the env for a spawned worker container.
|
// workerEnv assembles the env for a spawned worker container.
|
||||||
func workerEnv(s *Spawner, repo, sessionID string) []string {
|
func workerEnv(s *Spawner, repo, model, sessionID string) []string {
|
||||||
env := []string{
|
env := []string{
|
||||||
envProviderAPIKey + "=" + os.Getenv(envProviderAPIKey),
|
envProviderAPIKey + "=" + os.Getenv(envProviderAPIKey),
|
||||||
envToken + "=" + daemonToken,
|
envToken + "=" + daemonToken,
|
||||||
@@ -379,6 +382,10 @@ func workerEnv(s *Spawner, repo, sessionID string) []string {
|
|||||||
"PLAYWRIGHT_BROWSERS_PATH=/pw-browsers",
|
"PLAYWRIGHT_BROWSERS_PATH=/pw-browsers",
|
||||||
envLVMHRepo + "=" + repo,
|
envLVMHRepo + "=" + repo,
|
||||||
}
|
}
|
||||||
|
// Optional initial model selection (provider/model-id).
|
||||||
|
if model != "" {
|
||||||
|
env = append(env, "LVMH_MODEL="+model)
|
||||||
|
}
|
||||||
// Gitea token (write scope) so agents can push and open PRs.
|
// Gitea token (write scope) so agents can push and open PRs.
|
||||||
if e := giteaTokenEnv(s.store); e != "" {
|
if e := giteaTokenEnv(s.store); e != "" {
|
||||||
env = append(env, e)
|
env = append(env, e)
|
||||||
@@ -515,7 +522,7 @@ func extractBuildError(body []byte) string {
|
|||||||
// createAndStart provisions volumes, creates and starts the worker container.
|
// createAndStart provisions volumes, creates and starts the worker container.
|
||||||
// A repo-registered custom image (see /api/repos) overrides the default
|
// A repo-registered custom image (see /api/repos) overrides the default
|
||||||
// worker image; the ops agent builds and registers those.
|
// worker image; the ops agent builds and registers those.
|
||||||
func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID string) (string, string, error) {
|
func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model, sessionID string) (string, string, error) {
|
||||||
image := s.resolveImage(repo)
|
image := s.resolveImage(repo)
|
||||||
if image != imageRefWorker {
|
if image != imageRefWorker {
|
||||||
exists, err := s.imageRefExists(ctx, image)
|
exists, err := s.imageRefExists(ctx, image)
|
||||||
@@ -581,7 +588,7 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
|
|||||||
}
|
}
|
||||||
cfg := &container.Config{
|
cfg := &container.Config{
|
||||||
Image: image,
|
Image: image,
|
||||||
Env: workerEnv(s, repo, sessionID),
|
Env: workerEnv(s, repo, model, sessionID),
|
||||||
Labels: map[string]string{labelSession: sessionID},
|
Labels: map[string]string{labelSession: sessionID},
|
||||||
}
|
}
|
||||||
hostCfg := &container.HostConfig{
|
hostCfg := &container.HostConfig{
|
||||||
|
|||||||
+28
-1
@@ -495,7 +495,7 @@ func TestSpawnerSecretsBinds(t *testing.T) {
|
|||||||
sec := t.TempDir()
|
sec := t.TempDir()
|
||||||
t.Setenv(envSecretsDir, sec)
|
t.Setenv(envSecretsDir, sec)
|
||||||
|
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -518,3 +518,30 @@ func TestSpawnerSecretsBinds(t *testing.T) {
|
|||||||
t.Fatalf("secrets binds missing: %v", binds)
|
t.Fatalf("secrets binds missing: %v", binds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkerBindsCaches(t *testing.T) {
|
||||||
|
useFakeGit(t, fakeGitModeOK)
|
||||||
|
f := newFakeDocker()
|
||||||
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
t.Setenv(envPlaywrightCacheDir, "/host/pw")
|
||||||
|
t.Setenv(envCloakCacheDir, "/host/cb")
|
||||||
|
|
||||||
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
waitJobState(t, sp, res.SessionID, stateRunning)
|
||||||
|
creates := f.createsByName("lvmh-agent-")
|
||||||
|
binds := creates[0].HostConfig.Binds
|
||||||
|
for _, want := range []string{"/host/pw:/pw-browsers:ro", "/host/cb:/cloakbrowser-cache:ro"} {
|
||||||
|
ok := false
|
||||||
|
for _, b := range binds {
|
||||||
|
if b == want {
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("bind %s missing from %v", want, binds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+45
-20
@@ -29,7 +29,7 @@ func TestSpawnerStartHappyPath(t *testing.T) {
|
|||||||
f := newFakeDocker()
|
f := newFakeDocker()
|
||||||
sp, store := newTestSpawner(t, f)
|
sp, store := newTestSpawner(t, f)
|
||||||
|
|
||||||
res, err := sp.Start(context.Background(), "group/project", "main")
|
res, err := sp.Start(context.Background(), "group/project", "main", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ func TestSpawnerBuildsImageWhenMissing(t *testing.T) {
|
|||||||
f.images = 0 // image absent → ensureImage must build
|
f.images = 0 // image absent → ensureImage must build
|
||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,7 @@ func TestSpawnerStartValidatesDockerAndDockerfile(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSpawner: %v", err)
|
t.Fatalf("NewSpawner: %v", err)
|
||||||
}
|
}
|
||||||
_, err = sp2.Start(context.Background(), "group/project", "")
|
_, err = sp2.Start(context.Background(), "group/project", "", "")
|
||||||
if err == nil || !strings.Contains(err.Error(), "no worker Dockerfile") {
|
if err == nil || !strings.Contains(err.Error(), "no worker Dockerfile") {
|
||||||
t.Fatalf("Start without dockerfile err = %v", err)
|
t.Fatalf("Start without dockerfile err = %v", err)
|
||||||
}
|
}
|
||||||
@@ -247,7 +247,7 @@ func TestSpawnerRunJobErrorStates(t *testing.T) {
|
|||||||
tc.setup(t, f)
|
tc.setup(t, f)
|
||||||
}
|
}
|
||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -384,11 +384,11 @@ func TestSpawnerSameRepoSpawnsSerialize(t *testing.T) {
|
|||||||
t.Fatal("slugLock must return distinct mutexes per slug")
|
t.Fatal("slugLock must return distinct mutexes per slug")
|
||||||
}
|
}
|
||||||
|
|
||||||
res1, err := sp.Start(context.Background(), "group/project", "")
|
res1, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start 1: %v", err)
|
t.Fatalf("Start 1: %v", err)
|
||||||
}
|
}
|
||||||
res2, err := sp.Start(context.Background(), "group/project", "")
|
res2, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start 2: %v", err)
|
t.Fatalf("Start 2: %v", err)
|
||||||
}
|
}
|
||||||
@@ -436,7 +436,7 @@ func TestSpawnerCloneUsesHeaderAuthNotURLCredentials(t *testing.T) {
|
|||||||
t.Fatalf("set token: %v", err)
|
t.Fatalf("set token: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -491,7 +491,7 @@ func TestSpawnerFailedSeedRemovesRepoVolume(t *testing.T) {
|
|||||||
f.failArchive = true // CopyToContainer fails → seed fails
|
f.failArchive = true // CopyToContainer fails → seed fails
|
||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -514,7 +514,7 @@ func TestSpawnerFailedSeedSurvivesVolumeRemoveFailure(t *testing.T) {
|
|||||||
f.failVolumeDelete = true // cleanup itself fails; seed error still surfaces
|
f.failVolumeDelete = true // cleanup itself fails; seed error still surfaces
|
||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -722,7 +722,7 @@ func TestSpawnerStartDockerUnavailable(t *testing.T) {
|
|||||||
// because the client is already built; close the fake server instead.
|
// because the client is already built; close the fake server instead.
|
||||||
sp.cli.Close()
|
sp.cli.Close()
|
||||||
closeDocker(t, sp)
|
closeDocker(t, sp)
|
||||||
if _, err := sp.Start(context.Background(), "group/project", ""); err == nil || !strings.Contains(err.Error(), "docker unavailable") {
|
if _, err := sp.Start(context.Background(), "group/project", "", ""); err == nil || !strings.Contains(err.Error(), "docker unavailable") {
|
||||||
t.Fatalf("Start with dead docker = %v, want docker unavailable", err)
|
t.Fatalf("Start with dead docker = %v, want docker unavailable", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -799,7 +799,7 @@ func TestSpawnerSessionsVolumeCreateFails(t *testing.T) {
|
|||||||
f.volume[volumeRepoPrefix+repoSlug("group/project")] = true // repo volume exists → skip seed
|
f.volume[volumeRepoPrefix+repoSlug("group/project")] = true // repo volume exists → skip seed
|
||||||
f.failVolumeCreate = true
|
f.failVolumeCreate = true
|
||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -844,7 +844,7 @@ func TestSpawnerStoreFailures(t *testing.T) {
|
|||||||
dead2 := openTestStore(t)
|
dead2 := openTestStore(t)
|
||||||
_ = dead2.Close()
|
_ = dead2.Close()
|
||||||
sp2.store = dead2
|
sp2.store = dead2
|
||||||
res, err := sp2.Start(context.Background(), "group/project", "")
|
res, err := sp2.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -877,7 +877,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
|
|||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
|
||||||
f.failCreate = true
|
f.failCreate = true
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -887,7 +887,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
|
|||||||
|
|
||||||
f.failCreate = false
|
f.failCreate = false
|
||||||
f.failStart = true
|
f.failStart = true
|
||||||
res2, err := sp.Start(context.Background(), "group/project", "")
|
res2, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start 2: %v", err)
|
t.Fatalf("Start 2: %v", err)
|
||||||
}
|
}
|
||||||
@@ -906,7 +906,7 @@ func TestSpawnerBuildHTTPErrors(t *testing.T) {
|
|||||||
f.images = 0
|
f.images = 0
|
||||||
f.failBuildHTTP = true // /build endpoint itself 500s
|
f.failBuildHTTP = true // /build endpoint itself 500s
|
||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1186,7 +1186,7 @@ func TestSpawnerStartDedupesImageList(t *testing.T) {
|
|||||||
useFakeGit(t, fakeGitModeOK)
|
useFakeGit(t, fakeGitModeOK)
|
||||||
f := newFakeDocker()
|
f := newFakeDocker()
|
||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1209,7 +1209,7 @@ func TestSpawnerCustomImageUsed(t *testing.T) {
|
|||||||
t.Fatalf("set repo image: %v", err)
|
t.Fatalf("set repo image: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1238,7 +1238,7 @@ func TestSpawnerCustomImageMissing(t *testing.T) {
|
|||||||
t.Fatalf("set repo image: %v", err)
|
t.Fatalf("set repo image: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1266,7 +1266,7 @@ func TestSpawnerCustomImageDeleteFallsBack(t *testing.T) {
|
|||||||
t.Fatalf("delete repo image: %v", err)
|
t.Fatalf("delete repo image: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := sp.Start(context.Background(), "group/project", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1287,8 +1287,33 @@ func TestSpawnerCustomImageCheckDockerDead(t *testing.T) {
|
|||||||
t.Fatalf("set repo image: %v", err)
|
t.Fatalf("set repo image: %v", err)
|
||||||
}
|
}
|
||||||
sp.images0AndDead(t)
|
sp.images0AndDead(t)
|
||||||
_, _, err := sp.createAndStart(context.Background(), "group/project", repoSlug("group/project"), "s1")
|
_, _, err := sp.createAndStart(context.Background(), "group/project", repoSlug("group/project"), "", "s1")
|
||||||
if err == nil || !strings.Contains(err.Error(), "docker unavailable") {
|
if err == nil || !strings.Contains(err.Error(), "docker unavailable") {
|
||||||
t.Fatalf("createAndStart with dead docker = %v, want docker unavailable", err)
|
t.Fatalf("createAndStart with dead docker = %v, want docker unavailable", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSpawnerModelEnv(t *testing.T) {
|
||||||
|
useFakeGit(t, fakeGitModeOK)
|
||||||
|
f := newFakeDocker()
|
||||||
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
|
||||||
|
res, err := sp.Start(context.Background(), "group/project", "", "zai-renaud/glm-5.2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
waitJobState(t, sp, res.SessionID, stateRunning)
|
||||||
|
creates := f.createsByName("lvmh-agent-")
|
||||||
|
if len(creates) != 1 {
|
||||||
|
t.Fatalf("creates = %d", len(creates))
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, e := range creates[0].Env {
|
||||||
|
if e == "LVMH_MODEL=zai-renaud/glm-5.2" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("LVMH_MODEL missing from %v", creates[0].Env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+28
-2
@@ -4,7 +4,10 @@
|
|||||||
// Global npm layout: resolve pi via absolute path (NODE_PATH does not apply to ESM).
|
// Global npm layout: resolve pi via absolute path (NODE_PATH does not apply to ESM).
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { createAgentSession } from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js";
|
import {
|
||||||
|
createAgentSession,
|
||||||
|
ModelRuntime,
|
||||||
|
} from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js";
|
||||||
|
|
||||||
const SETUP_PATH = "/workspace/.lvmh/setup.sh";
|
const SETUP_PATH = "/workspace/.lvmh/setup.sh";
|
||||||
const SETUP_TIMEOUT_MS = 10 * 60 * 1000;
|
const SETUP_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
@@ -57,7 +60,30 @@ process.on("unhandledRejection", (err) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await runRepoSetup();
|
await runRepoSetup();
|
||||||
const { session } = await createAgentSession();
|
// Optional initial model: LVMH_MODEL="provider/model-id" from the spawn
|
||||||
|
// request. Resolved against the session's model registry; a bad value is
|
||||||
|
// logged and falls back to the default model.
|
||||||
|
const requested = process.env.LVMH_MODEL ?? "";
|
||||||
|
let model;
|
||||||
|
if (requested.includes("/")) {
|
||||||
|
const slashIdx = requested.indexOf("/");
|
||||||
|
const provider = requested.slice(0, slashIdx);
|
||||||
|
const modelId = requested.slice(slashIdx + 1);
|
||||||
|
try {
|
||||||
|
const runtime = await ModelRuntime.create();
|
||||||
|
const models = await runtime.getAvailable(provider);
|
||||||
|
const found = models.find((m) => m.id === modelId);
|
||||||
|
if (found === undefined) {
|
||||||
|
console.error(`[lvmh-bridge] LVMH_MODEL ${requested} not found; using default`);
|
||||||
|
} else {
|
||||||
|
model = found;
|
||||||
|
console.error(`[lvmh-bridge] initial model: ${requested}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[lvmh-bridge] model resolution failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { session } = await createAgentSession(model === undefined ? {} : { model });
|
||||||
// SDK does not bind extensions implicitly (unlike TUI/RPC modes); without
|
// SDK does not bind extensions implicitly (unlike TUI/RPC modes); without
|
||||||
// bindExtensions the session_start event never fires, so the lvmh plugin
|
// bindExtensions the session_start event never fires, so the lvmh plugin
|
||||||
// would never dial the daemon.
|
// would never dial the daemon.
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ export function groupCatalog(entries: ModelCatalogEntry[]): CatalogGroup[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Load the model catalog once (module cache shared across views). */
|
||||||
|
export function fetchCatalog(): Promise<ModelCatalogEntry[]> {
|
||||||
|
if (catalogCache !== null) return Promise.resolve(catalogCache);
|
||||||
|
return fetchJson<ModelCatalogEntry[]>(Route.ModelCatalog).then((list) => {
|
||||||
|
catalogCache = list;
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Test seam: drop the module-level catalog cache. */
|
/** Test seam: drop the module-level catalog cache. */
|
||||||
export function resetCatalogCache(): void {
|
export function resetCatalogCache(): void {
|
||||||
catalogCache = null;
|
catalogCache = null;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Repo, RepoImage, SessionListItem } from "./protocol";
|
import type { Repo, RepoImage, SessionListItem } from "./protocol";
|
||||||
import { Route as ApiRoute } from "./protocol";
|
import { Route as ApiRoute } from "./protocol";
|
||||||
import { fetchJson } from "./api";
|
import { fetchJson } from "./api";
|
||||||
|
import { resetCatalogCache } from "./ChatView";
|
||||||
import SpawnView from "./SpawnView";
|
import SpawnView from "./SpawnView";
|
||||||
import type { SessionsStore } from "./store";
|
import type { SessionsStore } from "./store";
|
||||||
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
||||||
@@ -667,3 +668,50 @@ describe("SpawnView repo images (registry)", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("spawn model selection", () => {
|
||||||
|
beforeEach(() => resetCatalogCache());
|
||||||
|
it("model select lists catalog grouped by provider", async () => {
|
||||||
|
mockFetchJson((url) => {
|
||||||
|
if (url.endsWith("/api/model-catalog"))
|
||||||
|
return [
|
||||||
|
{ provider: "zai-renaud", id: "glm-5.3", name: "GLM-5.3" },
|
||||||
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Sonnet" },
|
||||||
|
];
|
||||||
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
|
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||||
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
render(tree(makeStore()));
|
||||||
|
await flush();
|
||||||
|
const sel = screen.getByLabelText("Initial model");
|
||||||
|
await waitFor(() => expect(sel.querySelectorAll("optgroup").length).toBe(2));
|
||||||
|
expect(sel.querySelector('option[value="zai-renaud/glm-5.3"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selected model is sent in the spawn body", async () => {
|
||||||
|
const bodies: string[] = [];
|
||||||
|
mockFetchJson((url, init) => {
|
||||||
|
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||||
|
bodies.push(String(init.body));
|
||||||
|
return { sessionId: "sm1", imageUsed: "lvmh-worker:latest" };
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/model-catalog"))
|
||||||
|
return [{ provider: "zai-renaud", id: "glm-5.3", name: "GLM-5.3" }];
|
||||||
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
|
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||||
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
render(tree(makeStore()));
|
||||||
|
await flush();
|
||||||
|
fireEvent.click(screen.getByText("g/p"));
|
||||||
|
const sel = screen.getByLabelText("Initial model");
|
||||||
|
await waitFor(() => expect(sel.querySelectorAll("option").length).toBeGreaterThan(1));
|
||||||
|
fireEvent.change(sel, { target: { value: "zai-renaud/glm-5.3" } });
|
||||||
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
|
await flush();
|
||||||
|
expect(bodies[0]).toContain('"model":"zai-renaud/glm-5.3"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import type {
|
import type {
|
||||||
GitlabStatus,
|
GitlabStatus,
|
||||||
|
ModelCatalogEntry,
|
||||||
Repo,
|
Repo,
|
||||||
RepoImage,
|
RepoImage,
|
||||||
SpawnJob,
|
SpawnJob,
|
||||||
@@ -10,6 +11,7 @@ import type {
|
|||||||
import { Route } from "./protocol";
|
import { Route } from "./protocol";
|
||||||
import { errMessage, fetchJson } from "./api";
|
import { errMessage, fetchJson } from "./api";
|
||||||
import type { SessionsStore } from "./store";
|
import type { SessionsStore } from "./store";
|
||||||
|
import { fetchCatalog, groupCatalog } from "./ChatView";
|
||||||
|
|
||||||
const POLL_MS: number = 1500;
|
const POLL_MS: number = 1500;
|
||||||
const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish)
|
const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish)
|
||||||
@@ -59,10 +61,22 @@ export default function SpawnView({ store, pushToast }: Props) {
|
|||||||
const [query, setQuery] = useState<string>("");
|
const [query, setQuery] = useState<string>("");
|
||||||
const [selected, setSelected] = useState<Repo | null>(null);
|
const [selected, setSelected] = useState<Repo | null>(null);
|
||||||
const [branch, setBranch] = useState<string>("");
|
const [branch, setBranch] = useState<string>("");
|
||||||
|
const [model, setModel] = useState<string>("");
|
||||||
|
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
||||||
const [busy, setBusy] = useState<boolean>(false);
|
const [busy, setBusy] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const [spawning, setSpawning] = useState<SpawnResponse | null>(null);
|
const [spawning, setSpawning] = useState<SpawnResponse | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
void fetchCatalog().then((c) => {
|
||||||
|
if (alive) setCatalog(c);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
const tickRef = useRef<number>(0);
|
const tickRef = useRef<number>(0);
|
||||||
|
|
||||||
@@ -161,6 +175,7 @@ export default function SpawnView({ store, pushToast }: Props) {
|
|||||||
const body = {
|
const body = {
|
||||||
repo: selected!.path,
|
repo: selected!.path,
|
||||||
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}),
|
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}),
|
||||||
|
...(model.length > 0 ? { model } : {}),
|
||||||
};
|
};
|
||||||
const res = await fetchJson<SpawnResponse>(Route.Spawn, {
|
const res = await fetchJson<SpawnResponse>(Route.Spawn, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -391,6 +406,29 @@ export default function SpawnView({ store, pushToast }: Props) {
|
|||||||
{registration(selected.path)?.image ?? "base worker image"}
|
{registration(selected.path)?.image ?? "base worker image"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<div className="row" style={{ marginTop: 8 }}>
|
||||||
|
<select
|
||||||
|
aria-label="Initial model"
|
||||||
|
value={model}
|
||||||
|
disabled={selected === null}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Default model (settings)</option>
|
||||||
|
{catalog !== null &&
|
||||||
|
groupCatalog(catalog).map((g) => (
|
||||||
|
<optgroup key={g.provider} label={g.provider}>
|
||||||
|
{g.models.map((m) => (
|
||||||
|
<option
|
||||||
|
key={`${m.provider}/${m.id}`}
|
||||||
|
value={`${m.provider}/${m.id}`}
|
||||||
|
>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
{error.length > 0 && <p className="error-text">{error}</p>}
|
{error.length > 0 && <p className="error-text">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user