Files
claude 35c6ff5a71 Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
2026-08-13 02:50:59 +04:00

137 lines
5.3 KiB
Go

package main
import (
"context"
_ "embed"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/webauthn"
)
// The resident-model surface (Vikunja #250).
//
// GET shows which model llama-server actually has loaded and which files the
// daemon is configured to allow. POST swaps to one of them, behind the same
// step-up gate as POST /tools: the loaded model decides how every utterance is
// routed and how every reply is worded, so it is an owner action.
//
// There is nothing on this page Maven can press. The swap is an IPC method rated
// AuthStepUp in internal/auth, unreachable from an act, an intent or a timer.
// modelController — the two non-CoreAPI methods this page needs. *ipc.Client
// satisfies it; a core without a swap allowlist answers ErrUnknownMethod, which
// the page renders as "not configured" rather than an error.
type modelController interface {
ModelStatus(ctx context.Context) (ipc.ModelStatusResp, error)
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
}
//go:embed models.html
var modelsHTML string
var modelsTmpl = parsePage("models", modelsHTML, nil)
type modelsPage struct {
Msg string
Err string
Off bool
Status ipc.ModelStatusResp
}
// handleModels renders the model surface (GET) and applies a swap (POST).
//
// A failed swap is reported as a failure with the model that is still serving
// named, because that is the state the operator needs: the daemon rolled back
// and is answering turns, it just is not answering them with what he asked for.
// swapConn, when non-nil, is a SECOND connection to the same core, used for
// nothing but this page. ipc.Client holds its mutex for a whole roundtrip and
// neither side sets a read deadline, so a swap on the shared connection blocks
// /dash, /history, /notifications and everything else for as long as the load
// takes: a 90s drain plus a 60s launch plus a 30s probe, doubled if it rolls
// back. No browser timeout frees them, because the server side keeps reading
// the reply. On its own connection the swap only blocks the swap.
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swapConn modelController, session *webauthn.PasskeySession, requireStepUp bool) {
if core == nil {
writeProblem(w, r, http.StatusServiceUnavailable, problemCoreUnavailable,
"models disabled (no -core)", nil)
return
}
mc, ok := swapConn, swapConn != nil
if !ok {
mc, ok = core.(modelController)
}
if !ok {
writeProblem(w, r, http.StatusServiceUnavailable, problemModelsUnavailable,
"models unavailable: core connection does not support model swap", nil)
return
}
ctx := r.Context()
page := modelsPage{}
if r.Method == http.MethodPost {
if !stepUpOK(session, requireStepUp) {
writeProblem(w, r, http.StatusForbidden, problemStepUpRequired,
"step-up required: assert a passkey first", nil)
return
}
path := strings.TrimSpace(r.FormValue("model_path"))
if path == "" {
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
"model_path required", nil)
return
}
// Only the path comes off the form. n_ctx and n_gpu_layers are load
// settings the daemon keeps from what is live, and the resident model is
// a Thinking variant whose 4096-token window is sized for reasoning
// tokens (CLAUDE.md). A field nothing renders, that a hand-crafted POST
// could use to shrink the window under the router, is not worth having.
// Changing them is a config edit and a restart.
res, err := mc.SwapModel(ctx, ipc.SwapModelReq{ModelPath: path})
switch {
case err == nil:
page.Msg = "loaded " + res.Model + " (" + strconv.FormatInt(res.TookMs, 10) + "ms)"
log.Printf("models: swapped to %s (%s) in %dms", res.ModelPath, res.Model, res.TookMs)
case errors.Is(err, ipc.ErrForbidden):
writeProblem(w, r, http.StatusForbidden, problemModelsForbidden,
"refused: that model is not in phraser.swap_models, or step-up was not asserted",
fmt.Errorf("swap model %q: %w", path, err))
return
case errors.Is(err, ipc.ErrUnknownMethod):
writeProblem(w, r, http.StatusServiceUnavailable, problemModelsUnavailable,
"swap not configured on this core", fmt.Errorf("swap model %q: %w", path, err))
return
case res.NoBackend:
page.Err = inlineProblem(r, problemModelsUnavailable,
"swap failed AND the rollback failed — no model is loaded. She is answering from templates and routing on the classifier. Try loading a model again; a restart is not needed.",
fmt.Errorf("swap model %q and rollback: %w", path, err))
case res.RolledBack:
page.Err = inlineProblem(r, problemModelsUnavailable,
"swap failed, rolled back to "+res.Model+" — she is still answering, with the old model",
fmt.Errorf("swap model %q, rolled back to %q: %w", path, res.Model, err))
default:
page.Err = inlineProblem(r, problemModelsUnavailable,
"swap failed; the current model state is shown below",
fmt.Errorf("swap model %q: %w", path, err))
}
}
st, err := mc.ModelStatus(ctx)
if err != nil {
if errors.Is(err, ipc.ErrUnknownMethod) {
page.Off = true
} else {
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
"core read failed", fmt.Errorf("read model status: %w", err))
return
}
}
page.Status = st
renderPage(w, modelsTmpl, page)
}