Files
Maven/internal/config/workstation.go
T
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

218 lines
8.0 KiB
Go

package config
import (
"fmt"
"net"
"net/url"
"strings"
"time"
)
// WorkstationConfig — the big model on the owner's desktop (workpc, a
// 7900 GRE with 16GB), fronted by mavgpud.
//
// homesrv cannot grow a GPU, so the resident Qwen3-1.7B is the floor and this
// is the preferred model above it (owner's call, 2026-08-02, docs/offload.md).
// The workstation is never assumed up: its card is often held by a CPT run and
// the machine sleeps. No block, or an empty URL, and homesrv behaves exactly as
// it does today.
//
// Only the prompt crosses the LAN, and the workstation is not "the box". The
// rules in CLAUDE.md about what may leave still apply.
type WorkstationConfig struct {
// Disabled keeps both written workpc arms explicitly dark. ModelDisabled is
// the narrower switch: CW2 STT may remain live while the large-model
// supervisor has no provisioned client token.
Disabled bool `json:"disabled,omitempty"`
ModelDisabled bool `json:"model_disabled,omitempty"`
// URL — where mavgpud listens, e.g. "http://192.168.1.105:8080". Empty ⇒
// the whole block is normalised to nil and nothing probes anything.
URL string `json:"url,omitempty"`
// Health — the admission endpoint. Empty ⇒ URL + "/health", which is what
// mavgpud serves. It answers 503 while the card is held, and that is the
// signal, so it must be the supervisor's endpoint and not llama-server's.
Health string `json:"health,omitempty"`
// Token — the bearer credential mavgpud requires, expanded from the
// environment like every other secret here. It is what stops anything on
// the LAN spending the card, so a URL that is not loopback needs one.
// A missing token on a LAN URL fails config validation. Loopback development
// endpoints may omit it.
Token string `json:"token,omitempty"`
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
// Nothing on the hot path waits for it: the answer is cached and read
// atomically, so this only sets how late Maven notices the card came back.
Probe Duration `json:"probe,omitempty"`
// Timeout — the per-request budget for a completion on the workstation.
// 0 ⇒ DefaultWorkstationTimeout. A big model on a LAN host is slower than
// the resident one, and a request that overruns falls back to the floor.
Timeout Duration `json:"timeout,omitempty"`
// Stt — CrisperWhisper 2.0 on the same machine, a separate service on its
// own port. Absent ⇒ every utterance goes to mavsttd, which is today.
Stt *WorkstationSttConfig `json:"stt,omitempty"`
}
// WorkstationSttConfig — speech-to-text on the workstation.
//
// It is a second service and not a second endpoint on mavgpud: whisper.cpp
// cannot load CrisperWhisper 2.0 at all, because it derives its language count
// from the vocabulary size and CW2's 51897 tokens shift seven special token
// ids. So CW2 runs under transformers, and this block addresses it.
//
// Worth the trouble: CW2 turbo scores 10.4% WER in Russian against 27.5% for
// the ggml-small.bin homesrv loads
// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md).
type WorkstationSttConfig struct {
// Disabled keeps the written CW2 endpoint dark without also disabling the
// independently authenticated model supervisor.
Disabled bool `json:"disabled,omitempty"`
// URL — the transcribe endpoint, e.g.
// "http://192.168.1.105:8081/transcribe". Empty ⇒ the block is normalised
// to nil and mavsttd takes every turn.
URL string `json:"url,omitempty"`
// Health — the admission endpoint. Empty ⇒ the URL's origin + "/health".
// It answers 503 while the card is held, and that is the signal.
Health string `json:"health,omitempty"`
// Token — the bearer token the service checks. Audio is the most sensitive
// thing that crosses this seam, so a LAN deployment should set one. Write
// it as ${MAVEN_STT_TOKEN} and keep the value in deploy/telegram.env, the
// way every other secret in this file is written.
Token string `json:"token,omitempty"`
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
Probe Duration `json:"probe,omitempty"`
// Timeout — the per-request budget for one utterance. 0 ⇒
// DefaultWorkstationSttTimeout. A request that overruns falls back to
// mavsttd, which costs a worse transcript and not the turn.
Timeout Duration `json:"timeout,omitempty"`
}
// Workstation defaults, applied in normaliseWorkstation.
const (
DefaultWorkstationProbe = 15 * time.Second
DefaultWorkstationTimeout = 90 * time.Second
// One utterance, not one completion. A voice turn waits on this, so the
// budget is a few seconds and not a minute and a half.
DefaultWorkstationSttTimeout = 10 * time.Second
)
// normaliseWorkstation applies the block's defaults. No address, no preferred
// model: an unconfigured workstation is the default deploy and must be
// indistinguishable from today.
func (c *Config) normaliseWorkstation() {
if c.Workstation != nil && c.Workstation.Disabled {
c.Workstation = nil
}
if c.Workstation == nil {
return
}
w := c.Workstation
// Preserve the historical empty-block meaning. A live STT sub-block makes
// the parent non-empty; in that case omitting the model URL is an error
// unless model_disabled states the operator's intent.
if !w.ModelDisabled && strings.TrimSpace(w.URL) == "" &&
(w.Stt == nil || w.Stt.Disabled || strings.TrimSpace(w.Stt.URL) == "") {
c.Workstation = nil
return
}
if !w.ModelDisabled {
if strings.TrimSpace(w.Health) == "" {
w.Health = strings.TrimRight(w.URL, "/") + "/health"
}
if w.Probe <= 0 {
w.Probe = Duration(DefaultWorkstationProbe)
}
if w.Timeout <= 0 {
w.Timeout = Duration(DefaultWorkstationTimeout)
}
}
normaliseWorkstationStt(w)
if w.ModelDisabled && w.Stt == nil {
c.Workstation = nil
}
}
// validateWorkstation rejects a live LAN endpoint without its bearer secret.
// Loopback remains useful for local development without manufacturing a secret;
// malformed or non-HTTP endpoints are rejected before any probe starts.
func (c *Config) validateWorkstation() error {
if c.Workstation == nil {
return nil
}
w := c.Workstation
if !w.ModelDisabled {
if err := validateWorkstationEndpoint("workstation.url", w.URL, w.Token); err != nil {
return err
}
if err := validateWorkstationEndpoint("workstation.health", w.Health, w.Token); err != nil {
return err
}
}
if w.Stt != nil {
if err := validateWorkstationEndpoint("workstation.stt.url", w.Stt.URL, w.Stt.Token); err != nil {
return err
}
if err := validateWorkstationEndpoint("workstation.stt.health", w.Stt.Health, w.Stt.Token); err != nil {
return err
}
}
return nil
}
func validateWorkstationEndpoint(name, raw, token string) error {
u, err := url.Parse(raw)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("%s must be an absolute http(s) URL", name)
}
host := strings.TrimSpace(u.Hostname())
loopback := strings.EqualFold(host, "localhost")
if ip := net.ParseIP(host); ip != nil {
loopback = ip.IsLoopback()
}
if !loopback && strings.TrimSpace(token) == "" {
return fmt.Errorf("%s is not loopback, so its token is required while enabled", name)
}
return nil
}
// normaliseWorkstationStt applies the speech-to-text block's defaults. No
// address, no remote: mavsttd then takes every utterance, which is today.
func normaliseWorkstationStt(w *WorkstationConfig) {
if w.Stt != nil && (w.Stt.Disabled || strings.TrimSpace(w.Stt.URL) == "") {
w.Stt = nil
}
if w.Stt == nil {
return
}
s := w.Stt
if strings.TrimSpace(s.Health) == "" {
s.Health = healthOrigin(s.URL)
}
if s.Probe <= 0 {
s.Probe = Duration(DefaultWorkstationProbe)
}
if s.Timeout <= 0 {
s.Timeout = Duration(DefaultWorkstationSttTimeout)
}
}
// healthOrigin derives the admission endpoint from the transcribe endpoint.
// The URL names a path, so appending to it would ask for /transcribe/health.
func healthOrigin(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return strings.TrimRight(raw, "/") + "/health"
}
return u.Scheme + "://" + u.Host + "/health"
}