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:
+189
-2
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user