daemon: integrate QoL slices 1-5 (model/rename/catalog, history deletes, stats, repo prepare+imageUsed+built) — 95.3% cover green

This commit is contained in:
Raphael Westphal
2026-08-19 10:07:52 +02:00
parent 7287b831e4
commit 698b742cb3
16 changed files with 1506 additions and 36 deletions
+189 -2
View File
@@ -9,10 +9,12 @@ import (
"errors"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
@@ -21,14 +23,21 @@ import (
const (
envToken string = "LVMH_TOKEN"
envModelsFile string = "LVMH_MODELS_FILE"
defaultModelsFile string = "/app/build/docker/worker-models.json"
defaultEventsAfter int64 = 0
defaultEventsLimit int = 1000
maxEventsLimit int = 10000
maxBodyBytes int64 = 1 << 20
webIndexFallback string = "index.html"
reposPathPrefix string = "/api/repos/"
reposPathSuffix string = "/image"
reposPathPrefix string = "/api/repos/"
reposPrepareSuffix string = "/prepare"
// opsPreparePrompt is sent to the ops control session by
// POST /api/repos/<repo>/prepare.
opsPreparePrompt string = "prepare %s: clone, build a worker image, register it"
reposPathSuffix string = "/image"
)
// imageNameRe pins registered images to the lvmh-worker- namespace so agents
@@ -63,12 +72,18 @@ func (s *Server) Routes(webdist string) http.Handler {
api := http.NewServeMux()
api.HandleFunc("GET /api/sessions", s.handleSessions)
api.HandleFunc("GET /api/sessions/{id}/events", s.handleSessionEvents)
api.HandleFunc("GET /api/sessions/{id}/stats", s.handleSessionStats)
api.HandleFunc("GET /api/stats", s.handleStats)
api.HandleFunc("POST /api/sessions/{id}/prompt", s.handlePrompt)
api.HandleFunc("POST /api/sessions/{id}/abort", s.handleAbort)
api.HandleFunc("POST /api/sessions/{id}/model", s.handleSetModel)
api.HandleFunc("PATCH /api/sessions/{id}", s.handleRenameSession)
api.HandleFunc("DELETE /api/sessions/{id}/container", s.handleDeleteContainer)
api.HandleFunc("GET /api/model-catalog", s.handleModelCatalog)
api.HandleFunc("POST /api/spawn", s.handleSpawn)
api.HandleFunc("GET /api/spawn/status", s.handleSpawnStatus)
api.HandleFunc("GET /api/repos", s.handleRepoImages)
api.HandleFunc("POST /api/repos/", s.handlePrepareRepo)
// repo paths contain "/" (group/project), which a single {repo} wildcard
// segment cannot match — subtree routes with manual path parsing instead.
api.HandleFunc("PUT /api/repos/", s.handleSetRepoImage)
@@ -268,6 +283,60 @@ func (s *Server) handleAbort(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleSetModel(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Provider string `json:"provider"`
ModelID string `json:"modelId"`
}
if !decodeBody(w, r, &body) {
return
}
if strings.TrimSpace(body.ModelID) == "" || strings.TrimSpace(body.Provider) == "" {
writeError(w, http.StatusBadRequest, "provider and modelId required")
return
}
if err := s.hub.SetModel(id, body.Provider, body.ModelID); err != nil {
if errors.Is(err, ErrOffline) {
writeError(w, http.StatusConflict, "session offline")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleRenameSession(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Name string `json:"name"`
}
if !decodeBody(w, r, &body) {
return
}
if strings.TrimSpace(body.Name) == "" {
writeError(w, http.StatusBadRequest, "name required")
return
}
found, err := s.store.SetSessionName(id, body.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
writeError(w, http.StatusNotFound, "unknown session")
return
}
// best-effort mirror into the live session; offline renames persist
// daemon-side only (documented edge in PROTOCOL.md)
if err := s.hub.Rename(id, body.Name); err != nil && !errors.Is(err, ErrOffline) {
log.Printf("api: rename frame %s: %v", id, err)
}
s.hub.BroadcastSessionList()
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleDeleteContainer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var err error
@@ -334,6 +403,14 @@ func (s *Server) handleRepoImages(w http.ResponseWriter, r *http.Request) {
if rows == nil {
rows = []RepoImageRow{}
}
for i := range rows {
built, err := s.spawn.imageRefExists(r.Context(), rows[i].Image)
if err != nil {
writeError(w, http.StatusInternalServerError, "docker: "+err.Error())
return
}
rows[i].Built = built
}
writeJSON(w, http.StatusOK, rows)
}
@@ -424,6 +501,62 @@ func (s *Server) handleGitLabRepos(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, repos)
}
// ModelCatalogItem is one selectable model in GET /api/model-catalog.
type ModelCatalogItem struct {
Provider string `json:"provider"`
ID string `json:"id"`
Name string `json:"name"`
}
// parseModelCatalog extracts provider/id/name entries from a pi models.json
// file body, grouped per provider (file order preserved within a provider).
func parseModelCatalog(data []byte) []ModelCatalogItem {
var doc struct {
Providers map[string]struct {
Models []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"models"`
} `json:"providers"`
}
if err := json.Unmarshal(data, &doc); err != nil {
return nil
}
providers := make([]string, 0, len(doc.Providers))
for p := range doc.Providers {
providers = append(providers, p)
}
sort.Strings(providers)
out := []ModelCatalogItem{}
for _, p := range providers {
for _, m := range doc.Providers[p].Models {
if m.ID == "" {
continue
}
name := m.Name
if name == "" {
name = m.ID
}
out = append(out, ModelCatalogItem{Provider: p, ID: m.ID, Name: name})
}
}
return out
}
func (s *Server) handleModelCatalog(w http.ResponseWriter, r *http.Request) {
data, err := os.ReadFile(envOr(envModelsFile, defaultModelsFile))
if err != nil {
writeError(w, http.StatusInternalServerError, "model catalog unavailable: "+err.Error())
return
}
catalog := parseModelCatalog(data)
if catalog == nil {
writeError(w, http.StatusInternalServerError, "model catalog unreadable")
return
}
writeJSON(w, http.StatusOK, catalog)
}
// webHandler serves the embedded web/dist, or an override directory when
// webdist is non-empty (dev mode / mounted volume).
func (s *Server) webHandler(webdist string) http.Handler {
@@ -463,3 +596,57 @@ func (s *Server) webHandler(webdist string) http.Handler {
// daemonToken is set by main from LVMH_TOKEN (single process, read-only).
var daemonToken string
// repoFromPreparePath extracts the repo path from /api/repos/<repo>/prepare.
func repoFromPreparePath(path string) (string, bool) {
return repoFromSubPath(path, reposPrepareSuffix)
}
func (s *Server) handlePrepareRepo(w http.ResponseWriter, r *http.Request) {
repo, ok := repoFromPreparePath(r.URL.Path)
if !ok {
writeError(w, http.StatusBadRequest, "repo must look like group/project")
return
}
if err := s.hub.Prompt(opsSessionID, fmt.Sprintf(opsPreparePrompt, repo)); err != nil {
if errors.Is(err, ErrOffline) {
writeError(w, http.StatusConflict, "ops session offline")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func repoFromSubPath(path, suffix string) (string, bool) {
rest, ok := strings.CutSuffix(strings.TrimPrefix(path, reposPathPrefix), suffix)
if !ok || !validRepoPath(rest) {
return "", false
}
return rest, true
}
func (s *Server) handleSessionStats(w http.ResponseWriter, r *http.Request) {
stats, err := s.store.SessionStats(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, stats)
}
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
totals, err := s.store.StatsTotals()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, statsResponse{StatsTotals: totals, OnlineCount: s.hub.OnlineCount()})
}
// statsResponse is the /api/stats shape: daemon-wide totals plus hub liveness.
type statsResponse struct {
StatsTotals
OnlineCount int `json:"onlineCount"`
}