Merge branch 'fix/g07' into fix/integrated

# Conflicts:
#	internal/ipc/api.go
#	internal/ipc/client.go
#	internal/llm/client.go
This commit is contained in:
kami
2026-08-01 14:36:48 +04:00
39 changed files with 1913 additions and 215 deletions
+22 -20
View File
@@ -93,10 +93,12 @@ var querySources = []querySource{
{"memory", (*reactiveHandler).queryMemory},
{"notes", (*reactiveHandler).queryNotes},
// LAST before the model answers from memory, and that position is the whole
// design (Vikunja #259): local sources first. The model, his own notes and
// facts, and — once internal/kiwix is wired into this chain — the offline
// ZIMs all get their turn before anything touches the network. This source
// only claims a turn where he named a URL out loud, so it never competes
// design (Vikunja #259): local sources first. His memory, his notes and
// once internal/kiwix is wired into this chain — the offline ZIMs all get
// their turn before anything touches the network. The model does NOT: it
// answers after this, because a URL he said out loud is an instruction and
// a 1.7B guessing at a page it cannot read is how contents get invented.
// This source only claims a turn where he named a URL, so it never competes
// with a local answer.
{"web", (*reactiveHandler).queryWeb},
{"general-knowledge", (*reactiveHandler).queryGeneral},
@@ -218,7 +220,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
return profile.FormatOverallRU(), true
}
// feedNoteWindow — how many recent notes are scanned for feed items, and
// feedNoteWindow — how many recent FEED notes are scanned, and
// feedReadOut — how many headlines she actually reads back. She summarises the
// top of the pile, she does not recite a river.
const (
@@ -243,25 +245,23 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
// news bulletin.
return "я пока не читаю ленты — они не настроены.", true
}
notes, err := h.api.RecentNotes(ctx, feedNoteWindow)
// By source, not the last 200 notes of any kind: a busy day of voice notes
// used to push the newest headline out of the window, and she answered "в
// лентах пока ничего нового" while the poller was working fine.
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
if err != nil {
log.Printf("voice: feeds: recent notes: %v", err)
return "не получилось посмотреть ленты.", true
}
var picked []string
for _, n := range notes {
if !strings.HasPrefix(n.Source, rss.SourcePrefix) {
if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) {
continue
}
if !router.CategoryMatches(n.Text, q.Category) {
continue
}
// The note carries title, summary and link; she reads the title.
title := n.Text
if i := strings.IndexByte(title, '\n'); i > 0 {
title = title[:i]
}
picked = append(picked, strings.TrimSpace(title))
// The note carries title, summary, category tag and link; she reads the
// title alone. The tag is for the match above, and piper reads brackets
// out loud.
picked = append(picked, rss.NoteHeadline(n.Text))
if len(picked) == feedReadOut {
break
}
@@ -462,10 +462,12 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
return "", false
}
if h.crawler == nil {
// Claim rather than fall through: he asked about a specific page, and
// letting the model answer from the URL's spelling alone is how a small
// model invents a page's contents.
return "я не читаю страницы — это не настроено.", true
// Fall through. Reading pages is off unless configured, and on a daemon
// where it was never turned on the older behaviour is right: the model
// answers the question as if the URL had not been said. Announcing a
// configuration status is for a capability that exists and failed, not
// for one he never asked for.
return "", false
}
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
+44 -9
View File
@@ -20,6 +20,8 @@ package main
import (
"context"
"errors"
"fmt"
"log"
"net/url"
"time"
@@ -39,21 +41,36 @@ func newCrawler(cfg *config.Config) *crawl.Crawler {
return nil
}
cc := cfg.Crawl
// The WATCH crawler, and only it, reaches the watched hosts. webfetch reads
// a non-empty allow list as "these and nothing else", so folding the watch
// hosts in turned a single watch into an allowlist for everything: a config
// with one watch and on_demand true silently refused every other page he
// pasted, with "не получилось прочитать страницу." and no clue why.
return crawlerWithHosts(cc, crawlHosts(cc, true))
}
// crawlHosts — the allowlist for one of the two crawlers. forWatches adds the
// watched pages' own hosts, so a watch does not have to be allowlisted by hand.
//
// The on-demand crawler gets his allow_hosts and nothing else. webfetch reads a
// non-empty list as "these and nothing else", so adding the watch hosts there
// would silently narrow on-demand reading to the watched sites.
func crawlHosts(cc *config.CrawlConfig, forWatches bool) []string {
hosts := append([]string(nil), cc.AllowHosts...)
// A watched page's own host is always reachable; otherwise an allowlist and
// a watch list would have to be kept in sync by hand.
if !forWatches {
return hosts
}
for _, w := range cc.Watches {
if u, err := url.Parse(w.URL); err == nil && u.Hostname() != "" {
hosts = append(hosts, u.Hostname())
}
}
// An allowlist plus on-demand is a contradiction worth logging rather than
// silently resolving: he asked for arbitrary pages AND for a fixed list.
// The allowlist wins, because it is the narrower instruction.
if len(hosts) > 0 && cc.OnDemand && len(cc.AllowHosts) > 0 {
log.Printf("crawl: allow_hosts is set, so on-demand reading is limited to those hosts")
}
return hosts
}
// crawlerWithHosts builds a crawler over one allowlist. Two callers, two lists:
// see newCrawler and onDemandCrawler.
func crawlerWithHosts(cc *config.CrawlConfig, hosts []string) *crawl.Crawler {
ua := cc.UserAgent
if ua == "" {
ua = webfetch.DefaultUserAgent
@@ -81,7 +98,14 @@ func onDemandCrawler(cfg *config.Config) *crawl.Crawler {
if cfg.Crawl == nil || !cfg.Crawl.OnDemand {
return nil
}
return newCrawler(cfg)
cc := cfg.Crawl
// His own allow_hosts, and nothing added behind his back. Empty means "any
// host that is not denied and not private", which is what on-demand reading
// of a URL he just said out loud has to mean.
if len(cc.AllowHosts) > 0 {
log.Printf("crawl: allow_hosts is set, so on-demand reading is limited to those %d host(s)", len(cc.AllowHosts))
}
return crawlerWithHosts(cc, crawlHosts(cc, false))
}
// crawlWorker — ticker + watcher for the scheduled half.
@@ -137,9 +161,20 @@ func (w *crawlWorker) run(ctx context.Context) {
// net/http out of the crawler package.
type crawlFetcher struct{ f *webfetch.Fetcher }
// Get maps webfetch's sentinels onto crawl's. This adapter is the one place
// that imports both packages, so the mapping belongs here; the crawler used to
// match on three substrings of a message it could not see the definition of,
// and a reworded error would have quietly turned a blocked host into "there is
// no robots.txt here".
func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, error) {
resp, err := a.f.Get(ctx, u)
if err != nil {
switch {
case errors.Is(err, webfetch.ErrBlocked), errors.Is(err, webfetch.ErrPrivate), errors.Is(err, webfetch.ErrScheme):
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchRefused, err)
case errors.Is(err, webfetch.ErrStatus):
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchStatus, err)
}
return nil, err
}
return &crawl.Response{URL: resp.URL, ContentType: resp.ContentType, Body: resp.Body}, nil
+63 -9
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -13,6 +14,7 @@ import (
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/voice"
"github.com/kami/maven/internal/webfetch"
)
// The default config reads nothing. This is the whole "off unless configured"
@@ -123,16 +125,13 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
}
}
// Not configured is said out loud rather than falling through, so a small model
// never invents a page's contents from its URL.
func TestQueryWebSaysWhenNotConfigured(t *testing.T) {
// A daemon where page reading was never turned on — the default — answers the
// question the way it did before the capability existed. Claiming the turn to
// report a configuration status is for something that exists and failed.
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
h := buildWebHandler(nil)
reply, ok := askWeb(h, "посмотри https://example.org/page")
if !ok {
t.Fatal("the web source did not claim a question with a URL")
}
if !strings.Contains(reply, "не настроено") {
t.Errorf("reply = %q, want the not-configured answer", reply)
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
}
}
@@ -184,3 +183,58 @@ func (robotsDenyFetcher) Get(_ context.Context, u string) (*crawl.Response, erro
}
return &crawl.Response{URL: u, ContentType: "text/html", Body: []byte("<html>nope</html>")}, nil
}
// TestCrawlHostsKeepsAWatchOutOfTheOnDemandAllowlist — the on-demand crawler
// used to be built over allow_hosts PLUS every watched host. webfetch reads a
// non-empty allow list as "these and nothing else", so one watch on a config
// with no allow_hosts at all turned unrestricted on-demand reading into
// "the watched site only", and every other URL he pasted came back as
// "не получилось прочитать страницу." with nothing in the log to explain it.
func TestCrawlHostsKeepsAWatchOutOfTheOnDemandAllowlist(t *testing.T) {
cc := &config.CrawlConfig{
OnDemand: true,
Watches: []config.CrawlWatchConfig{{Name: "p", URL: "https://watched.example/p"}},
}
if got := crawlHosts(cc, false); len(got) != 0 {
t.Errorf("on-demand allowlist = %v; a watch is not an allowlist entry, and an empty list is what means \"anything public\"", got)
}
if got := crawlHosts(cc, true); len(got) != 1 || got[0] != "watched.example" {
t.Errorf("watch allowlist = %v; want the watched host so a watch needs no hand-written entry", got)
}
// With allow_hosts set, his list is what on-demand gets, unchanged.
cc.AllowHosts = []string{"wiki.example"}
on := crawlHosts(cc, false)
if len(on) != 1 || on[0] != "wiki.example" {
t.Errorf("on-demand allowlist = %v; want exactly his allow_hosts", on)
}
if got := crawlHosts(cc, true); len(got) != 2 {
t.Errorf("watch allowlist = %v; want his hosts plus the watched one", got)
}
}
// TestCrawlFetcherReportsARefusalAsARefusal — internal/crawl cannot import
// webfetch, so it used to recognise a guard refusal by matching substrings of
// webfetch's message text. This adapter owns both packages and is where the
// translation belongs.
func TestCrawlFetcherReportsARefusalAsARefusal(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusBadGateway)
}))
defer srv.Close()
blocked := &crawlFetcher{f: webfetch.New(webfetch.Config{AllowHosts: []string{"wiki.example"}})}
if _, err := blocked.Get(context.Background(), "https://other.example/a"); !errors.Is(err, crawl.ErrFetchRefused) {
t.Errorf("a host outside allow_hosts = %v; want crawl.ErrFetchRefused", err)
}
if _, err := blocked.Get(context.Background(), "file:///etc/passwd"); !errors.Is(err, crawl.ErrFetchRefused) {
t.Errorf("a non-http scheme = %v; want crawl.ErrFetchRefused", err)
}
// A 5xx is a different thing: the server answered, badly. robots.txt over
// this must refuse the crawl rather than read it as "no rules".
open := &crawlFetcher{f: webfetch.New(webfetch.Config{AllowHosts: []string{"127.0.0.1"}, AllowPrivate: true})}
if _, err := open.Get(context.Background(), srv.URL+"/robots.txt"); !errors.Is(err, crawl.ErrFetchStatus) {
t.Errorf("a 502 = %v; want crawl.ErrFetchStatus", err)
}
}
+21 -2
View File
@@ -88,12 +88,12 @@ func TestQueryFeedsByCategory(t *testing.T) {
// answered by the model inventing a bulletin.
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
off := buildFeedHandler(t, false)
reply, ok := askFeeds(t, off, "что нового?")
reply, ok := askFeeds(t, off, "что нового в лентах?")
if !ok || !strings.Contains(reply, "не настроены") {
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
}
on := buildFeedHandler(t, true)
reply, ok = askFeeds(t, on, "что нового?")
reply, ok = askFeeds(t, on, "что нового в лентах?")
if !ok || !strings.Contains(reply, "ничего нового") {
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
}
@@ -104,6 +104,25 @@ func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
if reply, ok := askFeeds(t, h, "напомни полить цветы"); ok {
t.Fatalf("claimed an unrelated question with %q", reply)
}
// The bare greeting is not a request for headlines. It used to be answered
// with a configuration status.
if reply, ok := askFeeds(t, h, "что нового?"); ok {
t.Fatalf("claimed a greeting with %q", reply)
}
}
// A busy day of his own notes must not push the newest headline out of the
// window the feed answer scans.
func TestQueryFeedsIsNotCrowdedOutByHisOwnNotes(t *testing.T) {
notes := []ipc.Note{{Text: "Релиз ядра [технологии]", Source: "rss:habr"}}
for i := 0; i < feedNoteWindow+10; i++ {
notes = append(notes, ipc.Note{Text: "мысль вслух", Source: "tap:voice"})
}
h := buildFeedHandler(t, true, notes...)
reply, ok := askFeeds(t, h, "что нового в лентах?")
if !ok || !strings.Contains(reply, "ядра") {
t.Fatalf("reply = %q, ok = %v; the headline fell out of the window", reply, ok)
}
}
// The mark is what stops a restart from re-noting yesterday's headlines, so the
+6 -1
View File
@@ -377,7 +377,11 @@ func run(args []string) error {
if locked {
srv.Check = func(ctx context.Context, m ipc.Method, _ json.RawMessage) error {
switch m {
case ipc.MethodAssertStepUp, ipc.MethodUnlock:
case ipc.MethodAssertStepUp, ipc.MethodUnlock, ipc.MethodPing:
// Ping is allowed for the same reason the two unlock methods
// are: it never reaches CoreAPI. It answers "she is up and
// locked", which is what mavupdate needs to tell a daemon
// waiting for a passkey apart from one that failed to start.
return nil // allowed in locked mode
default:
return errLocked
@@ -388,6 +392,7 @@ func run(args []string) error {
}
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
srv.LockedFn = dl.isLocked
// Mail ingestion (Vikunja #246): the hook stays nil unless an email block is
// configured and there is a llama-server to extract with, in which case
+8
View File
@@ -58,6 +58,7 @@ func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
ModelPath: res.ModelPath,
BaseURL: res.BaseURL,
RolledBack: res.RolledBack,
NoBackend: res.NoBackend,
TookMs: res.Took.Milliseconds(),
}
if err != nil {
@@ -106,9 +107,15 @@ func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
// the port of a server that no longer exists, and the daemon would degrade to
// the classifier permanently after the first swap. The client is re-pointed, not
// rebuilt, so nothing that holds it has to know a swap happened.
// SetSwapGate is the other half, and on the deploy shape it is the load-bearing
// one:
// llama-server is relaunched on the same fixed port, so SetBaseURL is usually a
// no-op, while the gate is what makes the swap's drain count these callers at
// all. Without it a swap can kill the server mid-routing-decision.
func llmClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
c := llm.New(lp.BaseURL(), timeout)
c.SetGate(residentGate, false)
c.SetSwapGate(lp)
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
return c
}
@@ -136,6 +143,7 @@ var residentGate = llm.NewGate(backgroundQuiet)
func llmBackgroundClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
c := llm.New(lp.BaseURL(), timeout)
c.SetGate(residentGate, true)
c.SetSwapGate(lp)
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
return c
}
+12 -3
View File
@@ -12,9 +12,12 @@
// the gate that guards the tool allowlist. That is deliberate and it is the
// reason there is no MethodApplyUpdate anywhere in internal/ipc.
//
// Consequently: mavend does not import internal/update, nothing runs on a timer,
// nothing checks a release server, and no act, intent, tool or LLM output can
// reach any of this. She cannot update herself. She can be updated, by him.
// Consequently: mavend never constructs an update.Updater and nothing in the
// daemon can call Apply, nothing runs on a timer, nothing checks a release
// server, and no act, intent, tool or LLM output can reach any of this. The
// package is linked into mavend through internal/config, which validates the
// update block at startup; the guarantee is the absent caller, not an absent
// import. She cannot update herself. She can be updated, by him.
//
// mavupdate -config deploy/mavend.json list # snapshots available to roll back to
// mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
@@ -158,6 +161,12 @@ func cmdRollback(ctx context.Context, u *update.Updater, id string) {
res, err := u.Rollback(ctx, id)
report(res.Steps)
summarize(res)
// The standalone rollback is what he reaches for when something is already
// wrong, so a failed one needs the loud paragraph more than apply does, not
// less.
if errors.Is(err, update.ErrRollbackFailed) {
die("\n%v\n\nSHE IS PROBABLY DOWN. The previous artifacts are in the snapshot dir; copy them\nover the install dir and restart by hand.", err)
}
if err != nil && !errors.Is(err, update.ErrRolledBack) {
die("\n%v", err)
}
+13 -1
View File
@@ -364,6 +364,11 @@ func main() {
flag.Parse()
var core ipc.CoreAPI
// swapConn — a second connection, for /models and nothing else. A model swap
// is a multi-minute IPC call and ipc.Client serialises everything on one
// mutex, so sharing the connection would freeze every other page for the
// length of the load. See handleModels.
var swapConn modelController
if *coreSock != "" {
c, err := ipc.DialWait(*coreSock, 60*time.Second)
if err != nil {
@@ -371,6 +376,12 @@ func main() {
}
defer c.Close()
core = c
if sc, err := ipc.Dial(*coreSock); err != nil {
log.Printf("models: second core connection failed (%v) — /models will share the main one and a swap will block the other pages", err)
} else {
defer sc.Close()
swapConn = sc
}
}
mux := http.NewServeMux()
@@ -516,13 +527,14 @@ func main() {
// /tools, and for a comparable reason: which model is loaded decides how every
// utterance is routed and how every reply is worded. GET is read-only.
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
handleModels(w, r, core, stepUpSession, *requireStepUp)
handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp)
})
// State-changing routes on this server, and their gate (Vikunja #317):
//
// POST /tools step-up — defines argv that internal/tool executes
// POST /routines step-up — accepting schedules recurring firing
// POST /models step-up — replaces the model that routes and phrases
// POST /api/revert step-up — voids the latest fact for a key
// POST /api/chat step-up — reaches the router, LLM and the act path
// POST /api/ptt step-up — audio into runTurn, so the same router,
+23 -7
View File
@@ -36,6 +36,7 @@ var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(
const modelsHTML = `{{template "shellTop" "models"}}
<h1>Resident model</h1>
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
<p class=hint>a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one <code>mavupdate</code> does — comes back on <code>phraser.model_path</code> from the config. Make it stick by editing that.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
{{if .Off}}
@@ -80,12 +81,22 @@ type modelsPage struct {
// 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.
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
// 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 {
http.Error(w, "models disabled (no -core)", http.StatusServiceUnavailable)
return
}
mc, ok := core.(modelController)
mc, ok := swapConn, swapConn != nil
if !ok {
mc, ok = core.(modelController)
}
if !ok {
http.Error(w, "models unavailable: core connection does not support model swap", http.StatusServiceUnavailable)
return
@@ -103,11 +114,13 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sess
http.Error(w, "model_path required", http.StatusBadRequest)
return
}
req := ipc.SwapModelReq{ModelPath: path}
if v, err := strconv.Atoi(r.FormValue("n_ctx")); err == nil {
req.NCtx = v
}
res, err := mc.SwapModel(ctx, req)
// 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)"
@@ -118,6 +131,9 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sess
case errors.Is(err, ipc.ErrUnknownMethod):
http.Error(w, "swap not configured on this core", http.StatusServiceUnavailable)
return
case res.NoBackend:
page.Err = "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."
log.Printf("models: swap to %s failed and the rollback failed, no model loaded: %v", path, err)
case res.RolledBack:
page.Err = "swap failed, rolled back to " + res.Model + " — she is still answering, with the old model"
log.Printf("models: swap to %s failed, rolled back: %v", path, err)
+58 -2
View File
@@ -36,7 +36,7 @@ func (f *fakeModelCore) SwapModel(ctx context.Context, req ipc.SwapModelReq) (ip
func modelsGET(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, false)
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, nil, false)
return w
}
@@ -45,7 +45,7 @@ func modelsPOST(t *testing.T, core ipc.CoreAPI, session *webauthn.PasskeySession
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path="+path))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handleModels(w, r, core, session, requireStepUp)
handleModels(w, r, core, nil, session, requireStepUp)
return w
}
@@ -148,3 +148,59 @@ func TestModels_CoreWithoutTheMethodsIs503(t *testing.T) {
type errBrokenModel struct{}
func (errBrokenModel) Error() string { return "llm: server did not start" }
func TestModels_TotalFailureDoesNotSaySheIsStillAnswering(t *testing.T) {
// The load failed and so did the rollback: nothing is loaded. The page used
// to branch on RolledBack first and render "rolled back to — she is still
// answering, with the old model" over an empty model name.
core := &fakeModelCore{
swapResp: ipc.SwapModelResp{NoBackend: true},
swapErr: errBrokenModel{},
status: ipc.ModelStatusResp{Model: "unknown"},
}
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
if w.Code != http.StatusOK {
t.Fatalf("POST /models after a total failure = %d; want 200 with the failure rendered", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "still answering") {
t.Errorf("the page claims she is still answering while no model is loaded:\n%s", body)
}
if !strings.Contains(body, "no model is loaded") {
t.Errorf("the page does not name the state the operator is in:\n%s", body)
}
}
func TestModels_POSTIgnoresLoadSettingsOffTheForm(t *testing.T) {
// n_ctx was read off a form that renders no such input, so only a
// hand-crafted POST could set it. The resident model is a Thinking variant
// whose window is sized for reasoning tokens; shrinking it from the wire is
// not a capability this page offers.
core := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf&n_ctx=512&n_gpu_layers=0"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handleModels(w, r, core, nil, nil, false)
if len(core.swapped) != 1 {
t.Fatalf("SwapModel calls = %v; want one", core.swapped)
}
if got := core.swapped[0]; got.NCtx != 0 || got.NGpuLayers != 0 {
t.Errorf("swap request = %+v; want the load settings left to the daemon", got)
}
}
func TestModels_SwapUsesItsOwnConnection(t *testing.T) {
// A swap is a multi-minute IPC call and ipc.Client serialises everything on
// one mutex, so it must not run on the connection every other page shares.
shared := &fakeModelCore{status: ipc.ModelStatusResp{Model: "qwen3"}}
swapConn := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleModels(httptest.NewRecorder(), r, shared, swapConn, nil, false)
if len(shared.swapped) != 0 {
t.Errorf("the swap went out on the shared connection: %v", shared.swapped)
}
if len(swapConn.swapped) != 1 {
t.Errorf("the swap did not use the dedicated connection: %v", swapConn.swapped)
}
}
+70 -11
View File
@@ -65,7 +65,13 @@ What it does and does not do:
response at 2 MiB and redirects at 3, and makes at most one request per host
per second. See `internal/webfetch`;
- how far each feed was read is stored as a config fact `rss:latest:<name>`, so
a restart does not re-note yesterday's headlines.
a restart does not re-note yesterday's headlines. A feed whose items carry no
dates gets the same mark, and the first poll after a restart takes those items
as already read rather than writing them all again;
- `max_items` paces, it does not drop: a burst larger than the cap arrives over
the following polls, oldest first;
- feed notes are **not** part of recall. "что я говорил про X" searches what he
said; headlines are read back only by asking about the feeds.
### Reading a page (`crawl`, also off by default)
@@ -89,13 +95,23 @@ switched:
a fallback and not a habit;
- `watches` re-reads a fixed list on its interval and writes a note when the
text changed. Like the feeds, it announces nothing;
- the answer path sits **last** in the query chain, behind his memory, his notes
and (once wired) the local Kiwix ZIMs. A local read costs nothing;
- the answer path sits behind his memory and his notes, and ahead of the model
answering from what it remembers. Kiwix is not wired into the chain yet. A
local read costs nothing, so anything local goes first;
- `robots.txt` is fetched first and obeyed with no override; a `Disallow` is a
refusal she says out loud. `Crawl-delay` is honoured;
refusal she says out loud. `Crawl-delay` is waited out before the page is
fetched, and a delay longer than the turn fails the read instead of hanging
it. A `robots.txt` that answers 5xx refuses the crawl — a broken server is
not permission;
- `allow_hosts` limits on-demand reading to those hosts and nothing else.
Watched pages' hosts are reachable by the scheduled crawler whether listed or
not, but a watch does **not** widen what he may ask her to read;
- same guarded fetcher as the feeds: allowlist/denylist, no private addresses,
size cap, redirect cap, timeout, one request per host per second;
- dedup state is the config fact `crawl:hash:<name>`.
- dedup state is the config fact `crawl:hash:<name>`;
- like feed notes, watch notes are kept out of recall (`store.ReadSourcePrefixes`).
Text from someone else's page is not something he said, so it must not come
back as an answer to a question about him. Watch notes are visible on `/dash`.
## Not yet verified / host-dependent
@@ -132,18 +148,61 @@ it), with paths as they exist **on the host**, not inside a container:
"source_dir": "/home/kami/apps/Maven",
"install_dir": "/home/kami/apps/Maven",
"snapshot_dir": "/var/lib/maven-snapshots",
"source_rollback": "git",
"binaries": ["mavend", "mavweb", "mavsttd", "mavttsd", "mavwaked",
"mavenclient", "mavpoll", "mavcaldav", "mavmaild"],
"mavenclient", "mavpoll", "mavcaldav", "mavmaild", "mavupdate"],
"config_files": ["deploy/mavend.json"],
"restart_cmd": ["docker", "compose", "up", "-d", "--build"],
"health_socket": "/var/lib/docker/volumes/maven_sockets/_data/mavend.sock",
"restart_cmd": ["docker", "compose", "up", "-d", "--build", "mavend"],
"health_socket": "/run/maven-host/mavend.sock",
"health_timeout_sec": 120
}
```
`snapshot_dir` must be outside `install_dir` (a restore must not read from what
the install writes) and `health_socket` is required: an update that cannot check
its own result cannot roll itself back, so the config is refused without one.
`snapshot_dir` must be outside both `install_dir` and `source_dir` (a restore
must not read from what the install writes, and a snapshot dir inside the tree
lands in the docker build context). `health_socket` is required: an update that
cannot check its own result cannot roll itself back, so the config is refused
without one.
**`source_rollback` is what makes a rollback real on this deployment.** Compose
builds the image from the tree — the Dockerfile copies `cmd/` and `internal/`
and runs the build in the builder stage, and `.dockerignore` keeps the host
binaries out — so `install_dir` is the tree, `install` is a no-op, and putting
the old binaries back puts back bytes nothing reads. A rollback that only did
that would rebuild the same bad image and burn a second health timeout proving
it. With `"source_rollback": "git"` the commit is recorded before the update and
checked back out before the restart, so the restore is of the thing that
actually gets deployed. It requires a clean tree: `apply` refuses to start with
uncommitted changes, because the recorded commit would not describe what is
deployed and the forced checkout on the way back would delete the work. It also
means a rollback moves every tracked file, `deploy/mavend.json` included, so on
this deployment a config edit belongs in a commit.
Leaving `source_rollback` out is only valid when `install_dir` holds what
actually runs. `Validate` refuses the combination of "same dir" and "no way to
put the source back" at startup rather than at the one rollback that mattered.
**The socket has to be one the account running `mavupdate` can open.** The
compose stack keeps IPC in a named volume, whose host path
(`/var/lib/docker/volumes/maven_sockets/_data`) is under a `drwx--x--- root
root` directory, and the socket itself is 0600 owned by the container's uid
10001. A non-root `mavupdate` gets EACCES on the dial, which reports as
`update: cannot open the health socket` rather than as a daemon that will not
answer. Bind-mount the socket dir to a host path he owns and run the daemon
under his uid instead:
```yaml
mavend:
user: "1000:1000"
volumes:
- /run/maven-host:/run/maven
```
Do **not** work around it with `sudo mavupdate apply`. `verify` runs `make
build` and `make test` in `source_dir`, and as root that leaves root-owned
binaries, object files and a build cache in the working tree, so the next
ordinary `make` fails. `Verify` refuses to run as root over a tree owned by
someone else for exactly that reason.
Then:
+15 -6
View File
@@ -117,10 +117,14 @@ type Config struct {
// the update capability does not exist, which is the state to leave it in
// unless the operator has read internal/update's package comment.
//
// mavend never reads this block: the daemon does not import internal/update
// and cannot update itself. It lives here because cmd/mavupdate — a CLI the
// owner runs on the host, the only trigger there is — reads the same config
// file to find the socket it health-checks.
// mavend never acts on this block: it constructs no Updater and cannot
// update itself. Validate below is the one thing the daemon does with it, so
// a broken update config is caught at startup instead of on the night it is
// needed. That validation is also why internal/update is linked into mavend
// at all — linked, with no caller, which is the property that matters. The
// block lives here because cmd/mavupdate — a CLI the owner runs on the host,
// the only trigger there is — reads the same config file to find the socket
// it health-checks.
Update *update.Config `json:"update,omitempty"`
// Voice — the client↔core surface + the stt/tts modules the daemon
@@ -945,8 +949,13 @@ type CrawlConfig struct {
Interval Duration `json:"interval,omitempty"`
// AllowHosts — when set, the ONLY hosts the crawler may reach (subdomains
// included). Watched pages' own hosts are added automatically. Setting this
// is how "she may read the arch wiki and nothing else" is expressed.
// included). Setting this is how "she may read the arch wiki and nothing
// else" is expressed.
//
// A watched page's own host is reachable by the scheduled crawler whether
// or not it is listed here, because configuring a watch is already saying
// she may read it. That does NOT extend to on-demand reading: a watch is
// not an allowlist entry for pages he pastes.
AllowHosts []string `json:"allow_hosts,omitempty"`
// DenyHosts — never reachable, checked first. Private addresses do not need
+2 -1
View File
@@ -346,7 +346,8 @@ func TestUpdateBlockValidatedAtStartup(t *testing.T) {
"snapshot_dir": "/var/lib/maven/snapshots",
"binaries": ["mavend", "mavweb"],
"restart_cmd": ["docker", "compose", "up", "-d", "--build", "mavend"],
"health_socket": "/run/maven/mavend.sock"
"health_socket": "/run/maven/mavend.sock",
"source_rollback": "git"
}}`
c, err := Load(writeConfig(t, good))
if err != nil {
+125 -45
View File
@@ -1,13 +1,21 @@
// Package crawl reads a web page: fetch, robots check, HTML to text.
//
// It is the LAST place Maven looks for an answer, and that ordering is the whole
// design. "Never phones home" is deprecated, but what replaced it puts local
// sources first: the resident model, then his own memory, then the Kiwix ZIMs on
// the box (internal/kiwix), and only then the network. A local read costs
// nothing and leaks nothing; a fetch costs a round-trip and puts a URL in
// someone's access log. So this package exists to be the fallback, not the
// front door — see the querySources chain in cmd/mavend/actions_query.go for
// where it actually sits.
// It is the last LOCAL-FIRST step, and the ordering is the whole design.
// "Never phones home" is deprecated, but what replaced it puts local sources
// first. Where this actually sits in querySources (cmd/mavend/actions_query.go):
// after his memory and after his notes, and BEFORE the model answers from what
// it remembers. Not after the model, which is what this comment used to claim.
//
// That position is deliberate. A fetch only happens where he named a URL out
// loud, and a named URL is an instruction, not a guess; letting a 1.7B answer
// about a page it cannot read is how a small model invents contents. Kiwix
// (internal/kiwix) is not in the chain yet, so nothing here describes it.
//
// A page's text is written by whoever owns the page. It reaches PhraseQuery as
// context beside his question, and for a watch it becomes a note. It cannot
// reach a tool or an act — the query path executes nothing — but it can steer
// what she says, which is the same trust level as a mail body and lower than
// anything he said himself.
//
// What never leaves the box: his notes, his facts, the persona block, the
// conversation history. Only the URL is requested and, for the on-demand path,
@@ -27,6 +35,7 @@ import (
"fmt"
"net/url"
"strings"
"sync"
"time"
)
@@ -34,6 +43,18 @@ import (
var (
ErrRobots = errors.New("crawl: robots.txt disallows this path")
ErrNotHTML = errors.New("crawl: response is not html or text")
// ErrFetchRefused — the fetcher would not go: a denied host, a private
// address, a scheme that is not http(s). The adapter that owns both
// packages (cmd/mavend/crawls.go) maps webfetch's sentinels onto this one,
// so this package tells "off limits" from "no robots.txt here" without
// importing webfetch and without matching on message text.
ErrFetchRefused = errors.New("crawl: the fetcher refused this url")
// ErrFetchStatus — the server answered, badly (5xx, and anything else
// non-2xx). Separate from ErrFetchRefused because robots treats them
// differently: a broken server is not permission to crawl.
ErrFetchStatus = errors.New("crawl: the server answered with an error status")
)
// Fetcher is the guarded HTTP door (internal/webfetch adapted by the daemon). An
@@ -69,8 +90,20 @@ type Crawler struct {
fetch Fetcher
cfg Config
robots *robotsCache
// mu guards last, the per-host time of the previous fetch. It is what makes
// Crawl-delay real: the fetcher's own limiter is a flat one request per
// host per second and knows nothing about what a site asked for.
mu sync.Mutex
last map[string]time.Time
}
// robotsTimeout — the robots fetch gets its own, shorter deadline. It shares the
// caller's budget with the page fetch (30s for the on-demand path, against a 20s
// default fetch timeout each), so a slow robots.txt used to eat the page's half
// and he heard "не получилось прочитать страницу" about a site that was fine.
const robotsTimeout = 8 * time.Second
// New builds a crawler. Returns nil when there is no fetcher, which is how the
// daemon expresses "crawling is off unless configured".
func New(fetch Fetcher, cfg Config) *Crawler {
@@ -89,7 +122,7 @@ func New(fetch Fetcher, cfg Config) *Crawler {
if cfg.Now == nil {
cfg.Now = time.Now
}
return &Crawler{fetch: fetch, cfg: cfg, robots: newRobotsCache(cfg.RobotsTTL)}
return &Crawler{fetch: fetch, cfg: cfg, robots: newRobotsCache(cfg.RobotsTTL), last: map[string]time.Time{}}
}
// Page fetches rawURL and returns its text. It checks robots.txt first and
@@ -99,14 +132,22 @@ func (c *Crawler) Page(ctx context.Context, rawURL string) (Page, error) {
if err != nil {
return Page{}, fmt.Errorf("crawl: bad url %q: %w", rawURL, err)
}
ok, err := c.allowed(ctx, u)
rules, err := c.rulesFor(ctx, u)
if err != nil {
return Page{}, err
}
if !ok {
path := u.EscapedPath()
if u.RawQuery != "" {
path += "?" + u.RawQuery
}
if !rules.Allowed(path) {
return Page{}, fmt.Errorf("%w: %s", ErrRobots, u.Path)
}
if err := c.waitCrawlDelay(ctx, u.Host, rules.Delay); err != nil {
return Page{}, err
}
resp, err := c.fetch.Get(ctx, u.String())
c.markFetched(u.Host)
if err != nil {
return Page{}, err
}
@@ -120,49 +161,88 @@ func (c *Crawler) Page(ctx context.Context, rawURL string) (Page, error) {
return Extract(resp.URL, resp.Body, c.cfg.MaxRunes), nil
}
// allowed consults robots.txt for u's host, reading it at most once per TTL.
// rulesFor consults robots.txt for u's host, reading it at most once per TTL.
//
// A robots.txt that cannot be fetched (404, a timeout, a blocked host) means
// allow, per the standard. The one thing that is NOT fail-open is an explicit
// Disallow.
func (c *Crawler) allowed(ctx context.Context, u *url.URL) (bool, error) {
// A robots.txt that is not there (404) means allow, per the standard. What does
// NOT mean allow: a server that answered with an error. The standard asks for
// the opposite there, and "the site is broken, so crawl it" is the wrong way to
// resolve an unknown.
func (c *Crawler) rulesFor(ctx context.Context, u *url.URL) (Rules, error) {
host := u.Host
now := c.cfg.Now()
rules, ok := c.robots.get(host, now)
if !ok {
robotsURL := u.Scheme + "://" + host + "/robots.txt"
resp, err := c.fetch.Get(ctx, robotsURL)
switch {
case err != nil:
// Note what is NOT swallowed: a refusal from the guarded fetcher.
// If webfetch says this host is denied or private, the page fetch
// would fail the same way, and reporting the real reason beats
// reporting a robots verdict we never got.
if isFatalFetchError(err) {
return false, err
}
rules = Rules{}
default:
rules = ParseRobots(string(resp.Body), c.cfg.UserAgent)
}
c.robots.put(host, rules, now)
if ok {
return rules, nil
}
path := u.EscapedPath()
if u.RawQuery != "" {
path += "?" + u.RawQuery
robotsURL := u.Scheme + "://" + host + "/robots.txt"
rctx, cancel := context.WithTimeout(ctx, robotsTimeout)
defer cancel()
resp, err := c.fetch.Get(rctx, robotsURL)
c.markFetched(host)
switch {
case err == nil:
rules = ParseRobots(string(resp.Body), c.cfg.UserAgent)
case errors.Is(err, ErrFetchRefused):
// Not swallowed: if the fetcher says this host is denied or private,
// the page fetch would fail the same way, and the real reason beats a
// robots verdict we never got.
return Rules{}, err
case errors.Is(err, ErrFetchStatus) && isServerError(err):
return Rules{}, fmt.Errorf("%w: robots.txt at %s could not be read", ErrFetchStatus, host)
default:
rules = Rules{}
}
return rules.Allowed(path), nil
c.robots.put(host, rules, now)
return rules, nil
}
// isFatalFetchError — a fetch failure that means "this host is off limits"
// rather than "there is no robots.txt here". The sentinel set is webfetch's, but
// this package must not import it (the interface exists precisely so it does
// not), so the check is on the message. Ugly and honest: the alternative is a
// dependency inversion for two strings.
func isFatalFetchError(err error) bool {
// waitCrawlDelay honours a Crawl-delay the site asked for. The fetcher's flat
// one-per-host-per-second is the floor and cannot express "30 seconds"; without
// this, deploy/README's claim that Crawl-delay is honoured was false.
//
// It waits on ctx, so a delay longer than the caller's budget fails the read
// rather than blocking a turn. That is the honest outcome: a site that wants a
// minute between requests is not a site to answer a voice question from.
func (c *Crawler) waitCrawlDelay(ctx context.Context, host string, delay time.Duration) error {
if delay <= 0 {
return nil
}
c.mu.Lock()
last, ok := c.last[host]
c.mu.Unlock()
if !ok {
return nil
}
wait := delay - c.cfg.Now().Sub(last)
if wait <= 0 {
return nil
}
t := time.NewTimer(wait)
defer t.Stop()
select {
case <-ctx.Done():
return fmt.Errorf("crawl: %s asks for %s between requests, longer than this read has: %w", host, delay, ctx.Err())
case <-t.C:
return nil
}
}
func (c *Crawler) markFetched(host string) {
c.mu.Lock()
c.last[host] = c.cfg.Now()
c.mu.Unlock()
}
// isServerError — a 5xx rather than any other non-2xx. The adapter formats the
// status into the message, which is the only place it survives.
func isServerError(err error) bool {
s := err.Error()
return strings.Contains(s, "not allowed") || strings.Contains(s, "private address") ||
strings.Contains(s, "only http and https")
for _, code := range []string{" 50", " 51", " 52", " 53"} {
if strings.Contains(s, code) {
return true
}
}
return false
}
// Hash is the dedup key for a crawl result: the sha256 of the extracted text,
+115
View File
@@ -0,0 +1,115 @@
package crawl
import (
"context"
"errors"
"fmt"
"testing"
"time"
)
// timedFetcher records when each request was made, so a test can assert a wait
// actually happened rather than that a field was parsed.
type timedFetcher struct {
pages map[string]Response
errs map[string]error
at []time.Time
urls []string
}
func (f *timedFetcher) Get(_ context.Context, u string) (*Response, error) {
f.at = append(f.at, time.Now())
f.urls = append(f.urls, u)
if err, ok := f.errs[u]; ok {
return nil, err
}
r, ok := f.pages[u]
if !ok {
return nil, errors.New("http 404: no such page")
}
if r.URL == "" {
r.URL = u
}
if r.ContentType == "" {
r.ContentType = "text/html"
}
return &r, nil
}
func TestPage_HonoursCrawlDelay(t *testing.T) {
// deploy/README says Crawl-delay is honoured. It was parsed into Rules and
// never read: the only pacing was the fetcher's flat one request per host
// per second, which cannot express what a site asked for.
const delay = 120 * time.Millisecond
f := &timedFetcher{pages: map[string]Response{
"https://example.org/robots.txt": {Body: []byte(fmt.Sprintf("User-agent: *\nCrawl-delay: %.3f\n", delay.Seconds())), ContentType: "text/plain"},
"https://example.org/a": {Body: []byte("<html><body>a</body></html>")},
}}
c := New(f, Config{UserAgent: "Maven/1.0"})
if _, err := c.Page(context.Background(), "https://example.org/a"); err != nil {
t.Fatal(err)
}
if len(f.at) != 2 {
t.Fatalf("requests = %v; want robots.txt then the page", f.urls)
}
if gap := f.at[1].Sub(f.at[0]); gap < delay {
t.Errorf("the page was fetched %s after robots.txt; the site asked for %s", gap, delay)
}
}
func TestPage_ACrawlDelayLongerThanTheTurnFailsInsteadOfBlocking(t *testing.T) {
f := &timedFetcher{pages: map[string]Response{
"https://example.org/robots.txt": {Body: []byte("User-agent: *\nCrawl-delay: 30\n"), ContentType: "text/plain"},
"https://example.org/a": {Body: []byte("<html><body>a</body></html>")},
}}
c := New(f, Config{UserAgent: "Maven/1.0"})
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
if _, err := c.Page(ctx, "https://example.org/a"); err == nil {
t.Fatal("a 30-second Crawl-delay was ignored inside a turn that cannot wait that long")
}
if len(f.urls) != 1 {
t.Errorf("requests = %v; the page must not be fetched before the wait it refused", f.urls)
}
}
func TestPage_ABrokenRobotsServerIsNotPermissionToCrawl(t *testing.T) {
// A 404 means unrestricted, per the standard. A 500 does not: the standard
// asks for the opposite, and "the site is broken, so read it" is the wrong
// way to resolve an unknown.
f := &timedFetcher{
pages: map[string]Response{"https://example.org/a": {Body: []byte("<html><body>a</body></html>")}},
errs: map[string]error{"https://example.org/robots.txt": fmt.Errorf("%w: 503", ErrFetchStatus)},
}
c := New(f, Config{UserAgent: "Maven/1.0"})
if _, err := c.Page(context.Background(), "https://example.org/a"); !errors.Is(err, ErrFetchStatus) {
t.Fatalf("Page over a 503 robots.txt = %v; want a refusal", err)
}
if len(f.urls) != 1 {
t.Errorf("requests = %v; the page was read anyway", f.urls)
}
}
func TestPage_AFetcherRefusalIsReportedAsItself(t *testing.T) {
// The old check matched three substrings of webfetch's message from a
// package that cannot import webfetch. The sentinel is mapped by the
// adapter that owns both (cmd/mavend/crawls.go).
f := &timedFetcher{errs: map[string]error{
"https://example.org/robots.txt": fmt.Errorf("%w: host is not allowed", ErrFetchRefused),
}}
c := New(f, Config{UserAgent: "Maven/1.0"})
if _, err := c.Page(context.Background(), "https://example.org/a"); !errors.Is(err, ErrFetchRefused) {
t.Fatalf("Page = %v; want the fetcher's own refusal, not a robots verdict", err)
}
}
func TestParseRobots_MostSpecificAgentWinsRegardlessOfOrder(t *testing.T) {
body := "User-agent: maven\nDisallow: /private\n\nUser-agent: mav\nDisallow: /\n"
r := ParseRobots(body, "maven/1.0")
if !r.Allowed("/public") {
t.Error("the shorter agent group won by file order; the longer prefix is the more specific match")
}
if r.Allowed("/private") {
t.Error("the group that names us was not applied")
}
}
+8 -3
View File
@@ -27,7 +27,8 @@ type Rules struct {
disallow []string
// Delay is Crawl-delay in seconds when the group named one, 0 otherwise.
// The fetcher's own per-host rate limit is the floor; this can only make
// Maven slower, never faster.
// Maven slower, never faster. Enforced in Crawler.waitCrawlDelay — the
// fetcher's limiter is flat and cannot express what a site asked for.
Delay time.Duration
}
@@ -104,7 +105,11 @@ func ParseRobots(body string, agent string) Rules {
}
}
// Most specific wins, and specificity is the LENGTH of the matching agent
// string, not the file order. Two groups naming "mav" and "maven" used to be
// resolved by whichever came last in the file.
var star, exact *group
best := 0
for i := range groups {
for _, a := range groups[i].agents {
if a == "*" && star == nil {
@@ -112,8 +117,8 @@ func ParseRobots(body string, agent string) Rules {
}
// A robots.txt names "maven", we send "Maven/1.0 (…)": match on
// prefix, which is how every crawler reads this field.
if a != "*" && a != "" && strings.HasPrefix(agent, a) {
exact = &groups[i]
if a != "*" && a != "" && strings.HasPrefix(agent, a) && len(a) > best {
best, exact = len(a), &groups[i]
}
}
}
+24
View File
@@ -413,11 +413,17 @@ type SwapModelReq struct {
// RolledBack is true when the requested model failed to load or would not answer
// and the previous one was put back. In that case the call also returns an error
// — the swap did not happen — and Model names the model still serving.
//
// NoBackend is the other failure and it is not a milder one: the rollback failed
// too, no model is loaded, and every phrasing path is on its template fallback
// with routing on the classifier. It is a separate field from RolledBack because
// the two need opposite words on the page.
type SwapModelResp struct {
Model string `json:"model"`
ModelPath string `json:"model_path"`
BaseURL string `json:"base_url"`
RolledBack bool `json:"rolled_back,omitempty"`
NoBackend bool `json:"no_backend,omitempty"`
TookMs int64 `json:"took_ms"`
}
@@ -433,6 +439,15 @@ type ModelStatusResp struct {
Swappable []string `json:"swappable,omitempty"`
}
// PingResp — the answer to MethodPing. Alive is always true (the reply itself
// is the proof); Locked says whether the daemon is still waiting for a passkey
// assertion, which is the one state where a CoreAPI read cannot tell an
// operator anything.
type PingResp struct {
Alive bool `json:"alive"`
Locked bool `json:"locked"`
}
type listTasksReq struct {
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
}
@@ -489,6 +504,10 @@ type kindNReq struct {
Kind string `json:"kind"`
N int `json:"n"`
}
type sourceNReq struct {
Prefix string `json:"prefix"`
N int `json:"n"`
}
type calendarEventsReq struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
@@ -646,6 +665,11 @@ type CoreAPI interface {
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
RecentNotes(ctx context.Context, n int) ([]Note, error)
// RecentNotesFromSource — the newest n notes whose source starts with
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
// from recall, so this is the only way to reach them, and it keeps the feed
// answer from being crowded out of a fixed window by his own notes.
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
// returns whether a new proposal was written. EnableTool fills cmd +
+43 -22
View File
@@ -55,28 +55,30 @@ var ErrAmbiguousOutcome = errors.New("ipc: mutation outcome unknown (connection
// conservative (refusing to retry) is the safe default for a method added
// here by omission.
var readOnlyMethods = map[Method]bool{
MethodLatestFact: true,
MethodLatestFactBySource: true,
MethodSince: true,
MethodPresence: true,
MethodListReminders: true,
MethodRecentOutcomes: true,
MethodRecentFacts: true,
MethodRecentActiveFacts: true,
MethodCalendarEvents: true,
MethodRecentNudges: true,
MethodRecentEcoTraces: true,
MethodQueryNotes: true,
MethodRecentNotes: true,
MethodLookupTool: true,
MethodListTools: true,
MethodListProposedRoutines: true,
MethodListTasks: true,
MethodTickTrace: true,
MethodMorningStatus: true,
MethodMCPServers: true,
MethodDayPlan: true,
MethodRecentEvents: true,
MethodLatestFact: true,
MethodLatestFactBySource: true,
MethodSince: true,
MethodPresence: true,
MethodListReminders: true,
MethodRecentOutcomes: true,
MethodRecentFacts: true,
MethodRecentActiveFacts: true,
MethodCalendarEvents: true,
MethodRecentNudges: true,
MethodRecentEcoTraces: true,
MethodQueryNotes: true,
MethodRecentNotes: true,
MethodLookupTool: true,
MethodListTools: true,
MethodListProposedRoutines: true,
MethodListTasks: true,
MethodTickTrace: true,
MethodMorningStatus: true,
MethodMCPServers: true,
MethodDayPlan: true,
MethodRecentEvents: true,
MethodRecentNotesFromSource: true,
MethodPing: true,
}
// Dial connects to a core socket at path and returns a Client. The module
@@ -392,6 +394,14 @@ func (c *Client) RecentNotes(ctx context.Context, n int) ([]Note, error) {
return out, nil
}
func (c *Client) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
var out []Note
if err := c.call(ctx, MethodRecentNotesFromSource, sourceNReq{Prefix: prefix, N: n}, &out); err != nil {
return nil, err
}
return out, nil
}
func (c *Client) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
var r proposeToolResp
if err := c.call(ctx, MethodProposeTool, proposeToolReq{Name: name, Scope: scope, Utterance: utterance, Ts: ts}, &r); err != nil {
@@ -659,5 +669,16 @@ func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
return result.NewID, nil
}
// Ping asks whether the daemon is there, and whether it is locked. It is not a
// CoreAPI method: it touches no store, so it answers before the passkey
// assertion that every other read waits for.
func (c *Client) Ping(ctx context.Context) (PingResp, error) {
var r PingResp
if err := c.call(ctx, MethodPing, nil, &r); err != nil {
return PingResp{}, err
}
return r, nil
}
// Compile-time check: *Client satisfies CoreAPI.
var _ CoreAPI = (*Client)(nil)
+38
View File
@@ -189,6 +189,18 @@ func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) (
return out, nil
}
func (a *storeAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
ns, err := a.s.RecentNotesFromSource(ctx, prefix, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Note, len(ns))
for i, note := range ns {
out[i] = toNote(note)
}
return out, nil
}
func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
ns, err := a.s.RecentNotes(ctx, n)
if err != nil {
@@ -517,6 +529,11 @@ type Server struct {
ListSpeakersFn ListSpeakersFunc
ForgetSpeakerFn ForgetSpeakerFunc
// LockedFn — reports whether the daemon is in locked (pre-unlock) mode.
// Read by MethodPing only. Nil ⇒ not locked, which is what an embedded or
// test Server without the unlock dance is.
LockedFn func() bool
// UnlockFn — unwraps the store encryption key from the wrapped blob using
// the passkey PRF secret, opens the encrypted store, and wires
// the rest of the daemon (voice, loop, delivery). Set by the daemon when
@@ -853,6 +870,16 @@ var methodTable = map[Method]handlerFunc{
}
return out, nil
}),
MethodRecentNotesFromSource: withParams(func(ctx context.Context, api CoreAPI, p sourceNReq) ([]Note, error) {
out, err := api.RecentNotesFromSource(ctx, p.Prefix, p.N)
if err != nil {
return nil, err
}
if out == nil {
out = []Note{}
}
return out, nil
}),
MethodRecentNotes: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Note, error) {
out, err := api.RecentNotes(ctx, p.N)
if err != nil {
@@ -988,6 +1015,17 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
// directly by the daemon (StepUp / WrapKeyFn / UnlockFn), not store
// state, so they can never be table entries keyed on a CoreAPI method.
switch req.Method {
case MethodPing:
// Deliberately reaches nothing: no store, no CoreAPI, no daemon
// component. That is what makes it answerable in locked mode, and it is
// the whole point — an update that restarts her into locked mode has to
// be able to tell that apart from a daemon that did not come up.
locked := false
if s.LockedFn != nil {
locked = s.LockedFn()
}
return marshalResult(PingResp{Alive: true, Locked: locked}), nil
case MethodAssertStepUp:
if s.StepUp != nil {
return marshalResult(nil), s.StepUp(ctx)
+4
View File
@@ -80,6 +80,10 @@ func (UnimplementedCoreAPI) WriteNote(ctx context.Context, ts time.Time, text st
func (UnimplementedCoreAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
return nil, ErrUnknownMethod
}
func (UnimplementedCoreAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
return nil, ErrNotImplemented
}
+36
View File
@@ -139,3 +139,39 @@ func TestLockedCheckDefaultDenies(t *testing.T) {
t.Error("UnlockFn never ran")
}
}
// A locked daemon has to be able to say it is alive. Every CoreAPI method is
// refused before unlock, so a health check built on one of those cannot tell a
// daemon waiting for a passkey apart from a daemon that failed to start. That
// is what turned a good update into the manual-recovery case in
// internal/update. MethodPing reaches no store, so it answers either way.
func TestPingAnswersWhileLocked(t *testing.T) {
_, srv, cli, _ := newServerWithStore(t)
srv.LockedFn = func() bool { return true }
locked := errors.New("daemon locked")
srv.Check = func(_ context.Context, m Method, _ json.RawMessage) error {
switch m {
case MethodAssertStepUp, MethodUnlock, MethodPing:
return nil
default:
return locked
}
}
ctx := context.Background()
p, err := cli.Ping(ctx)
if err != nil {
t.Fatalf("Ping while locked: %v", err)
}
if !p.Alive || !p.Locked {
t.Errorf("Ping = %+v; want alive and locked", p)
}
// And the read it replaces is still refused, which is the whole point.
if _, err := cli.Presence(ctx); err == nil {
t.Error("Presence answered while locked")
}
srv.LockedFn = func() bool { return false }
if p, err := cli.Ping(ctx); err != nil || p.Locked {
t.Errorf("Ping after unlock = %+v, %v; want alive and not locked", p, err)
}
}
+9
View File
@@ -32,6 +32,7 @@ const (
MethodWriteNote Method = "write_note"
MethodQueryNotes Method = "query_notes"
MethodRecentNotes Method = "recent_notes"
MethodRecentNotesFromSource Method = "recent_notes_source"
MethodProposeTool Method = "propose_tool"
MethodEnableTool Method = "enable_tool"
MethodDisableTool Method = "disable_tool"
@@ -65,6 +66,14 @@ const (
MethodListSpeakers Method = "list_speakers"
MethodForgetSpeaker Method = "forget_speaker"
MethodRecentEvents Method = "recent_events"
// MethodPing — liveness, and the only method that answers in locked mode
// without a passkey assertion. It reaches no store, takes no arguments and
// returns whether the daemon is locked, so an operator tool can tell "she is
// up and waiting for a passkey" apart from "she is not there at all".
// Everything else about her state needs the store, and the store needs the
// key.
MethodPing Method = "ping"
)
// Request — one frame from module to core. Params is the JSON-encoded argument
+59 -5
View File
@@ -14,14 +14,41 @@ import (
"time"
)
// SwapGate — admission control for a completion. Enter blocks or refuses while the
// resident model is being swapped, and the returned release says the request is
// done. The phraser implements it: a swap kills the running llama-server, so
// every holder of a base URL has to be counted before the kill, not just the
// phrasing paths.
//
// Without this the drain saw only the phraser's own calls. The LLM router, the
// replier, the mail extractor and the memory evaluator all reach llama-server
// through this client, so a swap could report zero requests in flight and kill
// the server out from under a routing decision. The turn then finished on the
// new model, which is the "half of one model and half of another" the swap is
// supposed to make impossible.
//
// Distinct from Gate, which is about priority between a voice turn and a
// background job. This one is about the model underneath them changing. A
// request passes the priority gate first and this one second, so nothing waits
// for a quiet window while counted as in flight against the drain.
type SwapGate interface {
Enter() (release func(), err error)
}
type Client struct {
// mu guards base only. The base URL changes when the daemon swaps the
// resident model (Vikunja #250): llama-server is relaunched on a fresh
// port, and every holder of this client — the LLM router, the replier, the
// mail extractor — must follow without being rebuilt. One mutexed field is
// the whole mechanism; a swap re-points the client, it does not replace it.
// mu guards base and gate. The base URL can change when the daemon swaps the
// resident model (Vikunja #250) and every holder of this client — the LLM
// router, the replier, the mail extractor — must follow without being
// rebuilt. A swap re-points the client, it does not replace it.
//
// On the deploy shape the new server binds the same fixed port the killed
// one released (startLlamaProc passes the port out of phraser.listen), so
// SetBaseURL is normally a no-op and the gate is the part doing the work.
// The re-pointing stays because nothing guarantees the port: a phraser
// listening on :0, or a future swap that moves the server, changes the base.
mu sync.RWMutex
base string
swap SwapGate
http *http.Client
// gate / background — priority on the single llama-server slot. Set once
@@ -51,6 +78,24 @@ func (c *Client) gateFor() (*Gate, bool) {
return c.gate, c.background
}
// SetSwapGate installs the swap admission gate. Nil (the default, and what the
// eval harness and the tests use) means no gating.
func (c *Client) SetSwapGate(g SwapGate) {
c.mu.Lock()
c.swap = g
c.mu.Unlock()
}
func (c *Client) enter() (func(), error) {
c.mu.RLock()
g := c.swap
c.mu.RUnlock()
if g == nil {
return func() {}, nil
}
return g.Enter()
}
func New(baseURL string, timeout time.Duration) *Client {
return &Client{base: baseURL, http: &http.Client{Timeout: timeout}}
}
@@ -107,6 +152,8 @@ type resp struct {
}
func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
// Priority first: a background request can sit here for a while, and it
// must not be counted against the swap drain while it waits.
if g, background := c.gateFor(); g != nil {
if background {
release, err := g.AcquireBackground(ctx)
@@ -118,6 +165,13 @@ func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
defer g.Foreground()()
}
}
// Then the swap drain, which counts what is actually about to hit the
// server it is going to kill.
release, err := c.enter()
if err != nil {
return "", err
}
defer release()
b, _ := json.Marshal(body{
Messages: []msg{{Role: "system", Content: r.System}, {Role: "user", Content: r.User}},
MaxTokens: r.MaxTokens,
+29 -2
View File
@@ -28,6 +28,10 @@ import (
// fallback (templates, "вот что я нашла", the classifier for routing), so a
// fast refusal degrades one turn instead of hanging it for the length of a
// model load. No turn ever gets half of one model and half of another.
// "Every path" means every path: the router, the replier, the mail
// extractor and the memory evaluator do not call acquire, they call
// llm.Client.Complete, so LLMPhraser implements llm.Gate and the client
// enters through the same counter.
//
// 3. A failed load rolls back to the model that was working. The new server is
// probed (it must say which model it loaded) before it is published; if the
@@ -68,11 +72,16 @@ type SwapSpec struct {
// SwapResult — what happened. Model is the identity the NEW server reported, so
// it is evidence rather than an echo of the request: if the file at ModelPath is
// not what the operator thought it was, this is where that shows up.
// RolledBack is true only when a model is serving again. NoBackend is the other
// failure, and it is the worse one: the rollback failed too and nothing is
// loaded. They are separate flags because the operator surface reads them, and
// "rolled back" spelled over a dead daemon reads as reassurance.
type SwapResult struct {
Model string
BaseURL string
ModelPath string
RolledBack bool
NoBackend bool
Took time.Duration
}
@@ -140,6 +149,19 @@ func (p *LLMPhraser) acquire() (string, func(), error) {
}, nil
}
// Enter implements llm.Gate so every holder of an *llm.Client is drained by a
// swap, not only the phrasing paths in this package.
//
// The router, the replier, the mail extractor and the memory evaluator do not
// call acquire; they call llm.Client.Complete. Before this existed quiesce could
// see zero requests in flight while the router was mid-generation and kill the
// server under it. A refusal here is the same ErrSwapping the phrasing paths
// get, and every caller of Complete already falls back.
func (p *LLMPhraser) Enter() (func(), error) {
_, release, err := p.acquire()
return release, err
}
// Swap loads another model in place of the live one. See the file comment for
// the properties it guarantees. Returns the new model's reported identity, or
// an error plus RolledBack=true when the old model was put back.
@@ -195,8 +217,13 @@ func (p *LLMPhraser) Swap(ctx context.Context, spec SwapSpec) (SwapResult, error
log.Printf("phraser: swap to %s FAILED (%v) — rolling back to %s", newLive.ModelPath, err, oldLive.ModelPath)
rb, rbErr := p.loadAndProbe(ctx, oldLive)
if rbErr != nil {
log.Printf("phraser: ROLLBACK to %s ALSO FAILED (%v) — no model is loaded, every phrasing path is on its fallback and routing is on the classifier until the daemon is restarted", oldLive.ModelPath, rbErr)
return SwapResult{RolledBack: true, Took: time.Since(started)},
// Nothing is loaded, so LiveModel must stop naming a gguf: the page
// would show a file next to an unknown model and read as half-working.
p.mu.Lock()
p.live = liveModel{}
p.mu.Unlock()
log.Printf("phraser: ROLLBACK to %s ALSO FAILED (%v) — no model is loaded, every phrasing path is on its fallback and routing is on the classifier. Swap is still wired, so another attempt can recover without restarting the daemon", oldLive.ModelPath, rbErr)
return SwapResult{NoBackend: true, Took: time.Since(started)},
fmt.Errorf("phraser: swap failed (%w) and rollback failed too: %v", err, rbErr)
}
p.publish(rb, oldLive)
+127
View File
@@ -0,0 +1,127 @@
package phraser
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/kami/maven/internal/llm"
)
// The drain has to cover every holder of the base URL, not only the phrasing
// paths in this package. The LLM router, the replier, the mail extractor and the
// memory evaluator all reach llama-server through llm.Client, and a swap that
// does not count them kills the server mid-turn.
// blockingLLM — a completion endpoint that does not answer until the test says
// so. It stands in for a router call that is generating when the swap arrives.
func blockingLLM(t *testing.T, release <-chan struct{}) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-release
w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
}))
t.Cleanup(srv.Close)
return srv
}
func (p *LLMPhraser) inflightCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.inflight
}
func TestSwap_WaitsForARouterCallThatWentThroughLLMClient(t *testing.T) {
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old", "/m/new.gguf": "new"}}
p := newSwapPhraser(t, fl, "/m/old.gguf")
release := make(chan struct{})
c := llm.New(blockingLLM(t, release).URL, 5*time.Second)
c.SetGate(p)
completed := make(chan error, 1)
go func() {
_, err := c.Complete(context.Background(), llm.Req{System: "s", User: "u"})
completed <- err
}()
deadline := time.Now().Add(2 * time.Second)
for p.inflightCount() == 0 {
if time.Now().After(deadline) {
t.Fatal("the llm.Client request never registered with the phraser gate")
}
time.Sleep(5 * time.Millisecond)
}
swapped := make(chan error, 1)
go func() { _, e := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/new.gguf"}); swapped <- e }()
select {
case e := <-swapped:
t.Fatalf("the swap finished while a router call was still generating (%v); the old server was killed under it", e)
case <-time.After(200 * time.Millisecond):
}
close(release)
if e := <-completed; e != nil {
t.Fatalf("the in-flight call did not finish on the old model: %v", e)
}
if e := <-swapped; e != nil {
t.Fatalf("Swap after the drain: %v", e)
}
}
func TestSwap_RefusesARouterCallThatArrivesMidSwap(t *testing.T) {
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old", "/m/new.gguf": "new"}}
p := newSwapPhraser(t, fl, "/m/old.gguf")
// An open server: the refusal has to come from the gate, not from a stall.
open := make(chan struct{})
close(open)
c := llm.New(blockingLLM(t, open).URL, 5*time.Second)
c.SetGate(p)
// Hold the door shut the way quiesce does.
_, held, err := p.acquire()
if err != nil {
t.Fatal(err)
}
defer held()
go p.Swap(context.Background(), SwapSpec{ModelPath: "/m/new.gguf"})
deadline := time.Now().Add(2 * time.Second)
for {
_, err := c.Complete(context.Background(), llm.Req{User: "u"})
if errors.Is(err, ErrSwapping) {
return
}
if time.Now().After(deadline) {
t.Fatalf("a router call during a swap was not refused (last error: %v)", err)
}
time.Sleep(10 * time.Millisecond)
}
}
func TestSwap_TotalFailureIsNotReportedAsARollback(t *testing.T) {
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old"}}
p := newSwapPhraser(t, fl, "/m/old.gguf")
fl.mu.Lock()
delete(fl.models, "/m/old.gguf")
fl.mu.Unlock()
res, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/broken.gguf"})
if err == nil {
t.Fatal("Swap returned nil when both the load and the rollback failed")
}
if res.RolledBack {
t.Error("a total failure set RolledBack; the page then says she is still answering with the old model, and she is not answering at all")
}
if !res.NoBackend {
t.Error("a total failure did not set NoBackend, so nothing distinguishes it from a rolled-back swap")
}
if path, _, _ := p.LiveModel(); path != "" {
t.Errorf("LiveModel = %q after a total failure; nothing is loaded, and naming a gguf makes the page read as half-working", path)
}
}
+47 -14
View File
@@ -16,14 +16,25 @@ type FeedQuery struct {
Category string
}
// feedNouns — the words that make a question about the feeds themselves.
// feedNouns — the words that name the feeds themselves. One of these is enough,
// with an ask, to make the turn a feed question.
var feedNouns = []string{
"лента", "ленте", "ленты", "лентах", "лентам",
"новости", "новостей", "новостях", "новостям",
"новое", "нового", "новенького",
"feed", "feeds", "news", "headlines",
}
// vagueNouns — the newness words that are NOT about the feeds by themselves.
//
// "что нового?" is the most common opener in the language and it is a greeting,
// not a request for headlines. It used to match here, so the shipping daemon —
// which has no feeds block — answered "я пока не читаю ленты, они не настроены",
// a configuration status in reply to hello. With feeds on it answered "в лентах
// пока ничего нового", which is no better. A vague noun claims the turn only
// when the utterance narrows it: a named topic ("что нового по технологиям"), or
// a feed noun somewhere in it ("что нового в лентах").
var vagueNouns = []string{"новое", "нового", "новенького", "new"}
// newnessMarkers — the "что нового" half. "нового" alone is in feedNouns
// because it carries the question on its own ("что нового?"); a bare "лента"
// needs the ask, which is what askMarkers below is for.
@@ -33,13 +44,15 @@ var askMarkers = []string{
}
// ParseFeedQuery reports whether an utterance asks what is new in the feeds, and
// which topic if it names one after "по"/"о"/"про"/"about".
// which topic if it names one after "по"/"об"/"про"/"about".
//
// Both a feed noun and an ask are required. "у меня новая лента в инстаграме" is
// a statement and must not be read as a request to recite headlines.
// A feed noun and an ask are required. "у меня новая лента в инстаграме" is a
// statement and must not be read as a request to recite headlines. A vague
// newness word counts as the noun only when a topic is named — see vagueNouns
// for why the bare "что нового?" must fall through.
func ParseFeedQuery(text string) (FeedQuery, bool) {
toks := planTokens(text)
noun, ask := false, false
noun, vague, ask := false, false, false
for _, t := range toks {
for _, n := range feedNouns {
if t == n {
@@ -47,6 +60,12 @@ func ParseFeedQuery(text string) (FeedQuery, bool) {
break
}
}
for _, n := range vagueNouns {
if t == n {
vague = true
break
}
}
for _, a := range askMarkers {
if t == a {
ask = true
@@ -54,24 +73,34 @@ func ParseFeedQuery(text string) (FeedQuery, bool) {
}
}
}
if !noun || !ask {
if !ask {
return FeedQuery{}, false
}
return FeedQuery{Category: feedCategory(toks)}, true
cat := feedCategory(toks)
if !noun && !(vague && cat != "") {
return FeedQuery{}, false
}
return FeedQuery{Category: cat}, true
}
// categoryPreps — the prepositions a topic follows. Russian marks the topic with
// a preposition ("по технологиям", "про политику"), so the word after one is the
// category; there is no stemming here, and the match against the configured
// category is a prefix comparison for exactly that reason.
var categoryPreps = map[string]bool{"по": true, "о": true, "об": true, "про": true, "about": true, "on": true}
//
// "о" is not in the list. It is one rune and it turns up as filler, a typo and
// half of "о'кей", so any utterance carrying a stray "о" produced a category of
// whatever word came next and she answered "по этой теме в лентах пока ничего"
// to a question that named no theme. "об" and "про" carry the same meaning and
// cannot be mistaken for anything else.
var categoryPreps = map[string]bool{"по": true, "об": true, "про": true, "about": true, "on": true}
func feedCategory(toks []string) string {
for i, t := range toks {
if categoryPreps[t] && i+1 < len(toks) {
next := toks[i+1]
// "по новостям" names no topic, it repeats the noun.
for _, n := range feedNouns {
for _, n := range append(append([]string{}, feedNouns...), vagueNouns...) {
if next == n {
return ""
}
@@ -82,12 +111,16 @@ func feedCategory(toks []string) string {
return ""
}
// CategoryMatches reports whether a note's text plausibly belongs to the
// category he named. Russian inflects the topic ("технологиям" vs the configured
// CategoryMatches reports whether a feed note's own category tag is the one he
// named. Russian inflects the topic ("технологиям" vs the configured
// "технологии"), and there is no stemmer in this repo, so the comparison is on a
// common prefix — long enough that "полит" and "погод" stay apart, short enough
// to survive a case ending.
func CategoryMatches(text, category string) bool {
//
// tag is the note's stored category (rss.NoteCategory), NOT the whole note. It
// used to be the whole note, which meant "что нового про погоду" matched any
// tech headline whose link happened to contain "pogod".
func CategoryMatches(tag, category string) bool {
if category == "" {
return true
}
@@ -95,7 +128,7 @@ func CategoryMatches(text, category string) bool {
if stem == "" {
return false
}
return strings.Contains(strings.ToLower(text), stem)
return strings.Contains(strings.ToLower(tag), stem)
}
// categoryStem cuts a word down to the part inflection leaves alone. 5 runes is
+13 -3
View File
@@ -9,13 +9,19 @@ func TestParseFeedQuery(t *testing.T) {
category string
}{
{"что нового в лентах?", true, ""},
{"что нового?", true, ""},
{"что нового по технологиям", true, "технологиям"},
{"какие новости?", true, ""},
{"что нового по технологиям?", true, "технологиям"},
{"расскажи новости про политику", true, "политику"},
{"что нового по новостям", true, ""},
{"what's new in the feeds?", true, ""},
{"any news about kubernetes", true, "kubernetes"},
// "что нового?" is a greeting. Claiming it made the shipping daemon
// answer hello with "я пока не читаю ленты — они не настроены".
{"что нового?", false, ""},
{"ну что нового", false, ""},
// A stray "о" is not a topic marker.
{"что нового в лентах, о боже", true, ""},
// Statements, not requests.
{"у меня новая лента в инстаграме", false, ""},
{"новости меня утомили", false, ""},
@@ -36,12 +42,16 @@ func TestParseFeedQuery(t *testing.T) {
func TestCategoryMatches(t *testing.T) {
// The inflected form he says must match the form the config spells.
if !CategoryMatches("Новый релиз [технологии]", "технологиям") {
if !CategoryMatches("технологии", "технологиям") {
t.Error("inflected category did not match")
}
if CategoryMatches("Новый релиз [технологии]", "политику") {
if CategoryMatches("технологии", "политику") {
t.Error("unrelated category matched")
}
// The tag, not the note. The link in a tech headline is not a weather report.
if CategoryMatches("технологии", "погоду") {
t.Error("a tech note matched a weather question")
}
if !CategoryMatches("anything", "") {
t.Error("an empty category must match everything")
}
+7 -3
View File
@@ -72,9 +72,13 @@ type feedDoc struct {
func Parse(r io.Reader) (Feed, error) {
var doc feedDoc
dec := xml.NewDecoder(r)
// Feeds in the wild declare windows-1251 and worse. We only ever read
// UTF-8; a charset we cannot decode is a feed we do not read, which is
// better than mojibake in his notes.
// Strict=false buys tolerance of the malformed markup feeds are full of:
// unclosed tags, stray entities. It has nothing to do with charsets.
//
// Charsets are handled by not handling them: CharsetReader stays nil, so a
// feed declaring windows-1251 fails to parse rather than being read as
// UTF-8. That is the behaviour we want — a charset we cannot decode is a
// feed we do not read, which beats mojibake in his notes.
dec.Strict = false
if err := dec.Decode(&doc); err != nil {
return Feed{}, fmt.Errorf("rss: bad xml: %w", err)
+114
View File
@@ -0,0 +1,114 @@
package rss
import (
"context"
"fmt"
"strings"
"testing"
"time"
)
// undatedFeed — a feed whose items carry no pubDate. Plenty of real ones do not.
func undatedFeed(titles ...string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0"?><rss version="2.0"><channel><title>u</title>`)
for _, t := range titles {
fmt.Fprintf(&b, `<item><title>%s</title><link>https://example.org/%s</link><guid>%s</guid></item>`, t, t, t)
}
b.WriteString(`</channel></rss>`)
return b.String()
}
// datedFeed — newest first, one hour apart, the standard shape.
func datedFeed(base time.Time, n int) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0"?><rss version="2.0"><channel><title>d</title>`)
for i := 0; i < n; i++ {
ts := base.Add(-time.Duration(i) * time.Hour)
fmt.Fprintf(&b, `<item><title>item-%d</title><link>https://example.org/%d</link><guid>g%d</guid><pubDate>%s</pubDate></item>`,
i, i, i, ts.Format(time.RFC1123Z))
}
b.WriteString(`</channel></rss>`)
return b.String()
}
func TestPoll_UndatedFeedIsNotReNotedAfterARestart(t *testing.T) {
// The seen-IDs map dies with the process, so before the durable mark was
// consulted every boot re-noted the whole front page, stamped now, on top of
// the recent-notes window. A crash loop made that a flood.
feed := FeedConfig{Name: "u", URL: "https://example.org/rss"}
fetch := &fakeFetch{body: undatedFeed("a", "b", "c")}
marks := newMarks()
notes1 := &fakeNotes{}
p1 := NewPoller([]FeedConfig{feed}, fetch, notes1, marks, nil, nil, Config{})
p1.PollDue(context.Background(), now)
if len(notes1.notes) != 3 {
t.Fatalf("first boot wrote %d notes, want 3", len(notes1.notes))
}
if marks.m["u"].IsZero() {
t.Fatal("an undated feed left no mark, so the next process cannot tell it has been read")
}
// Restart. Same process-lifetime dedup map, gone.
notes2 := &fakeNotes{}
p2 := NewPoller([]FeedConfig{feed}, fetch, notes2, marks, nil, nil, Config{})
p2.PollDue(context.Background(), now.Add(time.Hour))
if len(notes2.notes) != 0 {
t.Fatalf("a restart re-noted %d undated items: %v", len(notes2.notes), notes2.notes)
}
// And the same process still notices something genuinely new.
fetch.body = undatedFeed("a", "b", "c", "d")
p2.PollDue(context.Background(), now.Add(2*time.Hour))
if len(notes2.notes) != 1 {
t.Fatalf("a new undated item after the resync wrote %d notes, want 1", len(notes2.notes))
}
}
func TestPoll_BurstLargerThanMaxItemsIsPacedNotDropped(t *testing.T) {
// max_items reads as pacing in the config doc. Marking the newest item
// written put everything below the cap behind the mark, permanently.
feed := FeedConfig{Name: "d", URL: "https://example.org/rss"}
fetch := &fakeFetch{body: datedFeed(now.Add(-time.Minute), 12)}
notes := &fakeNotes{}
marks := newMarks()
p := NewPoller([]FeedConfig{feed}, fetch, notes, marks, nil, nil, Config{MaxItems: 5, MaxAge: 48 * time.Hour})
at := now
for i := 0; i < 3; i++ {
if _, err := p.PollFeed(context.Background(), feed, at); err != nil {
t.Fatal(err)
}
at = at.Add(time.Hour)
}
seen := map[string]bool{}
for _, n := range notes.notes {
title := strings.SplitN(n.text, "\n", 2)[0]
if seen[title] {
t.Errorf("item %q was noted twice", title)
}
seen[title] = true
}
if len(seen) != 12 {
t.Errorf("after three polls of a 12-item burst she has %d of them; the rest were dropped for good", len(seen))
}
}
func TestNoteHeadlineAndCategory(t *testing.T) {
text := NoteText(FeedConfig{Category: "технологии"}, Item{
Title: "Новая уязвимость", Summary: "Патч вышел", Link: "https://example.org/a",
})
if got := NoteHeadline(text); got != "Новая уязвимость" {
t.Errorf("NoteHeadline = %q; she reads the brackets out loud", got)
}
if got := NoteCategory(text); got != "технологии" {
t.Errorf("NoteCategory = %q, want технологии", got)
}
// A feed's own leading tag stays part of the title.
if got := NoteHeadline("[перевод] Что-то"); got != "[перевод] Что-то" {
t.Errorf("NoteHeadline stripped the feed's own tag: %q", got)
}
if got := NoteCategory("Без категории"); got != "" {
t.Errorf("NoteCategory on an untagged note = %q, want empty", got)
}
}
+135 -22
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"sort"
"strings"
"time"
)
@@ -88,6 +89,7 @@ type Poller struct {
cfg Config
nextDue map[string]time.Time
seen map[string]map[string]bool // feed → item ID, for items with no date
polled map[string]bool // feed → polled at least once in THIS process
}
// NewPoller wires a poller. Returns nil when there is nothing to poll — a
@@ -96,7 +98,9 @@ func NewPoller(feeds []FeedConfig, fetch Fetcher, notes Notes, marks Marks, embe
var valid []FeedConfig
for _, f := range feeds {
if strings.TrimSpace(f.Name) == "" || strings.TrimSpace(f.URL) == "" {
log.Printf("rss: skipping a feed with no name or no url")
// Name the offender. A silent skip in a list of six feeds is a
// config typo nobody finds.
log.Printf("rss: skipping feed %q (%q): a feed needs both a name and a url", f.Name, f.URL)
continue
}
valid = append(valid, f)
@@ -118,6 +122,7 @@ func NewPoller(feeds []FeedConfig, fetch Fetcher, notes Notes, marks Marks, embe
embed: embed, ranker: ranker, cfg: cfg,
nextDue: map[string]time.Time{},
seen: map[string]map[string]bool{},
polled: map[string]bool{},
}
}
@@ -164,14 +169,31 @@ func (p *Poller) PollFeed(ctx context.Context, f FeedConfig, now time.Time) (int
return 0, err
}
mark := p.mark(ctx, f.Name, now)
newest := mark
written := 0
mark, durable := p.mark(ctx, f.Name, now)
// resync — the first poll of this feed since the process started, on a feed
// we have read before. Undated items are deduped by an in-memory ID set that
// dies with the process, so on this poll they are all "unseen" again and
// would all be re-noted. See fresh.
resync := durable && !p.polled[f.Name]
p.polled[f.Name] = true
// Gather first, cap second, and write OLDEST first.
//
// The old loop walked the feed newest-first and stopped at MaxItems, then
// marked the newest item it had written. Feeds are newest-first, so with
// twenty new items and a cap of five it wrote the five newest and moved the
// mark past all twenty: items six through twenty were older than the mark on
// the next poll and were dropped for good. max_items reads as a pacing knob
// in the config doc, and that made it a silent loss. Writing the oldest five
// and marking the newest of THOSE is pacing: the rest arrive over the polls
// that follow, in order, each one exactly once.
var cands []Item
sawUndated := false
for _, it := range feed.Items {
if written >= p.cfg.MaxItems {
break
if it.Published.IsZero() {
sawUndated = true
}
if !p.fresh(f, it, mark, now) {
if !p.fresh(f, it, mark, now, resync) {
continue
}
if !Matches(f, it) {
@@ -185,6 +207,18 @@ func (p *Poller) PollFeed(ctx context.Context, f FeedConfig, now time.Time) (int
continue
}
}
cands = append(cands, it)
}
sort.SliceStable(cands, func(i, j int) bool {
return itemTime(cands[i], now).Before(itemTime(cands[j], now))
})
if len(cands) > p.cfg.MaxItems {
cands = cands[:p.cfg.MaxItems]
}
newest := time.Time{}
written := 0
for _, it := range cands {
if err := p.write(ctx, f, it, now); err != nil {
return written, err
}
@@ -193,33 +227,71 @@ func (p *Poller) PollFeed(ctx context.Context, f FeedConfig, now time.Time) (int
newest = it.Published
}
}
if p.marks != nil && newest.After(mark) {
if err := p.marks.SetMark(ctx, f.Name, newest); err != nil {
log.Printf("rss: feed %s: save mark: %v", f.Name, err)
}
}
p.advance(ctx, f.Name, mark, newest, sawUndated, now)
return written, nil
}
// mark — how far this feed was read. A feed with no mark starts MaxAge ago, so
// a first poll takes today's headlines instead of the whole archive.
func (p *Poller) mark(ctx context.Context, feed string, now time.Time) time.Time {
// itemTime — an item's own date, or now when the feed did not give one. Undated
// items sort last, which is the only defensible guess: they were seen now.
func itemTime(it Item, now time.Time) time.Time {
if it.Published.IsZero() {
return now
}
return it.Published
}
// advance moves the durable mark to the newest item actually WRITTEN. Because
// the cap is applied to the oldest candidates (see PollFeed), that is never
// ahead of an item still waiting to be read.
//
// A feed whose items carry no dates gets the mark set to now instead. Nothing
// else would ever set it, and the mark's existence is what tells the next
// process that this feed has been read before.
func (p *Poller) advance(ctx context.Context, feed string, mark, newest time.Time, sawUndated bool, now time.Time) {
if p.marks == nil {
return
}
at := newest
if at.IsZero() && sawUndated {
at = now
}
if at.IsZero() || !at.After(mark) {
return
}
if err := p.marks.SetMark(ctx, feed, at); err != nil {
log.Printf("rss: feed %s: save mark: %v", feed, err)
}
}
// mark — how far this feed was read, and whether that came from the durable
// store. A feed with no mark starts MaxAge ago, so a first poll takes today's
// headlines instead of the whole archive; durable is false in that case, and it
// is what tells PollFeed the difference between "never read" and "read by an
// earlier process".
func (p *Poller) mark(ctx context.Context, feed string, now time.Time) (time.Time, bool) {
cold := now.Add(-p.cfg.MaxAge)
if p.marks == nil {
return cold
return cold, false
}
at, err := p.marks.LastMark(ctx, feed)
if err != nil || at.IsZero() {
return cold
return cold, false
}
return at
return at, true
}
// fresh — two dedup rules, because feeds are inconsistent about dates. A dated
// item must be newer than the mark; an undated one is kept once per process by
// ID. Both are needed: dates alone re-import undated feeds forever, IDs alone
// lose their memory on restart.
func (p *Poller) fresh(f FeedConfig, it Item, mark, now time.Time) bool {
// ID.
//
// The ID set does not survive a restart, and on its own that re-notes an undated
// feed's whole front page on every boot — five notes, then five more, all stamped
// `now`, sitting at the top of the recent-notes window and crowding out the notes
// he actually made. A crash loop turns it into a flood. So on the first poll after
// a restart of a feed we have read before (resync), undated items are recorded as
// seen and NOT written. The cost is the undated items that appeared while the
// daemon was down. That is a bounded loss, and the alternative is an unbounded one.
func (p *Poller) fresh(f FeedConfig, it Item, mark, now time.Time, resync bool) bool {
if !it.Published.IsZero() {
if !it.Published.After(mark) {
return false
@@ -239,7 +311,7 @@ func (p *Poller) fresh(f FeedConfig, it Item, mark, now time.Time) bool {
return false
}
p.seen[f.Name][id] = true
return true
return !resync
}
// write stores one item as a note. Source "rss:<feed>" is what the answer path
@@ -291,6 +363,47 @@ func NoteText(f FeedConfig, it Item) string {
return b.String()
}
// NoteHeadline is the part of a feed note she reads out: the first line with the
// category tag taken off. The tag is bookkeeping for the answer path, and piper
// says brackets out loud — "Заголовок [технологии]" is what he heard before.
func NoteHeadline(text string) string {
line := text
if i := strings.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
line = strings.TrimSpace(line)
if head, _, ok := splitTag(line); ok {
return head
}
return line
}
// NoteCategory is the category tag a feed note carries, empty when it has none.
// Matching a topic against THIS rather than against the whole note is what keeps
// "что нового про погоду" from matching a tech headline whose link happens to
// contain "pogod".
func NoteCategory(text string) string {
line := text
if i := strings.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
_, tag, _ := splitTag(strings.TrimSpace(line))
return tag
}
// splitTag pulls a trailing "[...]" off a headline. Only a trailing one: a title
// that opens with "[перевод]" is the feed's own word, not ours.
func splitTag(line string) (head, tag string, ok bool) {
if !strings.HasSuffix(line, "]") {
return line, "", false
}
i := strings.LastIndexByte(line, '[')
if i < 0 {
return line, "", false
}
return strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1 : len(line)-1]), true
}
// trimRunes cuts on a rune boundary — a note is Russian as often as English and
// half a cyrillic letter is a broken note.
func trimRunes(s string, max int) string {
+58 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"math"
"sort"
"strings"
"time"
)
@@ -42,7 +43,8 @@ func (s *Store) WriteNote(ctx context.Context, ts time.Time, text string, embedd
// or an ANN index only when note count or latency actually bites — at personal
// scale (hundredsthousands) a full scan is sub-millisecond.
func (s *Store) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, ts, text, embedding, source FROM notes`)
rows, err := s.db.QueryContext(ctx,
`SELECT id, ts, text, embedding, source FROM notes WHERE `+notHisWordsSQL)
if err != nil {
return nil, fmt.Errorf("query notes: %w", err)
}
@@ -76,6 +78,61 @@ func (s *Store) QueryNotes(ctx context.Context, embedding []float32, k int) ([]N
return out, nil
}
// ReadSourcePrefixes — note sources that are text Maven READ somewhere, not
// text he said or wrote: RSS items (internal/rss) and crawled pages
// (internal/crawl).
//
// They are notes because that is where fetched text lands, and they are kept out
// of recall because recall answers questions about HIM. "что я говорил про
// переезд" must not be answered out of a stranger's web page that happened to
// land near in the vector space, and the fallback line for that is "вот что я
// нашла: " followed by the stranger's words. Ask for them by source instead —
// that is what RecentNotesFromSource is for.
var ReadSourcePrefixes = []string{"rss:", "crawl:"}
// notHisWordsSQL — the WHERE clause that drops the read sources. Built from the
// list above so adding a source is one line.
var notHisWordsSQL = buildNotHisWordsSQL()
func buildNotHisWordsSQL() string {
var b strings.Builder
b.WriteString("(source IS NULL OR (")
for i, p := range ReadSourcePrefixes {
if i > 0 {
b.WriteString(" AND ")
}
fmt.Fprintf(&b, "source NOT LIKE '%s%%'", p)
}
b.WriteString("))")
return b.String()
}
// RecentNotesFromSource returns the newest n notes whose source starts with
// prefix, newest first. The answer path for feeds and watches uses it: scanning
// the last 200 notes of ANY source meant a busy day of voice notes pushed the
// newest headline out of the window, and she said "в лентах пока ничего нового"
// while the poller was working fine.
func (s *Store) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, ts, text, source FROM notes WHERE source LIKE ? ORDER BY ts DESC LIMIT ?`,
prefix+"%", n)
if err != nil {
return nil, fmt.Errorf("recent notes by source: %w", err)
}
defer rows.Close()
var out []Note
for rows.Next() {
var nt Note
var tsMilli int64
if err := rows.Scan(&nt.ID, &tsMilli, &nt.Text, &nt.Source); err != nil {
return nil, err
}
nt.Ts = time.UnixMilli(tsMilli).UTC()
out = append(out, nt)
}
return out, rows.Err()
}
// RecentNotes returns the newest n notes, newest first — a browse view (no
// embedding math; Score stays 0). This is the read surface for /dash: notes
// captured by voice are otherwise only reachable through semantic query.
+40
View File
@@ -36,3 +36,43 @@ func TestQueryNotesRanksByCosine(t *testing.T) {
t.Errorf("scores not descending: %.3f then %.3f", got[0].Score, got[1].Score)
}
}
// Recall answers questions about HIM. A feed item and a crawled page are text
// Maven read somewhere, and letting them into the nearest-neighbour pool means
// "что я говорил про переезд" can be answered with a stranger's sentence, under
// the line "вот что я нашла: ".
func TestQueryNotesLeavesOutWhatSheOnlyRead(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Now()
// The read sources sit exactly on the query vector; his own note is further
// away, so ranking alone would put them first.
for _, n := range []struct{ text, source string }{
{"переезд в новую квартиру описан тут", "rss:habr"},
{"страница про переезд", "crawl:changelog"},
} {
if _, err := st.WriteNote(ctx, now, n.text, []float32{1, 0, 0}, n.source); err != nil {
t.Fatal(err)
}
}
if _, err := st.WriteNote(ctx, now, "переезд в субботу", []float32{0.8, 0.6, 0}, "tap:voice"); err != nil {
t.Fatal(err)
}
got, err := st.QueryNotes(ctx, []float32{1, 0, 0}, 5)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Source != "tap:voice" {
t.Fatalf("recall returned %+v; want only what he said himself", got)
}
// They are still reachable, by source.
feed, err := st.RecentNotesFromSource(ctx, "rss:", 10)
if err != nil {
t.Fatal(err)
}
if len(feed) != 1 || feed[0].Source != "rss:habr" {
t.Fatalf("RecentNotesFromSource(rss:) = %+v; want the one feed note", feed)
}
}
+78 -4
View File
@@ -41,9 +41,31 @@ func (u *Updater) Apply(ctx context.Context) (Result, error) {
// has no baseline to prove itself against.
u.log("preflight: checking the running daemon")
if err := u.health(ctx, u.cfg.HealthSocket); err != nil {
if errors.Is(err, ErrHealthDial) {
// Not the daemon's fault and not fixed by fixing the daemon. The
// usual cause is the socket path: under a docker volume it sits in
// /var/lib/docker, which the operator's account cannot traverse.
return res, err
}
return res, fmt.Errorf("%w: %v", ErrUnhealthyBefore, err)
}
// 0b. If the source is part of what a rollback has to put back, it must be
// in a state that can be described and restored. A dirty tree is neither:
// the commit recorded in the snapshot does not say what is deployed, and a
// forced checkout on the way back would delete his uncommitted work.
commit := u.gitHead(ctx)
if u.cfg.SourceRollback == "git" {
if commit == "" {
return res, fmt.Errorf("%w: source_rollback is \"git\" but %s has no readable git HEAD", ErrSourceRollback, u.cfg.SourceDir)
}
if dirty, err := u.gitDirty(ctx); err != nil {
return res, fmt.Errorf("%w: %v", ErrDirtyTree, err)
} else if dirty {
return res, fmt.Errorf("%w: %s", ErrDirtyTree, u.cfg.SourceDir)
}
}
// 1. Snapshot what is deployed now, BEFORE the build.
//
// The order matters and it is not the obvious one. `make build` writes its
@@ -53,7 +75,7 @@ func (u *Updater) Apply(ctx context.Context) (Result, error) {
// only thing standing between a bad build and a box that needs a screwdriver,
// so it is taken first, while the deployed bytes are still the old ones.
names := append(append([]string{}, u.cfg.Binaries...), u.cfg.ConfigFiles...)
snap, err := u.store.Save(u.cfg.InstallDir, names, u.gitHead(ctx), "pre-update")
snap, err := u.store.Save(u.cfg.InstallDir, names, commit, "pre-update")
if err != nil {
return res, err
}
@@ -68,10 +90,13 @@ func (u *Updater) Apply(ctx context.Context) (Result, error) {
steps, err := u.Verify(ctx)
res.Steps = append(res.Steps, steps...)
if err != nil {
if rerr := snap.Restore(u.cfg.InstallDir); rerr != nil {
// RolledBack stays false here on purpose. Nothing was installed and
// nothing was restarted, so there is no rollback to report; a compile
// error printing rolled_back=true sends the operator looking for a
// restart that never happened. The log line carries what was done.
if rerr := u.restoreBinaries(snap); rerr != nil {
u.log("verify failed and the artifacts could not be put back: %v — the previous ones are in %s", rerr, snap.Dir())
} else {
res.RolledBack = true
u.log("verify failed; the previously deployed artifacts are back in place, she was never restarted")
}
return res, err
@@ -141,10 +166,17 @@ func (u *Updater) rollback(ctx context.Context, snap Snapshot, res Result, cause
ctx = context.WithoutCancel(ctx)
res.RolledBack = true
u.log("rollback: restoring snapshot %s over %s", snap.ID, u.cfg.InstallDir)
if err := snap.Restore(u.cfg.InstallDir); err != nil {
if err := u.restoreBinaries(snap); err != nil {
u.log("rollback: RESTORE FAILED: %v", err)
return res, fmt.Errorf("%w: %v (after %v); the previous artifacts are in %s — copy them back by hand", ErrRollbackFailed, err, cause, snap.Dir())
}
// On a deployment that rebuilds from source, putting the binaries back is
// the part that changes nothing. The source is what the restart deploys, so
// it goes back too, and it goes back before the restart that reads it.
if err := u.restoreSource(ctx, snap); err != nil {
u.log("rollback: SOURCE CHECKOUT FAILED: %v", err)
return res, fmt.Errorf("%w: %v (after %v); the tree is still on the new commit, so a restart would redeploy it — `git -C %s checkout --force %s` by hand", ErrRollbackFailed, err, cause, u.cfg.SourceDir, snap.Commit)
}
// A restore with no restart leaves the failed process running, so a failed
// restart here is still the manual-recovery case.
if err := u.restart(ctx, &res); err != nil {
@@ -197,6 +229,48 @@ func (u *Updater) restart(ctx context.Context, res *Result) error {
return nil
}
// restoreBinaries puts back the built artifacts and nothing else.
//
// ConfigFiles are snapshotted and deliberately not restored. The Config doc
// says an update never replaces the operator's config, and a rollback that
// quietly reverted deploy/mavend.json would undo edits made since the last
// apply — a phraser.model_path change among them, which is how the resident
// model gets swapped. The copies stay in the snapshot dir for him to take by
// hand if the config is what he wants back.
func (u *Updater) restoreBinaries(snap Snapshot) error {
return snap.RestoreOnly(u.cfg.InstallDir, u.cfg.Binaries)
}
// restoreSource puts the working tree back on the commit the snapshot was taken
// at, for the deployments where that is what the restart command deploys. A
// no-op for every other shape.
func (u *Updater) restoreSource(ctx context.Context, snap Snapshot) error {
if u.cfg.SourceRollback != "git" {
return nil
}
if snap.Commit == "" {
return fmt.Errorf("snapshot %s records no commit, so there is nothing to check out", snap.ID)
}
u.log("rollback: checking %s back out to %s", u.cfg.SourceDir, snap.Commit)
// --force because the failed build left artifacts in the tree. Safe only
// because Apply refused to start on a dirty tree, so nothing uncommitted of
// his is in reach.
out, err := u.run(ctx, u.cfg.SourceDir, []string{"git", "checkout", "--force", snap.Commit})
if err != nil {
return fmt.Errorf("git checkout %s: %v: %s", snap.Commit, err, tail(out, 1000))
}
return nil
}
// gitDirty reports whether the working tree has uncommitted changes.
func (u *Updater) gitDirty(ctx context.Context) (bool, error) {
out, err := u.run(ctx, u.cfg.SourceDir, []string{"git", "status", "--porcelain"})
if err != nil {
return false, fmt.Errorf("git status in %s: %v", u.cfg.SourceDir, err)
}
return strings.TrimSpace(out) != "", nil
}
// gitHead records which commit produced a snapshot, for the operator's benefit.
// Best-effort: a tree without git is not a reason to refuse to snapshot.
func (u *Updater) gitHead(ctx context.Context) string {
+42 -3
View File
@@ -2,6 +2,7 @@ package update
import (
"context"
"errors"
"fmt"
"time"
@@ -17,14 +18,39 @@ import (
// Presence is the method used because it is read-only (safe to retry), needs no
// arguments, and touches the store. It cannot write anything, so a health check
// never leaves a trace in her memory.
//
// A locked daemon is healthy. With a passkey enrolled and no env key mavend
// boots locked and refuses every CoreAPI method until an assertion arrives, so
// a Presence read there fails for a daemon that came up perfectly. Rolling back
// on that would turn a good update into the manual-recovery case, and the
// rollback would boot locked too. MethodPing reaches no store and answers in
// locked mode, so it is asked first: an answer of "locked" is proof of life and
// is where the check stops.
// DialHealth connects to the mavend socket and performs one read.
// ErrHealthDial — the socket could not be opened at all. Kept apart from a
// failed read because the two have different causes and different fixes: on the
// docker deployment the socket lives under /var/lib/docker, which is
// drwx--x--- root root, so a non-root operator gets EACCES before reaching
// mavend. "She is not answering" would be the wrong thing to tell him.
var ErrHealthDial = errors.New("update: cannot open the health socket")
// DialHealth connects to the mavend socket and proves someone is serving it.
func DialHealth(ctx context.Context, socket string) error {
c, err := ipc.Dial(socket)
if err != nil {
return fmt.Errorf("update: health dial: %w", err)
return fmt.Errorf("%w %s: %v", ErrHealthDial, socket, err)
}
defer c.Close()
// Liveness first, because it is the only question a locked daemon can
// answer. ErrUnknownMethod means an older mavend on the other end, which is
// exactly the case during a rollback to a build from before ping existed —
// fall through to the store read rather than calling that a failure.
switch p, perr := c.Ping(ctx); {
case perr == nil && p.Locked:
return nil
case perr != nil && !errors.Is(perr, ipc.ErrUnknownMethod):
return fmt.Errorf("update: health ping: %w", perr)
}
if _, err := c.Presence(ctx); err != nil {
return fmt.Errorf("update: health read: %w", err)
}
@@ -39,7 +65,20 @@ func (u *Updater) waitHealthy(ctx context.Context, timeout time.Duration) error
delay := 500 * time.Millisecond
var last error
for {
attemptCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
// Cap the attempt at whatever is left of the budget, not a flat 10s: an
// attempt starting at 89s of a 90s timeout would otherwise run to 99s,
// and the caller asked for 90.
attempt := 10 * time.Second
if left := deadline.Sub(u.now()); left < attempt {
attempt = left
}
if attempt <= 0 {
if last == nil {
last = context.DeadlineExceeded
}
return fmt.Errorf("update: not healthy after %s: %w", timeout, last)
}
attemptCtx, cancel := context.WithTimeout(ctx, attempt)
err := u.health(attemptCtx, u.cfg.HealthSocket)
cancel()
if err == nil {
+16 -1
View File
@@ -164,11 +164,26 @@ func (st *Store) Load(id string) (Snapshot, error) {
// This is the function the whole package exists to be able to run. It uses the
// filesystem and nothing else — no toolchain, no build, no cooperation from the
// code being replaced.
func (s Snapshot) Restore(dstDir string) error {
func (s Snapshot) Restore(dstDir string) error { return s.RestoreOnly(dstDir, nil) }
// RestoreOnly is Restore limited to the named files. A nil list means all of
// them. The caller uses it to put binaries back without putting config back:
// see Updater.restoreBinaries.
func (s Snapshot) RestoreOnly(dstDir string, names []string) error {
if s.dir == "" {
return errors.New("update: snapshot has no directory (load it through the store)")
}
var want map[string]bool
if names != nil {
want = make(map[string]bool, len(names))
for _, n := range names {
want[n] = true
}
}
for _, f := range s.Files {
if want != nil && !want[f.Name] {
continue
}
src := filepath.Join(s.dir, f.Name)
sum, err := hashFile(src)
if err != nil {
+193
View File
@@ -0,0 +1,193 @@
package update
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
)
// The deployment the README documents builds the image from the working tree:
// source_dir and install_dir are the same path, the Dockerfile copies cmd/ and
// internal/ and runs the build inside the builder stage, and .dockerignore
// keeps the host binaries out. Restoring binaries there restores bytes nothing
// reads. These tests pin the two acceptable outcomes: the source goes back, or
// the config is refused.
// dockerBox — a fakeBox wired the way the README wires the docker deployment.
func dockerBox(t *testing.T, sourceRollback string) (*fakeBox, Config) {
t.Helper()
b := newFakeBox(t)
// The source IS the deployment. The old commit's source is what the running
// image was built from.
b.byCommit["old-commit"] = "GOOD-SOURCE"
b.commit = "old-commit"
write(t, filepath.Join(b.root, "src", "source.go"), "BAD-SOURCE")
cfg := b.cfg()
cfg.InstallDir = cfg.SourceDir
cfg.ConfigFiles = nil
cfg.SourceRollback = sourceRollback
// The artifacts the docker shape snapshots live in the tree.
write(t, filepath.Join(b.root, "src", "mavend"), "OLD-BUILD")
return b, cfg
}
func TestValidate_RefusesABuildFromSourceDeploymentThatCannotRollBack(t *testing.T) {
_, cfg := dockerBox(t, "")
err := cfg.Validate()
if !errors.Is(err, ErrSourceRollback) {
t.Fatalf("Validate = %v; want ErrSourceRollback — a binary snapshot rolls back nothing when the restart rebuilds from the tree", err)
}
if _, err := New(cfg); !errors.Is(err, ErrSourceRollback) {
t.Fatalf("New = %v; want the same refusal", err)
}
}
func TestApply_RollbackPutsTheSourceBackBeforeTheRestart(t *testing.T) {
b, cfg := dockerBox(t, "git")
u, err := New(cfg, WithRunner(b.run), WithHealth(b.health))
if err != nil {
t.Fatal(err)
}
// The new build compiles and passes, and then does not come up — the case
// this whole package exists for.
first := true
b.healthFn = func() error {
if first {
first = false
return nil // preflight
}
if len(b.builtFromSource) > 0 && b.builtFromSource[len(b.builtFromSource)-1] == "GOOD-SOURCE" {
return nil // she answers again once the good source is deployed
}
return errors.New("she does not answer")
}
res, err := u.Apply(context.Background())
if !errors.Is(err, ErrRolledBack) {
t.Fatalf("Apply = %v; want ErrRolledBack", err)
}
if !res.RollbackHealthy {
t.Fatalf("result = %+v; want a healthy rollback", res)
}
if len(b.builtFromSource) != 2 {
t.Fatalf("restarts = %v; want the bad one and the rolled-back one", b.builtFromSource)
}
if b.builtFromSource[1] != "GOOD-SOURCE" {
t.Errorf("the rollback restarted on %q; want the previous commit's source — restoring binaries alone redeploys the bad commit", b.builtFromSource[1])
}
// And the checkout came before the restart, not after it.
var checkoutAt, restartAt = -1, -1
for i, c := range b.ran {
if strings.HasPrefix(c, "git checkout") {
checkoutAt = i
}
if c == "restart-the-thing" {
restartAt = i
}
}
if checkoutAt < 0 || restartAt < checkoutAt {
t.Errorf("commands ran = %v; want the checkout before the last restart", b.ran)
}
}
func TestApply_RefusesADirtyTreeWhenTheSourceIsTheRollbackTarget(t *testing.T) {
b, cfg := dockerBox(t, "git")
b.dirty = true
u, err := New(cfg, WithRunner(b.run), WithHealth(b.health))
if err != nil {
t.Fatal(err)
}
if _, err := u.Apply(context.Background()); !errors.Is(err, ErrDirtyTree) {
t.Fatalf("Apply on a dirty tree = %v; want ErrDirtyTree", err)
}
for _, c := range b.ran {
if strings.HasPrefix(c, "make") || c == "restart-the-thing" {
t.Errorf("a refused update still ran %q", c)
}
}
}
func TestApply_VerifyFailureDoesNotClaimARollback(t *testing.T) {
b := newFakeBox(t)
b.testErr = errors.New("exit status 1")
res, err := b.updater(t).Apply(context.Background())
if !errors.Is(err, ErrVerifyFailed) {
t.Fatalf("Apply = %v; want ErrVerifyFailed", err)
}
if res.RolledBack {
t.Error("a compile error reported rolled_back=true; nothing was installed and nothing was restarted")
}
}
func TestRollback_LeavesHisConfigAlone(t *testing.T) {
b := newFakeBox(t)
if _, err := b.updater(t).Apply(context.Background()); err != nil {
t.Fatalf("setup Apply: %v", err)
}
// He edits the config after the update — a phraser.model_path change, say.
cfgPath := filepath.Join(b.root, "install", "mavend.json")
write(t, cfgPath, `{"tick_interval":"90s"}`)
if _, err := b.updater(t).Rollback(context.Background(), ""); err != nil && !errors.Is(err, ErrRolledBack) {
t.Fatalf("Rollback: %v", err)
}
if got := read(t, cfgPath); !strings.Contains(got, "90s") {
t.Errorf("config after rollback = %q; a rollback must not revert his config", got)
}
if got := b.deployed(); got != "OLD-BUILD" {
t.Errorf("deployed binary = %q; want the binaries rolled back", got)
}
}
func TestVerify_RefusesToBuildHisTreeAsRoot(t *testing.T) {
b := newFakeBox(t)
u := b.updater(t, WithIDs(func(string) (int, uint32, error) { return 0, 1000, nil }))
if _, err := u.Verify(context.Background()); !errors.Is(err, ErrRootOnHisTree) {
t.Fatalf("Verify as root over a uid-1000 tree = %v; want ErrRootOnHisTree", err)
}
if len(b.ran) != 0 {
t.Errorf("the refusal still ran %v — root-owned artifacts would break his next make", b.ran)
}
// Root's own tree is fine, and so is a normal user.
for _, ids := range []func(string) (int, uint32, error){
func(string) (int, uint32, error) { return 0, 0, nil },
func(string) (int, uint32, error) { return 1000, 1000, nil },
} {
if _, err := b.updater(t, WithIDs(ids)).Verify(context.Background()); err != nil {
t.Errorf("Verify refused a legitimate build: %v", err)
}
}
}
func TestValidate_RefusesSnapshotsInsideTheSourceTree(t *testing.T) {
cfg := (&fakeBox{root: t.TempDir()}).cfg()
cfg.SnapshotDir = filepath.Join(cfg.SourceDir, "snaps")
if err := cfg.Validate(); err == nil {
t.Error("snapshot_dir inside source_dir was accepted; it lands in the docker build context")
}
cfg = (&fakeBox{root: t.TempDir()}).cfg()
cfg.SourceRollback = "svn"
if err := cfg.Validate(); err == nil {
t.Error("an unknown source_rollback was accepted")
}
}
func TestTail_CutsOnARuneBoundary(t *testing.T) {
s := strings.Repeat("x", 20) + "--- FAIL: TestПривет"
got := tail(s, 10)
if !utf8ValidString(got) {
t.Errorf("tail(%q) = %q; a cut mid-rune shows as a replacement character", s, got)
}
}
func utf8ValidString(s string) bool {
for _, r := range s {
if r == 0xFFFD {
return false
}
}
return true
}
+115 -12
View File
@@ -10,12 +10,16 @@
// - It is never automatic and never on a timer. There is no checker, no
// channel, no "check for updates" call and nothing that fires from the tick
// loop. Apply runs exactly when a human runs cmd/mavupdate on the box.
// - The daemon cannot update itself. mavend does not import this package and
// there is no IPC method and no web route that reaches it, so no act, no
// intent, no tool and no LLM output can start an update. The trigger needs
// shell access to the host, which is a strictly higher bar than the step-up
// passkey gate that guards /tools — an update is not a thing to expose to
// anything reachable over the network.
// - The daemon cannot update itself. mavend never constructs an Updater and
// nothing in the daemon can call Apply: there is no IPC method and no web
// route that reaches this package, so no act, no intent, no tool and no LLM
// output can start an update. (The package IS linked into mavend, via
// internal/config, which calls Config.Validate so a bad update block is
// caught at daemon startup rather than on the night it is needed. Linked is
// not reachable — the guarantee is the absent caller, not an absent
// import.) The trigger needs shell access to the host, which is a strictly
// higher bar than the step-up passkey gate that guards /tools — an update
// is not a thing to expose to anything reachable over the network.
// - It does not fetch code. Nothing here talks to a release server, a
// registry, or GitHub. The new version is whatever is in the working tree
// the operator points it at, which he pulled himself. Downloading and
@@ -32,13 +36,18 @@
//
// # The order of operations, and why
//
// Apply is: health-check the CURRENT daemon → build → test → snapshot → install
// → restart → health-check → rollback on any failure.
// Apply is: health-check the CURRENT daemon → snapshot → build → test →
// install → restart → health-check → rollback on any failure.
//
// The first health check is not ceremony. If she is already not answering, a
// failed update and a broken box are indistinguishable afterwards, and the
// rollback has nothing to prove itself against — so Apply refuses to start.
//
// The snapshot comes before the build, not after, and the comment in apply.go
// spells out why: `make build` writes into the working tree, which on the
// docker deployment IS the install dir, so a snapshot taken after it would
// snapshot the new artifacts.
//
// Build and test run BEFORE anything is written to the install dir, so a broken
// tree costs nothing but time. Install is per-file write-temp-then-rename, so a
// crash mid-install leaves whole files, not half ones.
@@ -49,6 +58,24 @@
// a migration, and does not need the update to have gotten far enough to leave
// a working anything behind.
//
// # What the snapshot has to cover
//
// A rollback is only real if it puts back the thing the restart command
// deploys. For a bare-metal layout that is the binaries in InstallDir. For the
// docker layout it is not: the image is built from the source tree, and the
// host binaries never enter it. Restoring binaries there rebuilds the same bad
// image and burns a second health timeout proving it. So a deployment that
// rebuilds from source must say how the source is put back
// (Config.SourceRollback), and one that cannot say is refused by Validate
// rather than discovering it during the one rollback that mattered.
//
// # What an update is not
//
// It is not turn-safe. Nothing quiesces the daemon first: the restart kills the
// process mid-utterance if one is in flight. The model swap in
// internal/phraser drains, because a swap is a routine operation on a running
// box; an update is a deliberate restart and the operator picked the moment.
//
// # What is out of scope on purpose
//
// The database is not snapshotted or rolled back. It is encrypted, live, and
@@ -84,6 +111,26 @@ var (
// so the previous snapshot was restored. Wraps the underlying failure.
ErrRolledBack = errors.New("update: rolled back")
// ErrSourceRollback — the deployment rebuilds from source, and nothing in
// the config says how to put the source back. Refused at Validate: see
// Config.SourceRollback.
ErrSourceRollback = errors.New("update: this deployment rebuilds from source and has no way to roll the source back")
// ErrDirtyTree — source_rollback is "git" and the working tree has
// uncommitted changes, so the recorded commit does not describe what is
// deployed and a checkout would throw work away. Refused before anything is
// built.
ErrDirtyTree = errors.New("update: the source tree has uncommitted changes — commit or stash them first")
// ErrRootOnHisTree — running as root over a tree owned by somebody else.
// Refused: Verify runs `make build` and `make test` in SourceDir, and as
// root that leaves root-owned binaries, object files and a build cache in
// his working tree. His next ordinary `make` then fails, so one root apply
// breaks the normal build. This fires easily, because the documented health
// socket lives under a root-only directory and sudo is the obvious way past
// that.
ErrRootOnHisTree = errors.New("update: refusing to build someone else's tree as root — it would leave root-owned artifacts and break his next make")
// ErrRollbackFailed — the worst case: the new build failed AND the restore
// did not bring her back. The operator has to fix the box by hand; the
// snapshot directory is named in the result so he knows what to copy.
@@ -105,18 +152,47 @@ type Config struct {
InstallDir string `json:"install_dir"`
// SnapshotDir — where the pre-install copies live. Must not be inside
// InstallDir: a restore reading from a directory the install is writing to
// is not a restore.
// InstallDir or SourceDir: a restore reading from a directory the install is
// writing to is not a restore, and a snapshot dir inside the source tree
// lands in the docker build context and in whatever make and git do there.
SnapshotDir string `json:"snapshot_dir"`
// SourceRollback — how the SOURCE is put back when the deployment rebuilds
// from it. "" means it is not, which is only valid when the built binaries
// are what gets deployed.
//
// This exists because of what a rollback has to undo, which is not always
// the binaries. When RestartCmd is `docker compose up -d --build`, the image
// is built by the Dockerfile from cmd/ and internal/, and the host binaries
// are excluded by .dockerignore. Restoring them then restores bytes nothing
// reads: the restart rebuilds the same bad image from the same bad source,
// and the box stays down through two health timeouts for no reason.
//
// "git" makes the source part of the snapshot: the commit is recorded before
// the update and a rollback checks it back out before restarting. It
// requires a clean tree, because a recorded commit does not describe a dirty
// one and a forced checkout would throw uncommitted work away.
//
// Validate refuses a build-from-source deployment (SourceDir == InstallDir)
// that leaves this empty, rather than letting the operator find out during
// the one rollback he needed.
SourceRollback string `json:"source_rollback,omitempty"`
// Binaries — the artifact names to snapshot and install, relative to
// SourceDir (built) and InstallDir (deployed). Listed explicitly rather than
// globbed so a stray file in the tree never gets deployed.
Binaries []string `json:"binaries"`
// ConfigFiles — extra files to snapshot alongside the binaries, relative to
// InstallDir. Snapshotted, never overwritten by an install: the operator's
// config is not something an update gets to replace.
// InstallDir. Snapshotted and never written back: not by an install, and
// not by a rollback either. The operator's config is not something an update
// gets to replace, and a rollback that reverted it would silently undo every
// edit since the last apply. The copies are in the snapshot dir if he wants
// one back.
//
// The exception is source_rollback "git": a checkout moves every tracked
// file, config included. That is the same rollback the deployment needs to
// work at all, so on that shape a config edit belongs in a commit.
ConfigFiles []string `json:"config_files,omitempty"`
// RestartCmd — how this deployment restarts mavend, e.g.
@@ -156,6 +232,17 @@ func (c Config) Validate() error {
if within(c.SnapshotDir, c.InstallDir) {
return fmt.Errorf("update: snapshot_dir %q is inside install_dir %q — a restore must not read from what the install writes", c.SnapshotDir, c.InstallDir)
}
if within(c.SnapshotDir, c.SourceDir) {
return fmt.Errorf("update: snapshot_dir %q is inside source_dir %q — snapshots would land in the build context, and in whatever make and git do to that tree", c.SnapshotDir, c.SourceDir)
}
switch c.SourceRollback {
case "", "git":
default:
return fmt.Errorf("update: source_rollback %q is not a thing — use \"git\" or leave it out", c.SourceRollback)
}
if c.buildsFromSource() && c.SourceRollback == "" {
return fmt.Errorf("%w: source_dir and install_dir are both %q, so the restart deploys the tree and a restore of the binaries would undo nothing. Set \"source_rollback\": \"git\", or split the layout so install_dir holds what actually runs", ErrSourceRollback, c.SourceDir)
}
if len(c.Binaries) == 0 {
return errors.New("update: binaries is empty — nothing to install")
}
@@ -186,6 +273,13 @@ func (c Config) withDefaults() Config {
return c
}
// buildsFromSource — the deployment whose restart command rebuilds from the
// tree, which is what SourceDir == InstallDir means in practice (install is a
// no-op copy and the artifacts that matter are produced inside the image).
func (c Config) buildsFromSource() bool {
return filepath.Clean(c.SourceDir) == filepath.Clean(c.InstallDir)
}
func (c Config) healthTimeout() time.Duration {
return time.Duration(c.HealthTimeoutSec) * time.Second
}
@@ -234,6 +328,9 @@ type Updater struct {
health HealthCheck
log Logger
now func() time.Time
// ids reports the running euid and the owner of a directory. Injected so
// the root-build refusal is testable without a second account.
ids func(dir string) (int, uint32, error)
}
// New builds an Updater. Every seam has a real default; the tests replace them.
@@ -248,6 +345,7 @@ func New(cfg Config, opts ...Option) (*Updater, error) {
health: DialHealth,
log: func(string, ...any) {},
now: time.Now,
ids: realIDs,
}
for _, o := range opts {
o(u)
@@ -266,5 +364,10 @@ func WithClock(f func() time.Time) Option {
return func(u *Updater) { u.now = f }
}
// WithIDs replaces the euid/owner lookup behind the root-build refusal.
func WithIDs(f func(dir string) (int, uint32, error)) Option {
return func(u *Updater) { u.ids = f }
}
// Snapshots lists what is available to roll back to, newest first.
func (u *Updater) Snapshots() ([]Snapshot, error) { return u.store.List() }
+41 -2
View File
@@ -32,11 +32,24 @@ type fakeBox struct {
healthErrs int // remaining failures to serve
healthy bool
healthChecks int
// healthFn overrides the scripted behaviour entirely, for tests whose
// verdict depends on what the last restart actually deployed.
healthFn func() error
// deployedAtRestart records the installed bytes each time restart runs, so a
// test can prove the rollback put the old bytes back BEFORE restarting.
deployedAtRestart []string
// builtFromSource records the source the restart would have built an image
// from, each time it runs.
builtFromSource []string
ran []string
// The git half, for the deployment whose restart rebuilds from the tree.
// commit is HEAD; byCommit is what each commit's source says; dirty makes
// `git status --porcelain` report uncommitted work.
commit string
byCommit map[string]string
dirty bool
}
func newFakeBox(t *testing.T) *fakeBox {
@@ -52,7 +65,11 @@ func newFakeBox(t *testing.T) *fakeBox {
write(t, filepath.Join(root, "install", "mavend.json"), `{"tick_interval":"60s"}`)
// The source tree already contains a stale binary; `make build` overwrites it.
write(t, filepath.Join(root, "src", "mavend"), "STALE")
return &fakeBox{t: t, root: root, newBytes: "NEW-BUILD", healthy: true}
return &fakeBox{
t: t, root: root, newBytes: "NEW-BUILD", healthy: true,
commit: "cafebabecafebabecafebabecafebabecafebabe",
byCommit: map[string]string{},
}
}
func (b *fakeBox) cfg() Config {
@@ -86,19 +103,41 @@ func (b *fakeBox) run(ctx context.Context, dir string, argv []string) (string, e
}
return "ok", nil
case "git rev-parse HEAD":
return "cafebabecafebabecafebabecafebabecafebabe\n", nil
return b.commit + "\n", nil
case "git status --porcelain":
if b.dirty {
return " M internal/router/router.go\n", nil
}
return "", nil
case "restart-the-thing":
b.deployedAtRestart = append(b.deployedAtRestart, read(b.t, filepath.Join(b.root, "install", "mavend")))
// The docker shape: the restart rebuilds the image from the tree, so
// what it deploys is the source, not any binary on the host.
if src, err := os.ReadFile(filepath.Join(b.root, "src", "source.go")); err == nil {
b.builtFromSource = append(b.builtFromSource, string(src))
}
if b.restartErr != nil {
return "no such container", b.restartErr
}
return "restarted", nil
}
if len(argv) == 4 && argv[0] == "git" && argv[1] == "checkout" && argv[2] == "--force" {
content, ok := b.byCommit[argv[3]]
if !ok {
return "error: pathspec did not match", errors.New("exit status 1")
}
b.commit = argv[3]
write(b.t, filepath.Join(b.root, "src", "source.go"), content)
return "HEAD is now at " + argv[3], nil
}
return "", errors.New("unexpected command: " + strings.Join(argv, " "))
}
func (b *fakeBox) health(ctx context.Context, socket string) error {
b.healthChecks++
if b.healthFn != nil {
return b.healthFn()
}
if b.healthErrs > 0 {
b.healthErrs--
return errors.New("connection refused")
+40 -1
View File
@@ -2,8 +2,12 @@ package update
import (
"context"
"errors"
"fmt"
"os"
"syscall"
"time"
"unicode/utf8"
)
// Verification is "does this tree build and does it pass its own tests", run
@@ -34,6 +38,9 @@ type Step struct {
// Verify runs the build and the test suite in SourceDir.
func (u *Updater) Verify(ctx context.Context) ([]Step, error) {
if err := u.refuseRootBuild(); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, u.cfg.verifyTimeout())
defer cancel()
var steps []Step
@@ -55,11 +62,43 @@ func (u *Updater) Verify(ctx context.Context) ([]Step, error) {
return steps, nil
}
// refuseRootBuild stops a sudo'd apply from building in a tree it does not own.
//
// The seam is injected so the tests can drive both sides without a second uid.
func (u *Updater) refuseRootBuild() error {
uid, owner, err := u.ids(u.cfg.SourceDir)
if err != nil || uid != 0 || owner == 0 {
return nil // not root, or root's own tree, or we cannot tell
}
return fmt.Errorf("%w: %s is owned by uid %d", ErrRootOnHisTree, u.cfg.SourceDir, owner)
}
// realIDs — the running uid and the owner of dir. Split out for the tests.
func realIDs(dir string) (uid int, owner uint32, err error) {
fi, err := os.Stat(dir)
if err != nil {
return 0, 0, err
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return 0, 0, errors.New("update: cannot read directory ownership")
}
return os.Geteuid(), st.Uid, nil
}
// tail keeps the last n bytes — a failing `make test` prints far more than is
// useful, and the failure is always at the end.
//
// The cut is nudged forward to a rune boundary. Russian test names and fixture
// strings are the common case in this tree, and a slice landing mid-rune starts
// the log with a replacement character.
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
cut := len(s) - n
for cut < len(s) && !utf8.RuneStart(s[cut]) {
cut++
}
return "…" + s[cut:]
}